Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55bc28a8e |
No files matched your search
@@ -9,8 +9,8 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.50.0",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.33.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -28,15 +28,12 @@ The frontend is plain JS served from `src/web/public/` with no bundler in dev: e
|
||||
CI runs all of these, so save yourself a round trip:
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
```
|
||||
|
||||
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
@@ -55,9 +52,7 @@ npm run test:all # literally everything, environmental failures included
|
||||
|
||||
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
|
||||
|
||||
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
|
||||
|
||||
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
|
||||
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
|
||||
|
||||
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ shared-host, multi-user, or tunneled deployments.
|
||||
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
|
||||
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
|
||||
|
||||
- **The local model runtime listens on a loopback port with no login.** The model server the local model install starts (macOS only, opt-in by a click) accepts requests from any process of any account on the Mac, and from any page served from `localhost`, like every model server of its kind. Codeman limits what that buys (one model in the endpoint record, a digest check before an agent is started on it) but does not authenticate the port. See `docs/security-architecture.md` §10d.
|
||||
- **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account.
|
||||
|
||||
Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Browser suite
|
||||
|
||||
# The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts),
|
||||
# so a browser-only regression can merge green. This job runs that suite on a schedule and on
|
||||
# demand, so such a regression (the Shift+Enter keypress bug was one) is caught within a day
|
||||
# instead of by a user. It is NOT a merge gate: a red run means "look", and it never blocks a
|
||||
# push or a PR.
|
||||
#
|
||||
# Needs: chromium (installed below), tmux, and the live server the tests start themselves.
|
||||
# Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and
|
||||
# codex-predictive-echo (needs a real codex binary; it also skips itself without one).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '29 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
browser:
|
||||
name: Playwright browser suite
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install tmux
|
||||
run: |
|
||||
if ! command -v tmux >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y tmux
|
||||
fi
|
||||
|
||||
- name: Install chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run the browser suite
|
||||
run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts
|
||||
@@ -34,13 +34,6 @@ jobs:
|
||||
- name: Frontend JS syntax check
|
||||
run: npm run check:frontend-syntax
|
||||
|
||||
# Asks `vitest list` what CI would actually collect, rather than matching
|
||||
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
|
||||
# (config/test-suites.ts) passes locally and dies in the test job with
|
||||
# "browserType.launch: Executable doesn't exist".
|
||||
- name: Browser-test exclusion check
|
||||
run: npm run check:browser-excludes
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
@@ -80,35 +73,6 @@ jobs:
|
||||
CLI_DETECT_DONE=""; detect_all_clis
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
|
||||
# --ai-cli answers the same menu with no terminal: `none` carries on, and a
|
||||
# name runs that one entry (bash 3.2 cannot expand an empty offer list under
|
||||
# set -u, which is the guard the loop over it has to get right).
|
||||
has_tty() { return 1; }
|
||||
AI_CLI_CHOICE=none
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
AI_CLI_CHOICE=codex
|
||||
k=0; while [[ $k -lt ${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
|
||||
CLI_INSTALL_CMD_TRUSTED[$k]=""
|
||||
k=$((k + 1))
|
||||
done
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer"
|
||||
# What the desktop app runs: preflight on stdout, and only JSON there. These
|
||||
# child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the
|
||||
# sourcing guard and print nothing. Each check ends in `|| exit 1`, because
|
||||
# bash 3.2 does not let a false [[ ]] trip set -e.
|
||||
out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin)
|
||||
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; }
|
||||
json_string "a\"b\\c" >/tmp/json-out
|
||||
[[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; }
|
||||
echo "bash $BASH_VERSION: preflight prints JSON"
|
||||
# How the desktop app adds an agent: a real run of the subcommand, which stops
|
||||
# on the missing npm before it could install anything.
|
||||
if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then
|
||||
echo "agent ran with no npm to run"; exit 1
|
||||
fi
|
||||
[[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; }
|
||||
echo "bash $BASH_VERSION: agent stops without saying done"
|
||||
'
|
||||
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
|
||||
# array, and on stock macOS there is no `timeout`, so the array is empty and the
|
||||
@@ -180,61 +144,6 @@ jobs:
|
||||
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
|
||||
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
|
||||
'
|
||||
# No-admin route: the helpers that decide what gets downloaded and whether it is
|
||||
# trusted (the Node.js checksum pick, the tmux digest table, the verify step), the
|
||||
# stop-before-download check, and an archive install plus its replacement, all
|
||||
# in bash 3.2. This image is Alpine, so it is also the one place the musl branch
|
||||
# runs for real: a machine the route cannot serve must stop BEFORE a download.
|
||||
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/h /tmp/src/Codeman-1/src
|
||||
. /w/install.sh
|
||||
sha=$(printf "%064d" 7)
|
||||
sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha")
|
||||
[[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]]
|
||||
if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then
|
||||
echo "accepted a checksum that is not 64 hex digits" >&2; exit 1
|
||||
fi
|
||||
for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do
|
||||
d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]]
|
||||
done
|
||||
printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]]
|
||||
verify_sha256 /tmp/f "$good"
|
||||
if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then
|
||||
echo "accepted a wrong or empty checksum" >&2; exit 1
|
||||
fi
|
||||
is_musl_libc
|
||||
NO_ADMIN=1; NONINTERACTIVE=1
|
||||
download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; }
|
||||
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; }
|
||||
check_build_tools() { return 0; }
|
||||
if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then
|
||||
echo "did not stop on a musl system" >&2; exit 1
|
||||
fi
|
||||
case "$out" in
|
||||
*DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;;
|
||||
*musl*) ;;
|
||||
*) echo "stopped for the wrong reason: $out" >&2; exit 1 ;;
|
||||
esac
|
||||
# An archive install, then its replacement beside it (busybox tar, bash 3.2).
|
||||
printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json
|
||||
tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1
|
||||
printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json
|
||||
tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1
|
||||
INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null
|
||||
download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; }
|
||||
CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap
|
||||
[[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]]
|
||||
grep -q 1.0.0 "$INSTALL_DIR/package.json"
|
||||
touch "$INSTALL_DIR/from-the-old-build"
|
||||
CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1
|
||||
[[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json"
|
||||
finish_source_swap
|
||||
grep -q 2.0.0 "$INSTALL_DIR/package.json"
|
||||
[[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]]
|
||||
NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]]
|
||||
echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok"
|
||||
'
|
||||
|
||||
- name: CLI catalogue artifacts are in sync with stock.ts
|
||||
run: npm run generate:cli-catalog -- --check
|
||||
@@ -303,8 +212,6 @@ jobs:
|
||||
run: npx vitest run
|
||||
working-directory: packages/xterm-zerolag-input
|
||||
|
||||
# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
|
||||
# that job is informational and never gates a push or a PR.
|
||||
# Note: three suites are excluded from CI, each with its own local runner:
|
||||
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
||||
# codex binary for codex-predictive-echo)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
name: Desktop app
|
||||
|
||||
# Checks the native macOS app in packages/desktop on the pull requests and master
|
||||
# pushes that change it: formatting, clippy and the unit tests, on macOS. No
|
||||
# secret and a read-only token, because cargo runs the build script of every
|
||||
# crate in Cargo.lock. The release build, signing and notarization live in
|
||||
# desktop-dmg.yml.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/desktop/**'
|
||||
- '.github/workflows/desktop-ci.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/desktop/**'
|
||||
- '.github/workflows/desktop-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Keep in step with desktop-dmg.yml, which builds the release with it.
|
||||
RUST_TOOLCHAIN: '1.99.1'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: fmt, clippy, test
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/desktop
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Rust toolchain
|
||||
run: |
|
||||
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component clippy --component rustfmt
|
||||
rustup default "$RUST_TOOLCHAIN"
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --check
|
||||
|
||||
# A test build bakes another installer in at compile time (option_env! in
|
||||
# setup.rs), so neither variable may reach cargo.
|
||||
- name: Clippy
|
||||
run: |
|
||||
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
|
||||
- name: Unit tests
|
||||
run: |
|
||||
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
|
||||
cargo test --locked
|
||||
|
||||
# /bin/bash on macOS is 3.2, the bash these scripts run under on a Mac.
|
||||
- name: Scripts parse on bash 3.2
|
||||
run: |
|
||||
for script in scripts/*.sh; do
|
||||
/bin/bash -n "$script"
|
||||
done
|
||||
@@ -1,399 +0,0 @@
|
||||
name: macOS app
|
||||
|
||||
# Builds Codeman.app (Apple Silicon + Intel), signs it with the Developer ID,
|
||||
# has Apple notarize the app and the DMG, and attaches Codeman.dmg and
|
||||
# Codeman.dmg.sha256 to a Codeman release. The asset name never changes, so
|
||||
# https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always
|
||||
# serves the newest app.
|
||||
#
|
||||
# Release calls this when npm took a Codeman release, with tag codeman@<version>,
|
||||
# and it builds the release commit. By hand, once this file is on master:
|
||||
# gh workflow run desktop-dmg.yml # dry run of master: artifacts only
|
||||
# gh workflow run desktop-dmg.yml --ref <branch> # dry run of a branch that carries this file
|
||||
# gh workflow run desktop-dmg.yml -f tag=codeman@1.50.0 # rebuild that tag, replace its Codeman.dmg
|
||||
# Only a run on master (or Release itself) attaches anything; every run leaves
|
||||
# the signed Codeman.dmg as an artifact on its run page. A rebuild takes only a
|
||||
# Codeman release tag whose commit is on master ("Check the tag", in build and
|
||||
# in sign).
|
||||
#
|
||||
# A dispatch runs its ref's copy of this file with the secrets, as every
|
||||
# repository secret reaches anyone with write access. A GitHub Environment
|
||||
# limited to master would not narrow the rebuild path (the job's ref is master
|
||||
# while it runs the tag's scripts) and would refuse the branch dry run above;
|
||||
# the tag check is what keeps a rebuild on master's history.
|
||||
#
|
||||
# Three jobs, so no job that holds a secret runs code from a crate:
|
||||
# - build runs cargo, and with it the build script of every crate in
|
||||
# Cargo.lock. It has a read-only token and nothing else: no secret, no cache.
|
||||
# - sign gets the built app as an artifact and runs only Apple's tools and this
|
||||
# repository's scripts on it. It alone holds the signing and notary secrets.
|
||||
# - attach has the one token that can write to a release, and no secret.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release to attach Codeman.dmg to (codeman@x.y.z); empty attaches nothing
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
secrets:
|
||||
MACOS_CERT_P12:
|
||||
description: The Developer ID Application certificate with its private key, a base64 .p12
|
||||
required: true
|
||||
MACOS_CERT_PASSWORD:
|
||||
description: The .p12's password
|
||||
required: true
|
||||
APPLE_ID:
|
||||
description: Apple ID of the developer team, for the notary service
|
||||
required: true
|
||||
APPLE_APP_PASSWORD:
|
||||
description: An app-specific password for that Apple ID
|
||||
required: true
|
||||
APPLE_TEAM_ID:
|
||||
description: The team ID the certificate belongs to
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to rebuild and attach to, e.g. codeman@1.50.0. Empty: dry run, artifacts only'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# What the macos-26 image and the Mac mini that built every tested DMG both ran
|
||||
# on 2026-11-08. Move it deliberately, together with desktop-ci.yml; Cargo.toml's
|
||||
# rust-version is the floor.
|
||||
RUST_TOOLCHAIN: '1.99.1'
|
||||
# The commit to build: a rebuild by hand builds the tag it attaches to; Release
|
||||
# (a push to master) and a dry run build the commit they ran on.
|
||||
BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' && format('refs/tags/{0}', inputs.tag) || github.sha }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Codeman.app
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.BUILD_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
# A rebuild by hand builds the tag it names, and sign then runs that tag's
|
||||
# scripts with the Developer ID key unlocked. So the tag must be a Codeman
|
||||
# release (another package's tag would put Codeman.dmg on its release), and
|
||||
# its commit must be on master's history, where those scripts were reviewed.
|
||||
# aicodeman@ is the tag a failed rename leaves (Release's retag step).
|
||||
# A tag can move between jobs, so build and sign each check their own
|
||||
# checkout. Inline on purpose: a script from the checkout would be the
|
||||
# tag's own code. Kept the same as the step in sign.
|
||||
- name: Check the tag
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
if ! [[ $TAG =~ $TAG_RE ]]; then
|
||||
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
|
||||
exit 1
|
||||
fi
|
||||
SHA=$(git rev-parse HEAD)
|
||||
# behind: an ancestor of master's tip; identical: the tip itself.
|
||||
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
|
||||
case "$STATUS" in
|
||||
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
|
||||
*)
|
||||
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Rust toolchain
|
||||
run: |
|
||||
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal \
|
||||
--target aarch64-apple-darwin --target x86_64-apple-darwin
|
||||
rustup default "$RUST_TOOLCHAIN"
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
# A release installs Codeman from getcodeman.com/install and master. A test
|
||||
# build bakes another installer in at compile time (option_env! in setup.rs),
|
||||
# so neither variable may reach cargo here, and setup.rs's tests pin the default.
|
||||
- name: Unit tests
|
||||
working-directory: packages/desktop
|
||||
run: |
|
||||
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
|
||||
cargo test --locked
|
||||
|
||||
- name: Bundle Codeman.app (signed ad hoc)
|
||||
working-directory: packages/desktop
|
||||
run: |
|
||||
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH SIGN_IDENTITY
|
||||
./scripts/bundle-macos.sh --universal
|
||||
|
||||
- name: Check the build
|
||||
working-directory: packages/desktop
|
||||
run: |
|
||||
BIN=dist/Codeman.app/Contents/MacOS/codeman-desktop
|
||||
lipo "$BIN" -verify_arch arm64 x86_64
|
||||
codesign --verify --strict dist/Codeman.app
|
||||
strings -a "$BIN" > "$RUNNER_TEMP/strings.txt"
|
||||
if grep -E 'raw\.githubusercontent\.com|test/desktop-' "$RUNNER_TEMP/strings.txt"; then
|
||||
echo "::error::A test installer URL or branch is baked into the app"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'https://getcodeman.com/install' "$RUNNER_TEMP/strings.txt"
|
||||
|
||||
# ditto keeps the bundle's modes, symlinks and extended attributes; an
|
||||
# artifact of the bare folder would not (upload-artifact sets every file to
|
||||
# 644). The artifact is a zip around this zip: download-artifact unpacks the
|
||||
# outer one and leaves the ditto zip as it was.
|
||||
- name: Archive Codeman.app
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/unsigned"
|
||||
ditto -c -k --sequesterRsrc --keepParent packages/desktop/dist/Codeman.app "$RUNNER_TEMP/unsigned/Codeman.app.zip"
|
||||
|
||||
- name: Upload the unsigned app
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codeman-app-unsigned
|
||||
path: ${{ runner.temp }}/unsigned/Codeman.app.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
|
||||
sign:
|
||||
name: Sign and notarize
|
||||
needs: build
|
||||
runs-on: macos-26
|
||||
# Two notary submissions; each usually takes minutes, and sign-macos.sh
|
||||
# gives each at most 45.
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
# For sign-macos.sh, make-dmg.sh and macos/entitlements.plist only. Nothing
|
||||
# here builds or runs code from the app or its crates.
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.BUILD_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
# The same check as in build, on this job's own checkout, before anything
|
||||
# here touches a secret. Kept the same as the step in build.
|
||||
- name: Check the tag
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
if ! [[ $TAG =~ $TAG_RE ]]; then
|
||||
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
|
||||
exit 1
|
||||
fi
|
||||
SHA=$(git rev-parse HEAD)
|
||||
# behind: an ancestor of master's tip; identical: the tip itself.
|
||||
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
|
||||
case "$STATUS" in
|
||||
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
|
||||
*)
|
||||
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Download the unsigned app
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: codeman-app-unsigned
|
||||
path: ${{ runner.temp }}/unsigned
|
||||
|
||||
# The build job ran every crate's build script, so its zip is checked before
|
||||
# this job, which holds the secrets, unpacks it: entries only under
|
||||
# Codeman.app/ (and the __MACOSX/ folder where ditto keeps attributes), no
|
||||
# "..", and no symlink, which the app never has and which could point an
|
||||
# unpack or a later write outside the app.
|
||||
- name: Unpack Codeman.app
|
||||
run: |
|
||||
ZIP="$RUNNER_TEMP/unsigned/Codeman.app.zip"
|
||||
ENTRIES=$(zipinfo -1 "$ZIP")
|
||||
LISTING=$(zipinfo "$ZIP")
|
||||
if printf '%s\n' "$ENTRIES" | grep -vE '^(Codeman\.app|__MACOSX)/' \
|
||||
|| printf '%s\n' "$ENTRIES" | grep -E '(^|/)\.\.(/|$)' \
|
||||
|| printf '%s\n' "$LISTING" | grep '^l'; then
|
||||
echo "::error::The build artifact holds an entry outside Codeman.app, a '..' or a symlink"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$RUNNER_TEMP/app"
|
||||
ditto -x -k "$ZIP" "$RUNNER_TEMP/app"
|
||||
if [ -n "$(find "$RUNNER_TEMP/app" -type l)" ]; then
|
||||
echo "::error::The unpacked app holds a symlink"
|
||||
exit 1
|
||||
fi
|
||||
test -x "$RUNNER_TEMP/app/Codeman.app/Contents/MacOS/codeman-desktop"
|
||||
|
||||
# A keychain of its own under RUNNER_TEMP, with a random password, holds the
|
||||
# Developer ID key for the length of this job. The last step deletes it and
|
||||
# the decoded .p12, whatever happened before. All five secrets are checked
|
||||
# here, so a missing one stops the job before any work.
|
||||
- name: Signing keychain
|
||||
env:
|
||||
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
|
||||
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
for name in MACOS_CERT_P12 MACOS_CERT_PASSWORD APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::The repository secret $name is empty or not set"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
|
||||
P12="$RUNNER_TEMP/codeman-signing.p12"
|
||||
trap 'rm -f "$P12"' EXIT
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -hex 32)
|
||||
echo "::add-mask::$KEYCHAIN_PASSWORD"
|
||||
|
||||
# Line breaks out first: an encoder that wraps its output (GNU base64
|
||||
# without -w0) leaves some that macOS's base64 refuses.
|
||||
printf '%s' "$MACOS_CERT_P12" | tr -d '\r\n ' | base64 --decode > "$P12"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
# Locks after six hours, not after the default five idle minutes, which
|
||||
# a notarization wait can outlast.
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security import "$P12" -k "$KEYCHAIN" -f pkcs12 -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
|
||||
rm -f "$P12"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
|
||||
# First on the search list, ahead of the runner's own keychains.
|
||||
SEARCH=()
|
||||
while IFS= read -r line; do
|
||||
line=${line#*\"}
|
||||
line=${line%\"*}
|
||||
if [ -n "$line" ]; then SEARCH+=("$line"); fi
|
||||
done < <(security list-keychains -d user)
|
||||
security list-keychains -d user -s "$KEYCHAIN" "${SEARCH[@]}"
|
||||
|
||||
# The identity by its SHA-1 hash, which no other certificate can match.
|
||||
IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN")
|
||||
MATCHES=$(printf '%s\n' "$IDENTITIES" | grep '"Developer ID Application: ' || true)
|
||||
COUNT=$(printf '%s' "$MATCHES" | grep -c . || true)
|
||||
if [ "$COUNT" != 1 ]; then
|
||||
printf '%s\n' "$IDENTITIES"
|
||||
echo "::error::MACOS_CERT_P12 holds $COUNT valid Developer ID Application identities; it needs exactly one"
|
||||
exit 1
|
||||
fi
|
||||
HASH=$(printf '%s\n' "$MATCHES" | awk '{ print $2 }')
|
||||
IDENTITY=$(printf '%s\n' "$MATCHES" | sed 's/^[^"]*"\(.*\)"$/\1/')
|
||||
case "$IDENTITY" in
|
||||
*"($APPLE_TEAM_ID)") ;;
|
||||
*)
|
||||
echo "::error::The certificate in MACOS_CERT_P12 ($IDENTITY) is not one of team APPLE_TEAM_ID"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Signing as $IDENTITY ($HASH)"
|
||||
echo "SIGN_IDENTITY=$HASH" >> "$GITHUB_ENV"
|
||||
|
||||
# The notary login goes to notarytool on its command line (sign-macos.sh's
|
||||
# APPLE_ID path), in this job only, which already holds the signing key.
|
||||
- name: Sign, notarize and staple
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packages/desktop/scripts/sign-macos.sh "$RUNNER_TEMP/app/Codeman.app" "$RUNNER_TEMP/out"
|
||||
|
||||
# A dry run's result: codeman-dmg on the run page (gh run download <run-id>
|
||||
# -n codeman-dmg). attach takes it from here too.
|
||||
- name: Upload Codeman.dmg
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codeman-dmg
|
||||
path: |
|
||||
${{ runner.temp }}/out/Codeman.dmg
|
||||
${{ runner.temp }}/out/Codeman.dmg.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
|
||||
- name: Remove the signing keychain
|
||||
if: always()
|
||||
run: |
|
||||
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
|
||||
if [ -e "$KEYCHAIN" ]; then
|
||||
security delete-keychain "$KEYCHAIN" || true
|
||||
fi
|
||||
rm -f "$KEYCHAIN" "$RUNNER_TEMP/codeman-signing.p12"
|
||||
|
||||
attach:
|
||||
name: Attach to the release
|
||||
needs: sign
|
||||
# Release runs on pushes to master, so github.ref is master both there and for
|
||||
# a rebuild started by hand on master. A dry run from any other branch, or a
|
||||
# fork, attaches nothing.
|
||||
if: inputs.tag != '' && github.ref == 'refs/heads/master' && github.repository == 'Ark0N/Codeman'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download Codeman.dmg
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: codeman-dmg
|
||||
path: out
|
||||
|
||||
- name: Upload to the release
|
||||
id: upload
|
||||
working-directory: out
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REQUESTED_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
sha256sum -c Codeman.dmg.sha256
|
||||
TAG=$REQUESTED_TAG
|
||||
# Release's retag step ignores its own errors, so a release whose rename to
|
||||
# codeman@ failed still carries the tag changesets gave it.
|
||||
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
|
||||
case "$TAG" in
|
||||
codeman@*) TAG="aicodeman@${TAG#codeman@}" ;;
|
||||
esac
|
||||
gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null
|
||||
echo "::warning::Attaching to $TAG: there is no release $REQUESTED_TAG"
|
||||
fi
|
||||
gh release upload "$TAG" Codeman.dmg Codeman.dmg.sha256 --clobber -R "$GITHUB_REPOSITORY"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check the download links
|
||||
working-directory: out
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.upload.outputs.tag }}
|
||||
run: |
|
||||
EXPECTED=$(cut -d' ' -f1 Codeman.dmg.sha256)
|
||||
check() {
|
||||
curl -fsSL --retry 6 --retry-delay 10 --retry-all-errors -o "$RUNNER_TEMP/served.dmg" "$1"
|
||||
echo "$EXPECTED $RUNNER_TEMP/served.dmg" | sha256sum -c --quiet -
|
||||
echo "$1 serves this build"
|
||||
}
|
||||
check "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/Codeman.dmg"
|
||||
LATEST=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
|
||||
if [ "$LATEST" = "$TAG" ]; then
|
||||
check "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/Codeman.dmg"
|
||||
else
|
||||
echo "::warning::Latest is $LATEST, so releases/latest/download/Codeman.dmg does not serve $TAG"
|
||||
fi
|
||||
@@ -1,128 +0,0 @@
|
||||
name: Local model catalogue
|
||||
|
||||
# Checks every Monday that the local model catalogue (src/config/local-model-catalog.ts) and
|
||||
# the pinned model runtime (OLLAMA_RUNTIME_PIN in src/local-model/runtime.ts) still match what
|
||||
# the registry and Ollama's releases serve: a tag can be pushed again or removed, the build a
|
||||
# Mac downloads can change size, and Ollama ships new versions. The catalogue is compiled into
|
||||
# Codeman on purpose, so this job only reports; a change ships in a release like any other.
|
||||
# What it checks, and what a maintainer does with its issue: docs/local-models.md, "Keeping
|
||||
# the catalogue current".
|
||||
#
|
||||
# By hand: gh workflow run local-model-catalog.yml (the file must be on master for that,
|
||||
# and the schedule only ever runs master's copy). Locally: npm run check:local-model-catalog
|
||||
#
|
||||
# Two jobs, so the token that can write issues never sits next to `npm ci`:
|
||||
# - check installs the dependencies and runs scripts/check-local-model-catalog.mjs with a
|
||||
# read-only token. Exit 0 clean, 1 drift, 2 the check failed. On 2 the job fails and the
|
||||
# issue is not touched: an incomplete report must not open or update it.
|
||||
# - issue runs no code from the repository, only gh. On drift it opens the one issue titled
|
||||
# "Local model catalogue: weekly check", or puts this week's report into it when it is
|
||||
# already open. On a clean week it comments on that issue only when one is open.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A run by hand while the scheduled one is going must not open a second issue.
|
||||
concurrency: ${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check the catalogue and the runtime pin
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
status: ${{ steps.check.outputs.status }}
|
||||
report: ${{ steps.check.outputs.report }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
# The check needs tsx and zod, both plain JavaScript, so no install script runs here
|
||||
# (node-pty's build and the repository's postinstall included).
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run the check
|
||||
id: check
|
||||
env:
|
||||
# Read-only here. Sent to api.github.com only, so the release lookups are not rate limited.
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
npx tsx scripts/check-local-model-catalog.mjs > "$RUNNER_TEMP/report.md"
|
||||
code=$?
|
||||
set -e
|
||||
cat "$RUNNER_TEMP/report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
case "$code" in
|
||||
0) echo "status=clean" >> "$GITHUB_OUTPUT" ;;
|
||||
1) echo "status=drift" >> "$GITHUB_OUTPUT" ;;
|
||||
*)
|
||||
echo "::error::The check itself failed (exit $code), so the issue is left alone. The summary holds what it found."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# One line of base64, so nothing in the report can end the output early. An issue
|
||||
# body holds at most 65,536 characters; the report is a few KB.
|
||||
echo "report=$(head -c 60000 "$RUNNER_TEMP/report.md" | base64 -w0)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
issue:
|
||||
name: Keep the weekly issue current
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE_TITLE: 'Local model catalogue: weekly check'
|
||||
STATUS: ${{ needs.check.outputs.status }}
|
||||
REPORT_B64: ${{ needs.check.outputs.report }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
steps:
|
||||
- name: Open, update or comment on the issue
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The issues API, not the search index, so an issue opened a minute ago is found too,
|
||||
# and an exact title match, so a similar title never counts. The oldest open one wins.
|
||||
number=$(gh issue list --state open --limit 1000 --json number,title \
|
||||
| jq -r --arg title "$ISSUE_TITLE" '[.[] | select(.title == $title)] | sort_by(.number) | first | .number // empty')
|
||||
today=$(date -u +%Y-%m-%d)
|
||||
case "$STATUS" in
|
||||
drift)
|
||||
printf '%s' "$REPORT_B64" | base64 -d > "$RUNNER_TEMP/report.md"
|
||||
printf '\n---\n\nWritten by the [weekly check](%s). Fix the catalogue or the pin in a pull request and close this issue once that is merged; the next drift opens it again.\n' \
|
||||
"$RUN_URL" >> "$RUNNER_TEMP/report.md"
|
||||
if [ -n "$number" ]; then
|
||||
gh issue edit "$number" --body-file "$RUNNER_TEMP/report.md"
|
||||
gh issue comment "$number" --body "Week of $today: drift found again. The description now holds this week's report ([run]($RUN_URL))."
|
||||
else
|
||||
gh issue create --title "$ISSUE_TITLE" --body-file "$RUNNER_TEMP/report.md"
|
||||
fi
|
||||
;;
|
||||
clean)
|
||||
if [ -n "$number" ]; then
|
||||
gh issue comment "$number" --body "Week of $today: the check was clean ([run]($RUN_URL))."
|
||||
else
|
||||
echo "Clean, and no issue is open: nothing to do."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::The check job reported no status ('$STATUS')."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,85 +0,0 @@
|
||||
name: Publish to the MCP registry
|
||||
|
||||
# The official MCP registry does not follow npm, so every version is published to it.
|
||||
# Release calls this after npm took a release. To publish the version on master by hand,
|
||||
# after a failed run for example: gh workflow run publish-mcp.yml
|
||||
#
|
||||
# No secret and no browser login: the registry takes this repository's GitHub Actions OIDC
|
||||
# token as proof of the io.github.Ark0N/ namespace. That id-token right is why this job
|
||||
# runs nothing from the repository: it reads server.json and runs one pinned binary.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
# Bump both together, the digest from the release's asset. Never releases/latest.
|
||||
MCP_PUBLISHER_VERSION: v1.8.7
|
||||
MCP_PUBLISHER_SHA256: 00b21004149a4ce66f0782508ae4a2bce67d1bea395a8da0bd8028159fb59579
|
||||
REGISTRY_VERSIONS_URL: https://registry.modelcontextprotocol.io/v0/servers/io.github.Ark0N%2Fcodeman/versions
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Also what ends a run for a release that only moved xterm-zerolag-input.
|
||||
- name: Skip a version the registry already has
|
||||
id: check
|
||||
run: |
|
||||
VERSION=$(jq -r .version config/mcp-registry/server.json)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$REGISTRY_VERSIONS_URL/$VERSION")
|
||||
case "$STATUS" in
|
||||
200) echo "::notice::$VERSION is already in the MCP registry" ;;
|
||||
404) echo "needed=true" >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo "::error::The MCP registry answered $STATUS for $VERSION"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# The registry refuses a version npm does not serve yet, and reads mcpName from it.
|
||||
- name: Wait until npm serves this version
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.check.outputs.version }}
|
||||
run: |
|
||||
EXPECTED=$(jq -r .name config/mcp-registry/server.json)
|
||||
for _ in $(seq 1 60); do
|
||||
FOUND=$(npm view "aicodeman@$VERSION" mcpName 2>/dev/null || true)
|
||||
[ "$FOUND" = "$EXPECTED" ] && exit 0
|
||||
sleep 20
|
||||
done
|
||||
echo "::error::npm did not serve aicodeman@$VERSION with mcpName $EXPECTED within 20 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Download mcp-publisher
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
run: |
|
||||
ARCHIVE="$RUNNER_TEMP/mcp-publisher.tar.gz"
|
||||
curl -fsSL -o "$ARCHIVE" "https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION/mcp-publisher_linux_amd64.tar.gz"
|
||||
echo "$MCP_PUBLISHER_SHA256 $ARCHIVE" | sha256sum -c -
|
||||
tar xzf "$ARCHIVE" -C "$RUNNER_TEMP" mcp-publisher
|
||||
|
||||
- name: Publish
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
working-directory: config/mcp-registry
|
||||
run: |
|
||||
"$RUNNER_TEMP/mcp-publisher" login github-oidc
|
||||
"$RUNNER_TEMP/mcp-publisher" publish
|
||||
|
||||
- name: Confirm the registry lists it as latest
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.check.outputs.version }}
|
||||
run: |
|
||||
curl -fsS --retry 4 --retry-all-errors "$REGISTRY_VERSIONS_URL/$VERSION" \
|
||||
| jq -e '._meta["io.modelcontextprotocol.registry/official"].isLatest == true'
|
||||
@@ -14,11 +14,6 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
# Set only when aicodeman itself was published: `published` is also true for
|
||||
# a release that moved only xterm-zerolag-input.
|
||||
codeman_version: ${{ steps.codeman.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
@@ -65,98 +60,15 @@ jobs:
|
||||
# 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package
|
||||
# releases already exist by the time this step runs, so setting it here
|
||||
# is deterministic.
|
||||
#
|
||||
# The title and body are the page people land on from the update toast.
|
||||
# changesets copied the body from CHANGELOG.md as one long bullet, so it is
|
||||
# reshaped here (scripts/lib/release-notes.mjs). npm has already published by
|
||||
# now, so a failure in that must not stop the retag below: without the body
|
||||
# field the release keeps the text changesets wrote.
|
||||
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
|
||||
BODY_ARGS=()
|
||||
if node scripts/generate-release-notes.mjs "$VERSION" > "$NOTES_FILE"; then
|
||||
BODY_ARGS=(-F "body=@$NOTES_FILE")
|
||||
else
|
||||
echo "::warning::Keeping the changesets release notes for $VERSION"
|
||||
fi
|
||||
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-f tag_name="$NEW_TAG" \
|
||||
-f name="Codeman ${VERSION}" \
|
||||
-f make_latest=true \
|
||||
"${BODY_ARGS[@]}"
|
||||
-f name="$NEW_TAG" \
|
||||
-f make_latest=true
|
||||
fi
|
||||
|
||||
# Retag
|
||||
git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true
|
||||
git tag -d "$OLD_TAG" 2>/dev/null || true
|
||||
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
|
||||
|
||||
# The macOS app job below runs only for a Codeman release, so it reads the
|
||||
# version from the list of packages this run published. Never fails the job:
|
||||
# npm has published by now, and a bad list only means no app build.
|
||||
- name: Codeman version published by this run
|
||||
id: codeman
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
env:
|
||||
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
run: |
|
||||
VERSION=$(printf '%s' "$PUBLISHED_PACKAGES" | jq -r '.[] | select(.name == "aicodeman") | .version' || true)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Codeman version published: ${VERSION:-none (no macOS app build)}"
|
||||
|
||||
# releases/latest/download/Codeman.dmg (the Mac app's stable link) serves
|
||||
# whichever release holds Latest. GitHub gives Latest to the release created
|
||||
# last, and a publish that moved only xterm-zerolag-input creates one the
|
||||
# retag above never touches, so Latest goes back to the newest Codeman
|
||||
# release after every publish. A failure warns and leaves npm, the tag and
|
||||
# the MCP publish alone.
|
||||
- name: Keep Latest on the Codeman release
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
RELEASE_ID=$(gh release view "codeman@${VERSION}" --json databaseId -q .databaseId 2>/dev/null || true)
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "::warning::No codeman@${VERSION} release to mark Latest"
|
||||
elif ! gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" -f make_latest=true > /dev/null; then
|
||||
echo "::warning::Could not mark codeman@${VERSION} Latest"
|
||||
fi
|
||||
|
||||
# Its own job: this one alone holds id-token, and a registry failure shows as itself
|
||||
# without touching the npm publish or the tag above.
|
||||
mcp-registry:
|
||||
name: MCP registry
|
||||
needs: release
|
||||
if: needs.release.outputs.published == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/publish-mcp.yml
|
||||
|
||||
# Builds, signs and notarizes the Mac app and attaches Codeman.dmg to the
|
||||
# release (.github/workflows/desktop-dmg.yml), only when Codeman itself was
|
||||
# published. Its own job, so a failed DMG shows as itself and never touches
|
||||
# npm, the tag or the MCP publish; `gh workflow run desktop-dmg.yml -f
|
||||
# tag=codeman@<version>` reruns it. It gets the five secrets it uses and no
|
||||
# other: never `secrets: inherit`, which would hand it NPM_TOKEN.
|
||||
macos-app:
|
||||
name: macOS app
|
||||
needs: release
|
||||
if: needs.release.outputs.codeman_version != ''
|
||||
# The ceiling for the called jobs; only its attach job asks for write. A job in
|
||||
# desktop-dmg.yml that asks for a permission not granted here (id-token or
|
||||
# attestations for a provenance attestation, say) makes GitHub refuse this
|
||||
# whole Release run at startup, npm publish included: widen both together.
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/desktop-dmg.yml
|
||||
with:
|
||||
tag: codeman@${{ needs.release.outputs.codeman_version }}
|
||||
secrets:
|
||||
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
|
||||
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
@@ -8,9 +8,8 @@ DECISIONS.md
|
||||
# Written by install.sh into end-user clones when setup finishes
|
||||
.install-complete
|
||||
|
||||
# Dependencies. No trailing slash: `node_modules/` matches only a directory, so a
|
||||
# worktree's `node_modules` symlink to a shared install got committed (#658, #662).
|
||||
node_modules
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
@@ -85,23 +84,6 @@ tmp/
|
||||
# added there). No trailing slash so it still matches the symlink, not just dirs.
|
||||
/public
|
||||
|
||||
# Video never goes to GitHub (docs/media-assets.md). The only tracked moving
|
||||
# images are the GIFs a public page embeds, in docs/images/; masters, promo cuts,
|
||||
# drafts and captures live in the local media library pr/media/ (ignored above).
|
||||
# The promo branch tracks its Remotion render inputs, so those stay addable.
|
||||
# A deliberate exception elsewhere takes `git add -f`.
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mov
|
||||
*.m4v
|
||||
*.mkv
|
||||
*.avi
|
||||
*.gif
|
||||
!/docs/images/*.gif
|
||||
!/scripts/remotion/public/**/*.mp4
|
||||
!/scripts/remotion/public/**/*.webm
|
||||
!/scripts/remotion/public/**/*.gif
|
||||
|
||||
# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB)
|
||||
# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git.
|
||||
# (The gesture bundle itself, gesture-codeman.js, IS tracked — built from
|
||||
@@ -125,9 +107,7 @@ todo.md
|
||||
@fix_plan.md
|
||||
readme-preview.mjs
|
||||
|
||||
# Prompt uploads land here under each session working dir (runtime artifact);
|
||||
# .claude-images/ is where they landed before the move.
|
||||
.codeman-uploads/
|
||||
# Uploaded images land here under each session working dir (runtime artifact)
|
||||
.claude-images/
|
||||
|
||||
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
|
||||
|
||||
@@ -24,9 +24,8 @@ src/web/public/settings-ui.js
|
||||
src/web/public/sw.js
|
||||
src/web/public/terminal-ui.js
|
||||
src/web/public/voice-input.js
|
||||
src/web/public/upload.html
|
||||
scripts/remotion/
|
||||
|
||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||
CLAUDE.md
|
||||
src/web/CLAUDE.md
|
||||
docs/releasing.md
|
||||
@@ -12,6 +12,6 @@ Quick pointers:
|
||||
|
||||
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
|
||||
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
|
||||
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
|
||||
- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =`
|
||||
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
||||
- Never commit secrets or local state from `~/.codeman/`
|
||||
@@ -1,599 +1,5 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.50.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ### Thanks
|
||||
- @irisitymichaelgrundberg for the Files panel following an agent into a worktree outside the session's folder, and for the rules that keep that second folder narrow (#780).
|
||||
- @Randalix for keeping two tests independent of the host's locale and git ignore file (#776).
|
||||
|
||||
**Codeman for Mac (#763).** A small native macOS app opens Codeman in its own window: it opens the Codeman running on this Mac, starts it when it is stopped, installs it when it is missing, or connects to Codeman on another machine, with that password kept in the Keychain. It is one universal app for Apple Silicon and Intel on macOS 11.3 or later, and a client of the web UI like a browser tab, so the server and the API do not change for it. Every release from this one carries it as `Codeman.dmg`, signed with a Developer ID and notarized, so it opens after macOS's usual question about a downloaded app ([1.50.0's copy](https://github.com/Ark0N/Codeman/releases/download/codeman@1.50.0/Codeman.dmg)). The app keeps its own version (0.1.0) and does not update itself yet.
|
||||
|
||||
**Your MacBook's Apple logo blinks with your agents (#781).** On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, _View > Blink Apple Logo_ gives each agent state its own rhythm: one for _While Working_, one for _Waiting for You_ (a yellow tab) and one for _Needs Permission_ (a red tab), each picked from Calm, Slow, Wink, Heartbeat, Fast and Alarm. Set Heartbeat for work, Wink for a finished turn and Alarm (three sharp flashes, then a rest) for a permission dialog, and the lid tells you from across the room which one it is. All three are off until you choose one, the menu shows only on a Mac with that logo, and no style flashes more than three times a second. It uses a private macOS setting, so a macOS update can take the menu away.
|
||||
|
||||
**Install from the app, with no Terminal and no password (#773).** On a Mac without Codeman, the app runs the installer's no-admin route in its own window, shows the steps as a checklist and opens Codeman once it answers. If the Mac has no coding agent it offers one, and _Add a coding agent_ (also in the app menu) adds one later without touching a running Codeman. The This Mac tab now lists every Codeman of this user already running on the Mac. The install needs macOS 15 or a tmux that is already there; on an older Mac the app offers Terminal instead. For this, `install.sh` gained `preflight`, `agent <id>`, `--ai-cli` and `--progress`.
|
||||
|
||||
**The Files panel follows a worktree outside the session's folder (#780).** Since 1.49.0 the panel follows an agent into a worktree inside the session's folder. It now also follows one of the same repository beside it, such as `git worktree add ../feature`, when the session's own repository vouches for it, and files there preview, edit and open from terminal links like workspace files. It follows none while attachment workspace confinement is on, and none at or around a blocked or sensitive folder, in a Docker case workspace, at the home folder or above, or outside the owner's space in multi-user mode. The API stays compatible: `liveWorktree` and `root=live` keep their 1.49.0 meaning, and an outside worktree comes as the new `outsideWorktree` field, listed by `root=live-any` with absolute paths.
|
||||
|
||||
**A coding model that runs on your Mac (#784, experimental).** On an Apple silicon Mac, **Run a local model** on the welcome screen opens a window that shows which local coding model fits the machine and with which agent, installs a pinned Ollama runtime and the model, checks it, and ends on an agent session that runs on it through a Custom Model Endpoint. The catalogue covers every Apple silicon memory size from 8 to 512 GB, and every model carries memory figures of its own: a 9B coding model (labelled limited) from 18 GB, Qwen3.8 27B or Qwen3.6 35B-A3B from 36 GB, Ornith 1.5 122B from 128 GB, while 8 and 16 GB get a plain sentence on why nothing fits. _Recommended_ appears only for a pairing run on a Mac like yours, so far Qwen3.6 35B-A3B with OpenCode on an M5 Pro with 48 GB; every other figure is computed from the model's architecture and shown as an estimate. No account or API key is needed, and the session's prompts go to the model on this Mac rather than a cloud provider. On any other machine nothing is offered, and in multi-user mode only an admin can install. A weekly check in CI compares every catalogue model and the pinned runtime with what a Mac would download and opens one issue when something moved. The routes and their payloads may change in any release; the guide is `docs/local-models.md`.
|
||||
|
||||
**Claude in the cloud, a model on your Mac (#784, experimental).** The codeman skill can hand work to that local model: `local_model` says what is installed and whether it runs, `spawn_worker <name> local` starts a worker on it (and the runtime, when it is stopped), and `sendmark` gives the worker a task and reads back the answer it writes. A Claude Code session plans and reviews while the Mac does the bulk: summaries, first drafts, files that should stay on the machine.
|
||||
|
||||
**Ralph starts from Session Options (#783).** The start screen no longer has a Start Ralph Loop button. Behaviour change: start a Ralph loop from Session Options, Ralph / Todo. The wizard that button opened can no longer be reached.
|
||||
|
||||
**The Codeman agent skill is on by default (#787).** New sessions get the skill where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; none for Pi, GitHub Copilot or a plain shell), so an agent can start and drive workers without an install step. The copy ignores itself in git (a `.gitignore` of `*` inside it, written once and never overwritten), so it never shows up as a new file in your repository, and a symlinked `reference/` folder inside it is refused. **Behaviour change:** every App Settings save on 1.49.0 or earlier stored this switch as off whether or not you touched it, so the first start of 1.50.0 switches a stored off on, once per data directory, and the startup log says when it did. Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**: an off set after that first start stays off. Reload any tab left open from before the upgrade before you save App Settings there. Turning it off removes no copy already written; `codeman skill uninstall --case <name>` does.
|
||||
|
||||
**Fixes.**
|
||||
- **No developer tools window on a Mac without Xcode (#779).** There `/usr/bin/git` is Apple's placeholder, and the server ran it on the first page load, so a 1.49.0 no-admin install opened macOS's "install the command line developer tools" window right after it finished, and again after every server start. Every git the server starts now checks first and treats that placeholder as no git, and installing the tools later needs no restart.
|
||||
- **A new agent shows up as soon as the installer adds it (#778).** A running Codeman remembered a missing CLI for up to 5 minutes, so Run still said it was not installed after `install.sh agent <id>` or the app had added it. The installer now touches `~/.codeman/cli-installed`, and the server looks again when that file changes.
|
||||
- **The approvals list leaves a brand-new dialog alone (#782).** In a dialog's first 3 seconds, before Claude Code has drawn it, `GET /api/approvals` could resolve it, which cleared its red tab on every device and left the push notification's Approve button with nothing to answer. The list now waits as long as the inbox does.
|
||||
- **Custom model endpoints survive concurrent writes.** The endpoint routes and the 5-minute discovery sweep wrote `custom-model-hosts.json` through one shared temp file, so two at once could tear it or drop a record. Every write is serialized now, and a file that does not parse is refused with 409 instead of replaced. Behaviour change: the endpoint id `local-ollama` is reserved for the model Codeman runs on this Mac.
|
||||
- **Clearing a custom endpoint relaunches on the session's own model.** A selection no longer writes its launch model into the session's stored CLI config, so a cleared session starts on the model it was created with, not on the endpoint's model id against the CLI's own backend. Only API callers could reach this.
|
||||
|
||||
**Fixes applied while landing.** An adopted session never follows a worktree outside its folder, like a remote or Docker session (#780). The worktree test also keeps the system gitconfig away from the code under test (#776).
|
||||
|
||||
The Release workflow now builds, signs, notarizes and attaches `Codeman.dmg` (`.github/workflows/desktop-dmg.yml`, #785), publishes each version to the official MCP registry (`io.github.Ark0N/codeman`) with GitHub's OIDC token, and rebuilds the GitHub release page from this changelog. Pull requests that touch `packages/desktop` run `cargo fmt`, clippy and `cargo test` on macOS.
|
||||
|
||||
**Docs and tests.** The README, the Chinese README and the wiki's Installation page gained a section on the Mac app (#786), the Autonomous Loops page no longer points at the removed wizard, and `docs/local-models.md` covers the local model install. Two tests no longer depend on the host's locale or git ignore file (#776).
|
||||
|
||||
## 1.49.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 12024cc: ### Thanks
|
||||
- @dignfei for letting Codeman open the tmux sessions you started by hand, on this machine, in a container or over ssh (#510).
|
||||
- @Randalix for the remote launch fix (#770) and the pi wheel fix (#771), both from their own reports (#760, #758), and for Edit history on remote sessions (#766).
|
||||
- @opticon454 for the New folder button in Create New's picker (#761) and for Clone Repo's parent folder (#774).
|
||||
- @shenlvkang-collab for a Files panel scan that no longer lets one folder use up the limit (#614).
|
||||
- @irisitymichaelgrundberg for reporting that the Files panel stayed on the main checkout (#765), and for catching a `.git` open race in the fix.
|
||||
- @maminmsh for reporting right-to-left text in the Response Viewer (#715).
|
||||
|
||||
**Open the tmux sessions you started yourself (#510).** Tmux sessions you started by hand now show up on the home screen under Opened outside Codeman, and one click opens one as a tab, on this machine, in a container or over ssh. Closing the tab only detaches, so the session keeps running. Codeman did not start it, so respawn, Ralph, auto-clear, one-shot prompts and prompt uploads are refused for it, and when the session ends the tab lets go of it. Adoption is admin-only in multi-user mode. The scan runs only while the home screen is in a visible tab. Close adopted tabs before going back to 1.48.0 or older: an older server does not know a tab is adopted and drops all of those guards.
|
||||
|
||||
**Install without administrator rights (#768).** The installer can now finish on an account without admin rights: Node.js and tmux go in your home folder, the source comes as an archive when no working git is available, and `--no-admin` forces this route. An install without git updates by re-running the install line, and Linux still needs make, a C++ compiler and python3.
|
||||
|
||||
**Docker Compose starts in two commands (#573).** `bash docker/Start-Codeman.sh` asks three questions, writes `docker/.env`, starts the container and prints the URL once Codeman answers. It now waits for the server and exits 1 if it does not come up (`--no-wait` skips that). Behaviour change: `Start-Codeman.sh` and `Update-Codeman.sh` refuse to run while `CODEMAN_PASSWORD` is still the example value `changeme`, so a deployment on that value has to set a real password before its next restart.
|
||||
|
||||
**Codex sessions can drive other sessions (#753).** A new opt-in setting, **Allow network access** (App Settings, Codex, off by default), starts new local Codex sessions with `sandbox_workspace_write.network_access=true`, so the Codeman skill's calls reach this server. It has a real cost: commands in Codex's sandbox can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even with a password set, so the sandbox no longer contains a prompt-injected command. It is forced off without the privileged-commands grant in multi-user mode, scheduled jobs do not use it, and it does not change Codex's sandbox mode. A local Codex 0.162 or later also starts with `--no-daemon`, so a shared background server no longer stops a launch on an "incompatible feature settings" dialog, and commands inside Codex see their own session's `CODEMAN_*` variables. Version gates in the CLI registry are now measured against each CLI's own version.
|
||||
|
||||
**Persian, Arabic and Hebrew read right to left (#715).** In the Response Viewer and the File Viewer's markdown view, each paragraph, heading, list, quote and table takes its direction from its own first letter, so a right-to-left answer lines up on the right while an English paragraph beside it stays left to right. Code blocks, inline code and the file paths the viewer links on its own stay left to right and no longer decide a sentence's direction. The phone's Compose dialog does the same. The terminal pane is unchanged: xterm.js has no right-to-left support yet (xtermjs/xterm.js#701).
|
||||
|
||||
**Edit history for remote sessions (#766).** The Edit history panel now works for remote (SSH) Claude sessions. It reads transcripts over ssh only while the panel is open, keeps the diffs in the same private local store and never wakes a sleeping host. An unreachable host shows the saved edits with the ssh error, and images preview on click rather than as thumbnails.
|
||||
|
||||
**The Files panel follows the agent into a git worktree (#772, #765).** When an agent moves into a worktree part way through a session, the panel lists that worktree, follows the agent back out and shows a strip that names it, with a button to switch to the session's folder. Only a worktree inside the session's folder is followed, and file access stays confined to that folder.
|
||||
|
||||
**One big folder no longer hides the rest (#614).** The Files panel and its search now walk folders in a per-directory round-robin, so one large folder no longer uses up the 5,000-entry limit, and search results come back shallow-first. One listing stops opening folders after reading 250,000 entries. A folder the scan stopped short of, or one that did not answer, no longer looks empty: `GET /api/sessions/:id/files` marks it `incomplete: true` (additive), sets `truncated`, and the panel shows a dim "Not fully scanned" row.
|
||||
|
||||
**Fixes.**
|
||||
- **Remote sessions start when the remote home differs (#770, #760).** New sessions no longer die with `tmux: command not found` (exit 127). The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, and a remote session starts in the remote user's own shell. Behaviour changes: in a per-host `commands.*` override, `$` now expands on the far end; a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value or a remote host's `extraSshOptions` is passed literally, so write the value out. `commands.omp` and `commands.copilot` are accepted now, like every other mode. A csh or tcsh login shell starts without `-i -l`, which tcsh refuses, so it reads `.tcshrc` and not `.login`.
|
||||
- **Remote file reads work on fish, csh and tcsh hosts.** The Files panel, previews and Edit history failed on those login shells, because their reads were parsed by the login shell before `sh` saw them. Every remote file read now crosses it as one word of text. This also ends a case where a file name holding shell syntax ran as a command on those hosts, as the same ssh user the agent runs as.
|
||||
- **The pi mouse wheel survives a tab switch or reload (#771, #758).** pi now takes the same path as Copilot: the server strips the mouse enables and remembers them, and the wheel is forwarded from that record, on the primary pane, tiles, split Pane B and phones. A drag in fullscreen pi now selects text in the browser instead of using pi's own selection, and with Wheel Scrolls Local History on the wheel does nothing there. A pi in `tuiMode: regular` is unaffected.
|
||||
- **Clone Repo's custom folder is a parent now (#774).** The repository lands in `<folder>/<case name>`, as it does with Create New, and the preview shows the joined path. Behaviour change: 1.48.0 cloned straight into the folder you picked, so a name the case-name rule refuses, such as `/mnt/my.repo`, can no longer be reached from the tab. `path` on `POST /api/cases/clone` stays the exact target for scripts and mount points.
|
||||
- **New folder in the Create New and Clone Repo pickers (#761).** You can make the parent folder without leaving the dialog. Asking for one directly in the cases folder is refused, and the refusal points at the right tab.
|
||||
- **Linked cases hold up under concurrent writes (#764).** Every write to `linked-cases.json` is serialized, so two requests can no longer claim one name or lose each other's entry, and a damaged file is refused with a clear error instead of being overwritten, which used to unlink every other case. A local case's rename racing a new link is not covered.
|
||||
- **A session keeps its own home and account variables (#767).** A session that set `CLAUDE_CONFIG_DIR`, `DSH_HOME` or the like keeps it when a custom endpoint selection is cleared or switched, also after a Codeman restart. With its own `CLAUDE_CONFIG_DIR` it keeps both its conversation and its account.
|
||||
|
||||
**Fixes applied while landing.** An adopted session never falls back to a direct terminal when its wrapper cannot start, which would have run a local agent in `/tmp` (#510). Output from a foreign pane, container or ssh host is parsed in linear time, so it cannot stall the server (#510). A `.git` swapped for a FIFO or a symlink is refused at open (#772). A folder listing that does not answer is flagged instead of shown as empty (#614).
|
||||
|
||||
**Docs and tests.** Adoption, the no-admin install, the Compose first run and the new remote shell rows in the guides, wiki and READMEs, new zh-CN strings for the Allow network access warning, and the Playwright bump to 1.64.1 with the browser suite unpinned.
|
||||
|
||||
## 1.48.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4960bee: ### Thanks
|
||||
- @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735).
|
||||
- @Randalix for the Edit history panel (#693).
|
||||
- @aakhter for relative links that follow your `cd` (#740).
|
||||
- @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742).
|
||||
- @timkjr for a tab strip that keeps its shape whichever tab is selected (#748).
|
||||
- @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below.
|
||||
|
||||
**Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "<message>" [--urgent] [--file <path>]`, and files travel both ways. Claude learns the command through its system prompt. Setup is in `docs/discord-bridge.md`.
|
||||
|
||||
**Edit history panel (#693).** An opt-in header button (per device, off by default, never on phones) shows every Edit and Write a local Claude session made, subagents included, with each diff inline, plus the images a Bash call produced. It reads transcripts only when the panel asks, and keeps its 0600 per-session store only as long as the session's record.
|
||||
|
||||
**Custom endpoints go further (#752, #755, #757).** OpenCode and Codex now receive the endpoint's context window, Codex launches on the model you picked, and endpoint discovery reads each model's context size from `/v1/models`, with a per-endpoint default for gateways that state none. DeepSeek Harness moves to a per-session home with a generated patch, so it runs the exact model you chose. The model picker gained a search box and marks the model one of your running sessions is using on an endpoint that cannot report it, or says plainly that nothing is known.
|
||||
|
||||
**Clone into a custom folder, and make a folder from the picker (#744, #745).** Clone Repo can clone straight into a folder you pick, empty or not yet created, and registers it as a linked case. The path picker in Link Existing gained a New folder button, so a brand-new project folder no longer means a trip to a shell.
|
||||
|
||||
**Relative links follow your `cd` (#740).** A relative path printed in the terminal now resolves against the directory the session's pane is in, not the folder the session started in. Only the base changes: what can be opened is the same as before. Session state carries it as the new optional `paneCwd` field.
|
||||
|
||||
**Fixes.**
|
||||
- **File links keep the whole name (#735).** `.conf` files and parenthesised paths with spaces link as one file in the terminal and the Response Viewer, and a path wrapped over several rows stays clickable from every row. An ambiguous hard wrap tries the unspaced name first and looks up the spaced one only after a 404, on click, never on hover.
|
||||
- **A failed settings save says so (#736).** Any write the server did not store now reports the reason and keeps App Settings open, where only two kinds of refusal did before. Run mode and respawn preset writes warn too, and a failed tunnel stop no longer claims the tunnel stopped.
|
||||
- **Phone keys show a press (#737).** Every toolbar button, the voice, attach and settings icons and the accessory keys now react on all seven skins.
|
||||
- **The L-shaped tab strip holds its shape (#748).** A too-wide first tab is trimmed to the room beside the toolbar, so the layout no longer changes with the selected tab.
|
||||
- **Credential files stay out of the attachment routes.** `webhook.json`, `custom-model-hosts.json`, the generated custom-model configs and everything under `backups/` could still be read through the attachment routes, which in multi-user mode means by any user. A config backup was the widest gap, since it holds copies of `users.json` and `settings.json`. A custom `configBackupDir` is outside what the blocklist can name, so keep that folder out of reach of other users.
|
||||
- **The OpenCode Console breach notice is retired.** A new install has no record of having seen it, so every new OpenCode user was still being warned about an October incident.
|
||||
|
||||
**Fixes applied while landing.** `codeman notify --file` refuses a credential file such as `.env` or a key under `.ssh`, even inside the tab's own folder, and the Discord bot token cannot be read through the attachment routes (#741). A very wide table cell in an agent's last message no longer stalls the server, and a file sent from Discord is no longer reported as something the agent produced (#741). A `Bash(` row keeps its links, because it holds a command rather than a path, while `Read(` and `Update(` rows link a spaced name whole (#735). A model id the CLI cannot launch is refused before anyone is asked to evict a loaded model, on both the swap route and quick-start (#752). A cases-folder refusal in Clone Repo now points at Clone Repo, and OpenSSL's `SSL_connect` errors classify as unreachable again (#744). Abandoning a tunnel enable records the settings baseline (#736). A session's unreachable `DSH_HOME` is logged when the server's home is linked instead (#755). Clone Repo and New folder no longer risk a hang on a mount that stopped answering (#744, #745).
|
||||
|
||||
**Docs and tests.** Discord setup guide and wiki page, the Edit history and `paneCwd` API sections, the custom endpoint guide, and the architecture notes for the new subsystems. Two Docker and TUI test harnesses now pass under a root runner and on tmux 3.2a (#738, #742).
|
||||
|
||||
## 1.47.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1ab01bb: ### Thanks
|
||||
- @timkjr for five PRs: a wrapped tab strip that uses the space under the toolbar (#721), three fixes that stop Codeman reading and watching a remote session's folder on the local disk (#722, #727, #728), and a quieter build (#729).
|
||||
- @kdxcxs for bodyless requests that work through Cloudflare Tunnel (#725), traced through a real Access + Tunnel hostname.
|
||||
- @Hugo-DDT for a desktop dead key typed once and a Ctrl+V that stays a paste after a replay (#726), and @hedburgaren for reporting both in #716.
|
||||
- @opticon454 for rendered Markdown that fills a resized File Viewer (#730).
|
||||
- @JDProfresh for a middle click on a file path that opens the file (#731).
|
||||
|
||||
**The wrapped tab strip uses the room under the toolbar (#721).** When the classic header strip wraps onto more rows (automatically, or with the two-rows setting), only the rows beside the toolbar stop short of it now; every row below runs the full header width, so long tab names no longer end up one per row. A strip that fits on one row looks exactly as before, and a strip tall enough to scroll keeps the plain wrap.
|
||||
|
||||
**Fixes.**
|
||||
- **Bodyless requests work through Cloudflare Tunnel (#725).** cloudflared forwards a POST with no body as an empty chunked request with no Content-Type, which the server refused with `415` before the route ran, so actions such as opening a saved web tab failed through a tunnel. An empty, untyped POST under `/api/` now reaches its route exactly like a `Content-Length: 0` one; anything with a body or another method is still refused as before.
|
||||
- **A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716).** The soft-keyboard recovery for Android IMEs now stays off on a desktop (a fine pointer that is not Android), so a dead-key backtick no longer arrives twice. Plain-text Ctrl+V is now framed as a bracketed paste by Codeman itself, because xterm stops adding the markers once a terminal replay clears its paste mode, and a copied newline then submitted the prompt. The paste still scrolls to the bottom and clears the selection, in the main pane, grid tiles and the split pane.
|
||||
- **Remote sessions no longer touch the local disk at their path (#722, #727, #728).** A remote (SSH) session's folder is a path on the remote host, but the Ralph `@fix_plan.md` watcher and the image watcher watched it on this machine (logging `ENOENT` at every start, or watching an unrelated local folder of the same name), and the Ralph file routes read and wrote it locally. Both watchers now skip remote sessions (the Session Options image-watcher switch is hidden there), the Ralph menu's **Import from File** reads `@fix_plan.md` on the remote host over the same ssh path the File Viewer uses, and **Write to File** (and the `@ralph_prompt.md` write) is refused for a remote session instead of landing in a local folder.
|
||||
- **Rendered Markdown fills a resized File Viewer (#730).** The document kept a fixed 960px column from before the window could be resized, so it stopped reflowing once you dragged or expanded the window past it.
|
||||
- **A middle click on a file path opens the file (#731).** In the Response Viewer and the File Viewer's rendered Markdown, a middle click on a file path or an in-document link opened the dashboard root in a new tab. It now does what a left click does; real web links keep the browser's own middle click.
|
||||
- **No more duplicate-key warning on build (#729).** The Chinese translation table carried `Idle` twice.
|
||||
|
||||
**Fixes applied while landing.** A remote session's fix plan stays authoritative as it is for a local one, so a prose line such as "All tasks completed" cannot mark every todo done and end a remote Ralph loop when it cannot end a local one (#722). The Session Options image-watcher switch flips back and says so when the server refuses the change, instead of reporting success (#727). The remote `@fix_plan.md` read uses the File Viewer's own containment check rather than a copy of it (#728). ASCII-diagram blocks in the File Viewer's rendered Markdown follow the window instead of staying 720px wide (#730). A paste marker hidden inside another one (`ESC[20` + `ESC[201~` + `1~`) can no longer survive the strip and end the bracketed paste early, in Ctrl+V and in the phone composer alike, and an Android device with a mouse, a trackpad cover or a stylus keeps the soft-keyboard fixes, which turned off there because Chromium reports such a pointer as fine (#726).
|
||||
|
||||
**Docs and tests.** The L-shaped strip in the frontend notes and the architecture notes, the remote-session rows for the Ralph file routes and the two watchers in the remote-sessions guide and the architecture notes, and an overview of the empty-POST parser in its module.
|
||||
|
||||
## 1.46.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- de1aab9: ### Thanks
|
||||
- @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710).
|
||||
- @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687).
|
||||
- @opticon454 for the Fetch button and fetch interval in the Git status window (#714).
|
||||
- @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717).
|
||||
|
||||
**Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency.
|
||||
|
||||
**Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard.
|
||||
|
||||
**The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before.
|
||||
|
||||
**Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too.
|
||||
|
||||
**Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link.
|
||||
|
||||
**`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request.
|
||||
|
||||
**Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it.
|
||||
|
||||
**Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written.
|
||||
|
||||
**Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual.
|
||||
|
||||
**Fixes.**
|
||||
- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy.
|
||||
- **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session.
|
||||
- **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message.
|
||||
- **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865.
|
||||
|
||||
**Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707).
|
||||
|
||||
**Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill.
|
||||
|
||||
## 1.45.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ### Thanks
|
||||
- @opticon454 for five PRs: renaming a case and its folder with the live sessions handled (#680), a resizable line between the tabs and the header toolbar (#689), opt-in GPU and CPU clock readings in the header (#692), and two Codex wheel fixes measured against the real CLI, a first notch that moves the transcript (#699, from #697) and a wheel that no longer moves a selection menu's choice (#698, from #696).
|
||||
- @aakhter for five PRs: an agent picker on the Cmd+K New session row (#685), a login throttle that no longer locks a whole tunnel out after ten typos (#684), an SSE connect attempt that can no longer hang forever (#678), prompts that start with a hyphen (#677), and `req` made required on the session lookup helpers so an ownership check cannot be skipped by accident (#683).
|
||||
- @JDProfresh for a phone swipe that follows the order the strip draws (#682) and an inline Claude tab whose history loads instead of paging the composer after a server restart (#681), both found and tested on an iPhone.
|
||||
- @Randalix for the per-session agent inbox (#690), the shape agreed in Discussion #558.
|
||||
- @Hugo-DDT for closing the last two ways a key binding could move a client onto another session (#688), and @christianhaberl for reporting both in #671.
|
||||
- @johoja12 for remote hosts whose tmux lives in Homebrew or a per-user bin directory (#691), the follow-up promised in #645.
|
||||
|
||||
**Rename a case and its folder (#680).** The case manager has a Rename action. A case under `~/codeman-cases` is renamed as a name and folder; a linked case can rename only its name or its real folder too. Live sessions that have the folder open are listed and only stopped if you agree, the folder is locked while it moves so nothing starts in it half-moved, a folder another program holds open is reported instead of forced, and Claude Code's conversation history (including the Resume list), the case order, the last used case and the default session group follow the new name.
|
||||
|
||||
**A per-session agent inbox (#690).** Agents can leave each other messages without typing into anyone's pane: `codeman agent post <id> "<text>"`, `codeman agent inbox [--wait]` and `codeman agent ack <ids>`, over `POST`/`GET /api/sessions/:id/inbox`, `POST .../inbox/ack` and `GET /api/agent-inbox/summary`. A message stays until its receiver acknowledges it, so nothing is lost between a read and the ack, and `codeman agent ls` shows an INBOX column. Inboxes live in memory by default; **Keep agent inbox across restarts** (Settings, Cross-session features, off by default) writes them to a 0600 file in the data dir, kept for 12 hours.
|
||||
|
||||
**Pick the agent from Cmd+K (#685).** The palette's New session row shows the agent it will start, with arrows to change it (Left/Right while the row is highlighted). It starts on the Run button's mode, offers the same agents the Run menu offers for that case (a Docker case's container agents included), and launches once without changing the Run button.
|
||||
|
||||
**A resizable header toolbar (#689).** Drag the line between the session tabs and the header toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then (in the Compact style) their percentages as it narrows; double-click or Home resets it, the arrow keys move it, and the width is remembered on the device.
|
||||
|
||||
**GPU and CPU clock readings (#692, opt-in).** **GPU Stats** adds GPU load, graphics memory and the GPU clock after CPU and MEM (each only when the host reports it), and **CPU MHz** adds the live CPU clock. Both are in Settings, Header & Panels, off by default, and nothing is read unless the browser asks. NVIDIA is read through `nvidia-smi`, AMD through sysfs, and inside WSL any vendor through the Windows performance counters (no GPU clock there). The header also keeps polling its stats with no session open, so CPU and MEM no longer sit at `--`.
|
||||
|
||||
**Fixes.**
|
||||
- **A tunnel no longer locks everyone out after ten typos (#684, multi-user mode).** Behind `tailscale serve` or a tunnel every login arrives from one address, so ten wrong passwords from anyone used to lock every user out for 15 minutes. The per-address limit is now 50, and each account allows ten failed guesses per address, both checked before the password is verified. Another address can no longer lock an account out, a burst of parallel guesses counts in full, and identical logins in flight share one check, so a reload with many tabs open is never refused. Single-user mode is unchanged.
|
||||
- **The first wheel notch moves a Codex transcript (#699).** When a CLI does not track the mouse, Codeman turns the wheel into PageUp/PageDown, and it used to send nothing until half a screen of travel had built up: 5 notches in Chrome, 7 in Firefox. The first notch now pages at once and the notches after it repay it, so the page rate is unchanged.
|
||||
- **The wheel no longer moves a Codex menu's selection (#698).** At "Implement this plan?" one notch down used to land on "No, stay in Plan mode" and one up on "Yes, implement this plan", because those menus use the page keys. The wheel is now ignored while such a menu shows, in the main pane and in tiles, and pages the transcript again once it closes.
|
||||
- **History loads after a restart instead of paging Claude (#681).** After a server restart, a second visit to an inline Claude tab on a phone turned every swipe into PageUp/PageDown for Claude, so the composer jumped around and old output never showed. The swipe now loads tmux's history, as the first visit does.
|
||||
- **A phone swipe follows the strip (#682).** With the case or state layout, a sorted rail or named groups, swiping now moves to the session drawn next to the current one, not the next one in the hidden canonical order. Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, matching the Alt+N badges.
|
||||
- **Key bindings can no longer move a client onto another session (#688, fixes #671).** The tmux servers behind Docker cases and owned remote sessions keep only their send-prefix binding, as Codeman's own socket has since 1.44.0, and `codeman tui`'s Alt+1..9 switch only the TUI's own client: an Esc then a digit in a browser tab reaches its pane instead of switching it.
|
||||
- **Remote tmux found in Homebrew or a per-user bin (#691).** Every remote tmux call (availability check, launch, attach, discovery, capture, cleanup) now runs on one widened, quoted PATH, so a host whose tmux works only in an interactive shell links and launches, home directories with spaces included.
|
||||
- **Prompts that start with a hyphen (#677).** Text typed into a pane that began with `-` (`-x hello`, `--help`) was read by tmux as its own options and never arrived.
|
||||
- **A connect attempt that never opens is retried (#678).** After a phone resumes on a VPN or tailnet, an event stream could sit in "connecting" forever with no error to trigger a retry. Each attempt now gets 15 seconds before it is closed and retried with the usual backoff.
|
||||
|
||||
**Fixes applied while landing.** A case rename that fails half way now puts `settings.json` back too and says where the folder is; it merges Claude Code's history into a folder a deleted case of the new name left behind instead of silently leaving it at the old name, refuses a dangling symlink at the new name before any session is stopped, refuses to move a folder while a session is still being created in it, and the dialog says plainly that stopping closes the sessions (#680). The inbox keeps the mail of sessions on the reboot-restore offer and of a detached session that comes back, prints message text with control characters escaped, and flushes after the server stops accepting posts (#690). Cmd+K's agent list follows the case's own availability rule, so a Docker case can start a CLI only its container has, and with a single choice the row keeps the Run button's mode (#685). The divider's stored width can no longer go negative and Escape no longer resets it (#689). With the toolbar narrowed, the GPU, VRAM and clock readings keep their labels, so they can still be told apart from CPU and MEM, and at Compact's rings-only step a clock with nothing left to show is hidden instead of leaving a gap (#689, #692). The stats route no longer waits on Windows' RAM probe, and a hung `nvidia-smi` can no longer freeze the header's CPU and MEM readings: the `nvidia-smi` and Windows RAM probes now give up after 1.5 s (#692). A second `codeman tui` on a longer pts number can no longer remove the first one's guard, and a client that fails the guard gets its key instead of losing it (#688). A test that had started passing vacuously after the SSE change checks the connect URL again (#678).
|
||||
|
||||
**Docs and tests.** `req` is now required on `findSessionOrFail` and `findPersistedSessionOrFail`, pinned by a test that also checks its exact type (#683). New sections for the agent inbox, the case rename, the header divider and the host sensors, the SSE connect timer next to the staleness watchdog, the login buckets in the security docs, and the remote PATH rule in the architecture notes.
|
||||
|
||||
## 1.44.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ### Thanks
|
||||
- @opticon454 for five PRs: config backups you can compare and restore (#661, ten review rounds), a File Viewer you can resize, expand and walk Back and Forward through (#659), GitHub Copilot's premium-request quota in the plan-usage chip (#662), a warning when llama.cpp's parallel slots leave too little context per request (#658, from #641), and a bare `npx vitest` that can no longer write into your real `~/.codeman` (#660, from #640).
|
||||
- @JDProfresh for four PRs: the opt-in prompt-cache keep-alive (#666, step 2 of #554), Markdown links to file paths that open in the File Viewer (#665), a phone keyboard that stays up while you press accessory keys (#673), and a session swipe that no longer taps or scrolls the session you are leaving (#674), with the iPhone testing behind both phone fixes.
|
||||
- @christianhaberl for four PRs: `CODEMAN_MAX_SESSIONS` (#668), tmux prefix keys that can no longer move a browser tab onto another session (#669), multi-line prompts that keep their line breaks (#670), and rediscovered tmux sessions that keep their own folder (#667). Also for the earlier #224, #225, #226 and the session sidebar (#307), whose commits carried an agent identity that GitHub credits to another account.
|
||||
- @johoja12, a first-time contributor, for scroll batches that stay with the tab that made them, smoother trackpad scrolling and wheel scrolling in fullscreen Codex (#645), measured against the real CLI through five review rounds.
|
||||
- @ludovicfourrage for touch scrolling in apps that track the mouse themselves (#664), and the careful write-up in #663.
|
||||
- @aakhter for a default tab group per case (#676).
|
||||
|
||||
**Config backups (#661).** Codeman now keeps timestamped copies of the files that hold your setup: settings, CLI overrides, custom model endpoints, the webhook, linked cases and, in multi-user mode, the accounts. It takes one at startup and whenever one of them changes, into `~/.codeman/backups/config` by default. App Settings → System lets you pick the folder and the retention (keep N backups, drop ones older than N days), back up now, compare two backups or a backup with what is running (credentials are never shown, only whether they changed), and restore one. A restore first saves what it is about to replace, so it can be undone, and retention only ever deletes folders Codeman made and never the newest backup.
|
||||
|
||||
**Keep an idle Claude session's prompt cache warm (#666, opt-in).** Claude Code's prompt cache expires after an hour of quiet, and the next prompt then pays to re-read the whole conversation. Switch on **Keep the cache warm while idle** in a session's Respawn tab (or **Keep prompt caches warm** in App Settings → System → Automation for every new session) and Codeman sends a one-line keep-alive turn about five minutes before the cache would expire. It only does that when nothing else is going on: an empty prompt, no open dialog, nobody typing, no respawn, Ralph loop or waiting agent, no usage-limit pause. It stops on its own a while after your last prompt (**Keep warm for at most**, default 2 hours). A session started with it on also launches with Claude Code's own idle compaction off (Claude Code 2.1.290 or newer). The keep-alive raises no alert, push, webhook or Away-digest row, and the readout's Status line says what it is doing.
|
||||
|
||||
**A default tab group per case (#676).** Case settings has a **Default session group**: every new session for that case lands straight in that tab group, in the same write that creates it, so it never flashes in Ungrouped first. An explicit group on the create request and a spawned child following its parent still win. It is stored per user and survives a group rename.
|
||||
|
||||
**A File Viewer you can resize, with Back and Forward (#659).** Drag the corner grip to resize the window, or press Expand to fill the screen (remembered on this device). Following a link from one file to another builds a history that the ← and → buttons and Alt+← / Alt+→ walk through. Phones keep the full-screen viewer.
|
||||
|
||||
**Copilot's premium-request quota in the plan-usage chip (#662).** With the GitHub Copilot CLI installed and signed in, the header chip gains a Copilot row for the month's premium requests, with the counts and the reset date in the tooltip. Codeman reads it every 10 minutes from GitHub with the Copilot CLI's own sign-in token. It is on by default, like the Codex row, and turning Plan Usage Limits off in App Settings stops the request.
|
||||
|
||||
**More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668).** The cap of 50 concurrent sessions can be set at startup, from 1 to 462. A value that is not a positive integer falls back to 50, and the per-user cap in multi-user mode follows it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set.
|
||||
|
||||
**Multi-line prompts keep their line breaks (#670).** A prompt sent through the API with `useMux` used to arrive as one welded line (`line oneline two`). Its lines now go into the pane as one bracketed paste, followed by the Enter that submits them together. The senders that run unattended (cron, respawn's update and kickstart prompts, the auto-compact focus, `codeman agent send`, the TUI and the skill's `sendwait`) stay single-line and join lines with a space, because the submit check cannot yet recognise a multi-line paste in the composer.
|
||||
|
||||
**A warning for llama.cpp slots that split the context too thin (#658).** A custom llama.cpp model that runs several parallel slots splits its context between them, and four 40K-token Claude requests against a 4-slot, 131K pool all failed with "Context size has been exceeded". Discovery now records each model's slot count, and a Claude launch warns when one slot gets less than 40K tokens, naming the fix (`--parallel 1`, or a context large enough for every slot).
|
||||
|
||||
**Fixes.**
|
||||
- **tmux prefix keys stay out of browser tabs (#669).** Ctrl+B then `)`, `(`, `s` or `w` in a tab switched its tmux client to another session while the tab kept the first one's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, set at session start and at boot. This also means `Ctrl+B d` no longer detaches a terminal attached to that socket: `codeman tui` and `scripts/tmux-manager.sh` detach with F1.
|
||||
- **Scrolling stays with its tab, and fullscreen Codex scrolls (#645).** A queued wheel batch can no longer land in a tab you switched to, trackpads keep their fractional travel instead of dropping it, the first wheel report of a tab visit goes out at once, and Codex gets wheel reports while it has mouse tracking on (measured on Codex 0.163.0, where an approval prompt's selection is unaffected).
|
||||
- **Touch scrolling in apps that track the mouse (#664).** A finger drag and its fling now scroll a program in the pane that asked for SGR mouse reports (a TUI in a shell tab, for example) instead of scrolling a local buffer that cannot show it. Other mouse encodings are left alone.
|
||||
- **A session swipe only switches sessions (#674).** A sideways swipe on the terminal no longer also opens a link, taps a CLI menu or toggles the keyboard on the session you are leaving. A deliberate swipe gets 500 ms instead of 300, a selection or a second finger pauses it, and a scroll that starts on a sideways thumb arc still scrolls.
|
||||
- **The phone keyboard stays up (#673).** Arrow, Tab, Esc and the other accessory keys that act on the terminal no longer blur it for a moment, so the toolbar stops bouncing on every tap.
|
||||
- **Markdown links to files open in the File Viewer (#665).** A link like Codex's `[label](/abs/path.json)` in the response viewer now opens the file instead of a same-origin 404 tab, and its label keeps the prose font.
|
||||
- **Rediscovered sessions keep their folder (#667).** A tmux session Codeman finds again on its socket (after `mux-sessions.json` was lost) now gets the directory tmux reports for its pane instead of the server's own, so a respawn goes back to the right project.
|
||||
- **Web tabs send POST bodies to dashboards that read Content-Length.** Codeman's web-tab proxy now relays the browser's Content-Length, so a dashboard built on Python's `http.server` or a small Go or C server no longer sees an empty body on every POST made through a web tab.
|
||||
|
||||
**Fixes applied while landing.** A restore that fails partway now says which files it restored, which it could not and where the pre-restore copy is, re-reads what was written and refills the Settings form; backup passes list the manifests once, and a pre-restore copy equal to the newest backup is not made twice (#661). A failed keep-alive write is retried before the expiry instead of never, an unreadable `settings.json` is logged once instead of every minute for every session, the keep-alive's own hooks stay out of the run summary, and saving App Settings re-plans keep-alives only when their settings changed (#666). The Copilot quota read checks the setting and the sign-in token before it ever probes for the CLI, so a machine without Copilot runs no login shell every 10 minutes, and drops a header the Copilot CLI itself does not send (#662). The prefix-table reset still binds send-prefix on an empty table and no longer reruns on every session for a prefix tmux lists escaped (#669). Claude worktree sessions follow the case's default group like every other CLI's (#676). The remote-history PATH change that rode along in #645 was left for its own PR. Smaller touches: the Expand button keeps its label while pressed and Alt+← / Alt+→ are listed in the shortcut overlay and help (#659); the scroll log names xterm's mouse encoding (#664); the response-viewer link test covers `?query` and folder links (#665).
|
||||
|
||||
**Docs and tests.** A bare `npx vitest run` from the repo now uses the isolated CI config instead of running without the setup file, the helpers that delete test folders refuse to run outside a sandboxed HOME, and the temp-HOME teardown is bounded so a stuck writer cannot time the hook out (#660). New sections and lines for config backups, the keep-alive and the Copilot quota in the architecture notes and `CLAUDE.md`, a test that a deleted pane directory is never adopted (#667), the codeman skill preamble re-stamped 1.44.0, and browser tests for the touch-scroll and swipe fixes.
|
||||
|
||||
## 1.43.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ### Thanks
|
||||
- @opticon454 for five PRs: GitHub Copilot CLI as a run mode (#592), an Antigravity tab that shows when it is working (#648, reported in #639), a WS pill that no longer flickers on every paste (#647, reported in #638), custom-folder cases anywhere below a broad cases folder (#567, reported in #637), and the opt-in "Pin tab names on the CLI" setting (#646). Also for filing #637 to #641 with the cause already worked out, and for measuring Copilot and `agy` live instead of guessing.
|
||||
- @JDProfresh for four follow-ups to the document uploads in #621: a mixed pick, drop or paste that says how many files it skipped (#651), a text upload refused as "not UTF-8" instead of a type mismatch (#652), a parity test that keeps the browser's copies of the document list honest (#653), and link taps on a phone that land on the right row after a tab switch (#654).
|
||||
- @irisitymichaelgrundberg for clickable `.diff` and `.patch` paths (#644).
|
||||
|
||||
**Security notices for the CLIs you run.** Codeman can now warn you about a security incident in an agent CLI it runs, and it does so only on machines where that CLI is installed. After an update the warning sits inside the "Codeman updated" note, with a button to the vendor's own notice next to **See what's new**; on a new install, or if you missed it, it comes as a card of its own. It stays until you close it or open the link, and expires on its own. The first one is about OpenCode: on 17 October OpenCode reported unauthorized access to a legacy system, and provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.
|
||||
|
||||
**GitHub Copilot CLI is a run mode (#592).** Install `copilot` (`npm install -g @github/copilot`) and it appears in the Run menu, on the welcome screen, in cron and in CLI management. It runs in tmux like the other agent CLIs, shows working and idle and the model it reads off its own footer, takes the usual launch options (`copilotConfig`: model, resume, continue, and `allowAll`, which is clamped off for non-granted multi-user owners), works with Custom Model Endpoints through Copilot's BYOK variables, joins MCP sync, and works with `codeman agent spawn`, `send` and `read`. A plain drag selects text, so Auto Copy works, while the wheel and phone swipes scroll its transcript. Copilot's folder-trust dialog still needs one Enter the first time a folder is used.
|
||||
|
||||
**Pin tab names on the CLI (#646, optional).** A new App Settings switch, off by default, launches every Claude session with `--name <tab name>`, not only the ones you renamed, so Claude Code's prompt-box label, `/resume` entry and remote session list match your tabs. The cost, which the setting spells out: Claude stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. It applies from the next session start or respawn.
|
||||
|
||||
**Antigravity tabs show when they are working (#648).** An `agy` turn now marks its tab working and a finished turn reads as idle again. A shell command waiting for your approval keeps showing as working until you answer it.
|
||||
|
||||
**Fixes.**
|
||||
- **Custom-folder cases below the cases folder (#567).** Create New with a custom folder refused every folder anywhere below the cases folder. Now only the cases folder itself and its direct children are refused, since those are the only ones listed as local cases, so a broad `CODEMAN_CASES_PATH` such as `/mnt/user/Scripts` no longer blocks the whole tree. The refusal now says which of the two it hit.
|
||||
- **A steady WS pill (#647).** The ` · NB queued` suffix appears only once a backlog has lasted over a second, instead of flashing on every paste, IME commit or fast burst.
|
||||
- **Clickable diff and patch files (#644).** A `.diff` or `.patch` path is now a link in the terminal and the response viewer, so an agent's review diff opens in one click.
|
||||
- **Mixed uploads say what they skipped (#651).** A pick, drop or paste with some unsupported files attaches the rest and says how many it skipped; the Compose and paste-dialog pickers now report an unsupported pick instead of doing nothing.
|
||||
- **"Not UTF-8" for refused text uploads (#652).** A text file that fails the UTF-8 check is told it is not UTF-8 (or UTF-16, when it starts with that byte order mark) and to save it as UTF-8, instead of reading like a corrupt file.
|
||||
- **Link taps on a phone after a tab switch (#654).** With the view parked above the bottom, a URL typed into the prompt no longer opens on tap, and a link in the scrollback opens on its own row.
|
||||
|
||||
**Fixes applied while landing.** The Copilot run mode clamps every `COPILOT_*` variable that bypasses approval, runs code or moves the token for non-granted multi-user owners, keeps a divider row inside an answer as part of what `codeman agent read` returns, and its Docker docs no longer promise a token you cannot set there (#592). Antigravity's working line only matches at the start of a row, so an answer that quotes the spinner no longer holds the tab busy (#648). The custom-folder refusal names the cases folder itself and a direct child separately (#567). The WS pill reads its queue once per update (#647). The "Pin tab names" setting is translated for zh-CN and listed in the Settings Reference (#646). JSON files the page fetches at runtime (the update note, the security notices) are now served `no-cache` instead of cached for a year, so a later notice always reaches a browser that already has the old copy.
|
||||
|
||||
**Docs and tests.** New route tests for nested custom-folder cases and for Copilot's screen-read answers, a wrapped-composer touch test, stricter upload parity checks, and docs that move Antigravity and Copilot into the screen-checked idle group.
|
||||
|
||||
## 1.42.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1238316: ### Thanks
|
||||
- @JDProfresh for five PRs: prompt uploads that take documents (#621, from the plan in #553), a video Codeman cannot take refused before it uploads (#622), a prompt-cache readout kept to the CLIs that report one and a statusline report with many cache-miss causes no longer thrown away (#623), and two fixes for Claude's workspace trust dialog in a brand-new folder: idle waits that hold until the dialog is answered (#634) and a dialog that gets answered every time (#635).
|
||||
- @Randalix for Codex prompts that really get submitted, sent as one bracketed paste and a separate Enter (#628), and for the careful measurements in #627 that found the cause.
|
||||
- @ludovicfourrage, a first-time contributor, for reporting (#629) and fixing (#632) the nested tmux status bar colour that leaked into live output after a history reload.
|
||||
- @opticon454 for three PRs: a one-time "what's new" note after each update (#618), Previous and Next Session as rebindable shortcuts that refuse a chord another action already uses (#617), and a DeepSeek install from CLI management that ends with a DeepSeek you can run (#616).
|
||||
|
||||
**Git worktrees for a case (#625).** Give every agent on a repository its own checkout. Pick a case whose folder is a git repository, choose **Git worktrees…** in the case picker (**Worktrees** on the phone's case sheet), type a name and pick the CLI: Codeman creates `<case>/.claude/worktrees/<name>` on a new branch `worktree-<name>` and opens a session there, in a tab named after the worktree. **Open** starts another session in a worktree you already have. Removing one runs plain `git worktree remove`, never `--force`, so uncommitted work stays where it is, and Close Session can kill the session and remove its worktree in one go. The layout is the one Claude Code's own `claude --worktree` uses, so each sees the other's worktrees. To keep the main checkout clean, Codeman lists the worktrees folder and the files it writes for sessions in the repository's local `.git/info/exclude`; your `.gitignore` is never touched.
|
||||
|
||||
**Codex sessions turn red when Codex asks you something (#624).** Codex's command and edit approvals, its question dialogs and the trust prompt on a new repository are read straight off the screen and raised the way a Claude permission prompt is: red tab, desktop notification, push, webhook, the phone's NEEDS YOU row and an Approvals Inbox card that tells you to answer in the terminal. An agent driving a codex worker can now wait for that with `until=blocked`.
|
||||
|
||||
**Prompt uploads take documents (#621).** Attach, drop or paste a PDF, a Word, PowerPoint or Excel file, a zip, or a text file (txt, md, csv, tsv, json, log, yaml, xml) and its path lands on your prompt, ready to send, like a photo or a video does. Every file is checked against its actual bytes on the way in, so a program renamed to `.pdf` is refused, and text files must be UTF-8. The saved name keeps a readable part of the original: `Q3 report.pdf` is saved as `paste-...-Q3-report.pdf`. The button in the Compose and paste dialogs now says Attach.
|
||||
|
||||
**Previous and Next Session are rebindable (#617).** Previous Session (Alt/Option+[) and the alternate Next Session (Alt/Option+]) are now ordinary rows in App Settings > Shortcuts, so you can rebind or switch them off like everything else. Capturing a chord another action already uses is refused with a message naming that action (Alt+1 to 9, Ctrl/Shift+Enter and Ctrl/Cmd+V stay reserved), and re-enabling or resetting a shortcut runs the same check. A chord you rebound no longer also types into the terminal: a rebind to Alt+J used to switch tabs and send ESC j to the session too. Built-in defaults like Ctrl+L still reach the CLI.
|
||||
|
||||
**A note after each update (#618).** The first time a browser loads a newer Codeman, a toast lists that release's headline changes, with a **See what's new** button that opens the full release notes. It shows once per version, and a browser that has never opened Codeman before just records the version and stays quiet, so the first note you will see is the one for the release after this one. The headlines are taken from the changelog when the release is cut.
|
||||
|
||||
**Multi-user mode: only admins change server-wide settings.** In multi-user mode any signed-in regular user could rewrite the server's settings (feature switches, run mode, case order, the default model for cron and the orchestrator, the app config), start a self-update, rotate the tunnel QR code, clear stale session state, and sign every user out with Revoke All, admins included. They could also read the Deepgram API key an admin saved. Those writes and operations now answer 403 to a regular user, Revoke All signs them out of their own logins only, and their settings read leaves out the admin's key and case names. In the browser a regular user's per-device settings still save, a change to a server-wide setting shows one notice that only an admin can change it, instead of pretending it was saved, and their own run mode and respawn presets are kept on their device. Single-user mode is unchanged.
|
||||
|
||||
**Fixes.**
|
||||
- **Codex submits long prompts (#628).** Prompts sent through the API, `codeman agent send`, the MCP server, cron or the agent skill go into the pane as one bracketed paste followed by a separate Enter, so Codex no longer leaves them sitting unsent in its composer. A single character (a menu answer like `3` or `y`) is still typed, so numbered dialogs keep picking the option you sent, and a trailing `;` survives, so `find . -exec rm {} \;` arrives whole.
|
||||
- **New Claude sessions in an untrusted folder (#634, #635).** An agent waiting for a fresh session to go idle no longer gets its answer while the workspace trust dialog is still up, so its first prompt can no longer press Enter on the highlighted "No, exit" and kill the session it just started. And the dialog is now answered every time: a screen read that came too soon after the previous one used to be dropped, which now and then left a new session sitting on the dialog. Deleting a session while the dialog is up also stops Codeman reading its dead pane for seconds afterwards.
|
||||
- **Nested tmux colours after a history reload (#632).** Opening a remote or Docker case, or a shell running tmux inside it, no longer hands the nested tmux status bar's green background to whatever the agent prints next.
|
||||
- **The codeman skill with arguments (#642).** The skill works again when Claude Code loads it with arguments ("use codeman to start 5 claude workers"). Claude Code swaps a skill's `$1` and `$2` for words of your request, which broke the pasted helpers; they now use `${1}` and `${2}`, and a cached copy written by the broken version is rewritten on the next run.
|
||||
- **DeepSeek installs ready to run (#616).** Installing the terminal profile finds the `pnpm` next to `dsh` even when the server's own PATH lacks `~/.local/bin`, and the DeepSeek install command adds `pnpm` when the machine has none.
|
||||
- **Unsupported videos refused up front (#622).** An Android `.3gp` or an `.mkv` gets "Unsupported file type" right away, instead of uploading up to a gigabyte and failing at the end.
|
||||
- **Statusline reports (#623).** The prompt-cache readout is stored only for CLIs whose statusline reports one (Claude today), like the model badge, and a Claude report with more than 20 cache-miss causes keeps the first 20 instead of being refused, which used to blank the footer and the plan-usage chip.
|
||||
|
||||
**Fixes applied while landing.** Removing a worktree that is, sits inside or holds a Docker case's workspace is refused before git runs; it would have deleted the folder a container still mounts (#625). Codex's trust prompt also reads as blocked in the variant reached through its "Update available" screen, which ends in "esc back" (#624). The DeepSeek install only adds `pnpm` when none is on PATH, so a machine with a corepack `pnpm` keeps it instead of the whole install failing (#616). Three statements in the upload docs were corrected: only a local agent and a Docker case share the uploads folder (remote sessions are refused), GIFs keep their frames instead of being re-encoded, and a developer note no longer names a release that never shipped (#621). The update note skips the Thanks list and group labels like "Fixes.", still works for a release with no headlines, and is translated for zh-CN (#618).
|
||||
|
||||
**Docs.** The README and its Chinese translation caught up with 1.41.0 and 1.41.1 and show a sharper hero GIF, the Codeman Skill got a wiki page and two GIFs linked from both READMEs, CLAUDE.md was split into the root file, `src/web/CLAUDE.md` and `docs/releasing.md`, the release steps now include the MCP registry publish, and a new note says where video and GIF masters live (not on GitHub).
|
||||
|
||||
## 1.41.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ### Thanks
|
||||
- @timkjr for four PRs: shells that reload from tmux's rendered history instead of replaying old full-screen repaints (#601), the full scrollback of a remote SSH session read from the remote tmux (#602) with no rows lost at the join (#609), and a phone shell Paste that inserts a multi-line block instead of running it line by line (#600).
|
||||
- @aakhter for three PRs: Codex panes that page their transcript with the wheel and touch (#527, fixes #508), new and spawned tabs that land in tab groups (#528), and a Focus section of shortcuts at the top of the vertical rail (#582).
|
||||
- @JDProfresh for three PRs: Claude Code's prompt-cache state per session (#607, step 1 of #554), the phone Attach button with camera-roll video uploads (#606, part two of #553), and an upload that no longer stalls the server on a dead network mount (#605).
|
||||
- @Randalix for clickable `~/`, relative and hard-wrapped file paths plus a folder grid for browsing a whole folder of renders (#604, closes #603).
|
||||
- @opticon454 for three PRs: a Working folder row with a Copy button in Session Options (#593), tab colour, pin and image watcher surviving a restart (#591), and Link Existing saying why it refused a case (#590).
|
||||
- @ideaCompany, a first-time contributor, for rendering local HTML files as real pages in the File Viewer (#547), and for the careful security write-up behind it.
|
||||
- @zomby1, a first-time contributor, for OSC 52 clipboard support (#611).
|
||||
- @Hugo-DDT, a first-time contributor, for two PRs: MCP server sync reading Gemini's bare `url`, OpenCode's V2 layout and `opencode.jsonc` (#599, fixes #586), and DeepSeek permission prompts that no longer offer Approve/Deny buttons that cannot work (#610, from #598).
|
||||
|
||||
**HTML files open as pages, and markdown opens rendered from the terminal (#547).** Click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer now shows it as a real page, with its own scripts, styles, images and data files from its folder, instead of raw source. A **Page** pill switches to the source and its Edit button. The page runs in a sandbox with an opaque origin: it cannot read Codeman's cookies, use Codeman's API or read files outside its own folder. Network access is open, so a dashboard that loads Chart.js or fonts from a CDN works as it would opened from disk. Broad folders (`/`, home, `/tmp`, a drive's mount point) are refused, so put a report in a folder of its own. And a `.md` or `.markdown` path clicked in the terminal now opens the rendered markdown viewer everywhere (main terminal, tiles, split pane, touch), instead of the live log viewer that showed "streaming"; the MD pill still gives the source. Logs, `.txt`, `.json` and code keep the live log viewer.
|
||||
|
||||
**`codeman mcp`: drive Codeman from any MCP client (#615).** A stdio Model Context Protocol server with seven tools (list, start, send, wait, read, interrupt, delete), so Claude Code, Codex, Gemini CLI, Cursor or Claude Desktop can start worker sessions in any installed agent CLI, prompt them, wait for them and read what they did. It is a client of the existing HTTP API with the same guards as `codeman agent`: it refuses control characters in input, never acts on the session it runs in, and by default only touches sessions it started. Setup lines for Claude Code, JSON clients and Codex are in the README and the wiki page "Driving Codeman From An Agent".
|
||||
|
||||
**The codeman skill for every agent CLI (#613).** With **Codeman Agent Skill** on (App Settings > Agents & CLIs > All agent CLIs, still off by default), Codex, Gemini CLI, OpenCode, Antigravity, Grok Build, DeepSeek Harness and OMP sessions now get the same `codeman` skill Claude Code had, in `.agents/skills/codeman`, so an agent in any mode knows how to start and manage worker sessions. `codeman skill install --mode <cli>` installs it by hand.
|
||||
|
||||
**Tiles: move them by hand, or tear one off into its own window (#584).** With the camera gesture overlay on, pinch a tile and carry it onto another tile to swap them, onto an empty cell to move it, or carry a tab onto the grid to add it. With the new **Detach Tiles** setting (App Settings > Appearance, off by default), drag a tile's header out of the window and it pops out into its own window; drop it on another Codeman window's tiles and it moves there. Known limits: all Codeman windows in one browser share one saved tile layout, a drag between two browser profiles is not seen by the source window, and a drag-out held for more than a few seconds can be blocked as a pop-up.
|
||||
|
||||
**Focus section on the vertical rail (#582).** "Add to Focus" in a row's menu (or right-click a web tab) pins a shortcut to that tab in a small collapsible section at the top of the rail. The tab itself stays where it is.
|
||||
|
||||
**Tabs land in groups (#528).** A tab group's menu has **New session**, which starts a session straight into that group. A session an agent spawns lands right after its parent; App Settings > Appearance > Tabs > **Spawned tabs follow their parent** (off by default) keeps it moving with the parent.
|
||||
|
||||
**Folder grid and more clickable paths (#604).** Paths written as `~/...`, relative to the session folder, or broken across terminal lines are now clickable in the terminal and the Response Viewer, and folders are too: a folder opens a grid of its files (renders, screenshots, reports) that you can page through with the arrow keys.
|
||||
|
||||
**Phone Attach button and videos (#606).** On a phone the toolbar has an Attach button that opens the system file sheet directly, without raising the keyboard, and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into `.codeman-uploads/` like images.
|
||||
|
||||
**OSC 52 clipboard (#611).** A program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) now puts the text on your browser's clipboard, in the main terminal and in every tile. Read requests are refused, and a replayed buffer never copies.
|
||||
|
||||
**Prompt-cache readout (#607).** Claude sessions show whether their prompt cache is warm (`cache:until HH:MM`) or cold in the footer and in Session Options > Respawn, restored after a restart, with a reason when a launch will never report it. Display only, nothing changes behaviour.
|
||||
|
||||
**Faster tab switches to fullscreen Claude (#588, #583).** Switching back to a fullscreen Claude tab now loads only its current screen (a few KB) instead of replaying a 1 MB tail of old redraws (about 1.3 to 1.7 s saved per switch). The "Showing the most recent 1.0 MB" bar now appears only when there really is older history and you scroll to the top looking for it, gives a line count, and stays closed once you close it.
|
||||
|
||||
**Fixes.**
|
||||
- **Shells reload cleanly (#601).** A shell tab, tile or split pane reloads from tmux's rendered history, so a prompt no longer appears four or five times and lines no longer go missing after a resize or a full-screen program.
|
||||
- **Remote SSH sessions keep their scrollback (#602, #609).** Loading a remote session's full history reads it from the remote tmux in one bounded ssh round trip, where the local pane kept only a fraction (48 of 279 lines in one measurement, none after a reattach).
|
||||
- **Codex pages with the wheel and touch (#527).** A Codex pane on its alternate screen pages its transcript with PageUp/PageDown from the first wheel notch or swipe, in the main terminal and in tiles, and never pages into an open permission dialog.
|
||||
- **openpyxl spreadsheets preview (#612).** An `.xlsx` written by openpyxl with a cell comment, a table or a chart now opens in the File Viewer instead of failing.
|
||||
- **Phone shell Paste (#600).** A multi-line paste in a shell is inserted as one bracketed paste and waits for Enter, instead of running each line as it arrives.
|
||||
- **Restart keeps tab colour, pin and image watcher (#591).**
|
||||
- **Session Options shows the working folder (#593)**, with a Copy button and a label that says when the folder is on a remote host or mounted into a Docker container.
|
||||
- **Link Existing says why (#590)**, for example "Invalid case name format", instead of "Invalid request body".
|
||||
- **Uploads on a dead mount (#605).** A prompt upload into a workspace on a network mount that stopped answering is refused at once instead of tying up a server thread.
|
||||
- **MCP server sync (#599).** Gemini servers with a bare `url` sync as HTTP, OpenCode's V2 `mcp.servers` layout and `opencode.jsonc` are read.
|
||||
- **DeepSeek approvals (#610).** DeepSeek Harness permission prompts show "Answer in the terminal" in the Approvals Inbox, the phone NEEDS YOU strip and push notifications, instead of Approve/Deny buttons the server always refused.
|
||||
|
||||
**Fixes applied while landing.** Remote SSH and Docker tabs keep their scrollback on a tab switch (they were about to lose it with #588). No colour bleed where remote history meets the live screen (#602, #609). A bare `~` in a Docker case no longer lists the host's home folder, and shell prompts (`user@host:~/proj$`) no longer turn into broken folder links (#604). Spawned sibling tabs keep their creation order (#528). An OSC 52 copy over plain HTTP gives keyboard focus back to the terminal (#611). A refused video upload answers instead of hanging, and the zh-CN "Attach" label of the tile grid is back (#606). The Working folder row fits on phones, with zh-CN text (#593). OpenCode sync no longer stalls on a server named `servers` (#599). Moving a tile to another window no longer overwrites that window's saved layout (#584). HTML pages: a `~/` link to an `.html` file renders as a page instead of falling back to source, `/tmp` is refused even when `TMPDIR` points elsewhere, symlinks cannot get around the hidden-file and file-type rules, workspace confinement is re-checked on every request, media in a page can seek, and the service worker never caches a page (#547). Codex users of `codeman mcp`: the config now forwards the session id, so its self-protection works (#615).
|
||||
|
||||
## 1.41.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 87f1c9c: ### Thanks
|
||||
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
|
||||
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
|
||||
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
|
||||
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
|
||||
|
||||

|
||||
|
||||
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
|
||||
|
||||
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
|
||||
|
||||
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
|
||||
|
||||
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
|
||||
|
||||
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
|
||||
|
||||
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
|
||||
|
||||
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
|
||||
|
||||
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
|
||||
|
||||
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
|
||||
|
||||
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
|
||||
|
||||
**Fixes.**
|
||||
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
|
||||
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
|
||||
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
|
||||
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
|
||||
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
|
||||
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
|
||||
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
|
||||
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
|
||||
|
||||
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
|
||||
|
||||
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
|
||||
|
||||
## 1.40.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ### Thanks
|
||||
- @opticon454 for four PRs in this release: the end of Android autocorrect duplicating a line (#541), a Run dropdown that fits and scrolls on any screen (#542), Git status that copes with big folders and slow shares (#543), and a nightly browser-suite workflow plus the three stale browser tests it needed (#540). Thanks also for confirming #550 on a live install.
|
||||
- @JDProfresh, a first-time contributor, for four PRs: Respawn and Ralph back in Claude's Session Options (#550), removing the broken tunnel Upload URL page (#551), the wiki's Contributing page catching up with CONTRIBUTING.md (#552), and a remote-wake test that no longer pins its budget to the millisecond (#559).
|
||||
- @Randalix for making opencode tabs selectable and scrollable again (#555), and for the server reporting the port it really bound, which let the test suite move off fixed ports (#556).
|
||||
- @shenlvkang-collab for Default Codex model and reasoning effort settings (#546), and for the opt-in `window.CodemanHost` bridge that lets a native wrapper app pop a session or a file preview out into a window of its own (#432).
|
||||
- @aakhter for `.xlsx` spreadsheets in the File Viewer (#502), parsed entirely in the browser behind admission limits that held up to round after round of review.
|
||||
|
||||
This is the biggest visual overhaul Codeman has had: a tile grid for driving several sessions at once, a new header, CLI logos everywhere an agent is named, lineage trees, a calmer welcome screen and optional new tab layouts. The defaults are chosen so an upgrade looks familiar: the tab strip stays Classic, the header gets the Compact stats, and the Tiles button is there to try.
|
||||
|
||||
**The tile grid: up to six live sessions side by side.** Click **Tiles** in the header (or press `Ctrl+Shift+G`) and the window splits into a grid of real terminals, each one a full session you can read and type in, with its own connection. Every tile has a small header naming the session, the agent (its logo) and the model it runs (`Claude on Opus 5.5`, `DeepSeek on qwen3.8-27b`, following an in-session `/model` switch), a status dot that turns red and pulses the tile's border when that session needs you, a session menu, zoom (`⤢` or `Alt+Shift+Enter`) and remove (`×`, the session keeps running). Right-click the button for a 2 / 4 / 6 count menu (remembered per device; a hover card explains both clicks). Add sessions by `Ctrl`/`Cmd`+clicking a tab, dragging a tab onto a tile or an empty cell, "Open group as tiles" in a tab group's menu, or just Run while the grid is open. Rearrange by dragging a tile by its header (onto another tile they swap, and an empty cell can sit anywhere), with `Ctrl+Shift+Arrows`, and resize with the column and row dividers; `Alt+Shift+Arrows`, `Ctrl+Tab` and `Alt+[` / `]` move the focus, and the focused tile is the session every panel follows (files, git status, respawn, subagent windows, voice, image paste). Picking a tab that is not tiled shows it on its own and one click brings the grid back; app-driven selections never collapse it. The grid survives a reload (per device, session ids only), opens and closes with a short animation (off under reduced motion), auto-zooms the focused tile when the window gets too small, shows an Attach overlay for a session that is not attached or whose agent exited, and is fully translated into 简体中文. It was built to stay fast with six busy agents: tiles paint first and load their terminals one per frame, a tile replays its history at xterm's own pace and never reads more scrollback than it keeps, the main terminal is parked (no SSE terminal stream) while tiles own the screen, and a window resize refits each tile exactly once. Desktop only (a window at least 1180px wide); the setting is App Settings → Header & Panels → Tiles, on by default on desktop and opt-in on touch tablets. The user guide is the new [Tile Grid](https://github.com/Ark0N/Codeman/wiki/Tile-Grid) wiki page.
|
||||
|
||||
**Split view, rebuilt on the same terminal (#560).** The split's second pane is now a `TerminalTile`, the same component every tile is: its input goes through the exactly-once queue, so a dropped link can no longer lose or double a keystroke; it reconnects on its own after a drop or a server restart; it and its PTY never disagree about size; file paths are clickable and `Ctrl+V` pastes images into it; and app shortcuts, voice and image paste follow the pane you are in. Both panes now name their agent and model above them.
|
||||
|
||||
**A new header.** The header stats come in three styles (App Settings → Header & Panels → Header Stats Style, per device): **Compact**, the new default, draws WS, CPU, MEM and the plan usage windows as two pills where every reading is a ring, a label and a value; **Tiles** gives each one its own box; **As before** keeps the old readout. The icon buttons beside them take the matching shape, and hover now moves the icon, never the button.
|
||||
|
||||
**CLI logos everywhere an agent is named (#532).** The Run menus (toolbar, phone overview picker, custom endpoints, model picker), every agent tab (header strip, rail, sidebar, phone chips, the desktop home rail, Claude's tabs included), the tile and split headers and the welcome screen now show each CLI's own logo instead of a colour dot or a two-letter pill. The marks are inline SVG, follow every skin, and a CLI you added through `clis.json` gets a plain dot.
|
||||
|
||||
**Lineage trees (#544).** The lines from a tab to the tabs it spawned are now one rounded tree per spawning tab, routed through the gaps between tab rows so they never cross a tab or reach the terminal. Every family is always drawn, the selected tab's family is drawn thicker and on top, and a dashed branch now means that child is working.
|
||||
|
||||
**Tab layouts (#538, optional).** App Settings → Appearance → Tabs → **Tab Layout** adds three opt-in arrangements next to the default **Classic** strip: **By state** (a row each for Needs you, Waiting, Working and the rest, most urgent first, flippable with State Order), **By case** (one labelled box per case) and **Ledger** (an aligned grid of equal cells). By state and By case also group the vertical rail and the sidebar. Per device; phones keep their scrolling chip row.
|
||||
|
||||
**A calmer welcome screen.** One primary launcher for the first agent in your catalog (Claude Code on a stock install, the next agent if it is disabled) with its real logo, every other CLI as a slim pill under it, and Cloudflare Tunnel as a quiet link above its QR. The toolbar's "+" and case gear moved into the case picker as "New or link a case…" and "Case settings…" rows, and the duplicate instance stepper is gone (#428).
|
||||
|
||||
**Every session knows its model.** Sessions publish the model they run (`displayModel` on the session state): a custom endpoint's model id, else what the running CLI itself reports (Claude's statusLine, codex's, pi's and opencode's footers, the dsh status line), else the model its config pins (a DeepSeek route) or the one it was launched with. It is persisted, follows `/model` switches, and is stripped of control characters and capped.
|
||||
|
||||
**Idle detection for every agent.** OpenCode, Gemini, Pi and OMP turns now end: their composer bars and spinners are read so a session goes idle when the agent is done instead of spinning "working" forever, codex 0.162's new footer row no longer hides its model or its background-terminal row, and a freshly started or re-attached agent pane now announces its idle to open pages (it used to stay `busy` until a reload). A pane prompted within its first seconds is never settled idle at launch, so send-and-wait cannot resolve before the turn starts.
|
||||
|
||||
**Spreadsheets in the File Viewer (#502).** `.xlsx` files open as a read-only grid with sheet tabs, number formats, merged cells and colours, from the Files panel, attachments or a path an agent prints. They are parsed entirely in your browser in a worker (up to 10 MB) behind admission limits on everything the parser would expand. `.xls` and `.ods` stay download-only, and `file-content` now reports `type: 'spreadsheet'` for `.xlsx`.
|
||||
|
||||
**Default Codex model and reasoning effort (#546).** App Settings has a Default Codex model and a Default Codex reasoning effort, applied to new local Codex sessions (Run menu, Resume and the HTTP API) unless the launch names its own; custom endpoints, Docker and remote sessions keep their own settings.
|
||||
|
||||
**Pop-out windows for native wrapper apps (#432).** A native wrapper (for example an Android app on a foldable) can pop a session, a file preview or a web tab out into a window of its own beside the dashboard through an opt-in, experimental `window.CodemanHost` bridge. Browsers behave exactly as before.
|
||||
|
||||
**Git status for big folders and slow shares (#543).** Two per-device settings under Settings → Bottom bar set how many repositories it lists (up to 50, default 12) and how long one git command may take (5 to 120 s, now 30 s by default), and a repository git cannot read is listed with the reason and counted as `? N` in the indicator instead of silently disappearing.
|
||||
|
||||
**Behaviour change: `Ctrl+W` no longer closes a session.** It is delete-word in every shell and agent CLI, and muscle memory used to kill a session (its pane and CLI, with no confirm) mid-sentence. Close Session has no default key now; bind one in App Settings → Shortcuts if you want it.
|
||||
|
||||
**Removed: the tunnel Upload URL page (#551).** Since the response envelope change it reported "Saved: undefined" and listed nothing. Use the in-app image paste instead. `POST /api/screenshots`, `GET /api/screenshots` and `GET /api/screenshots/:name` (and their `/api/v1` aliases) still work unchanged but log a one-time deprecation warning and will be removed in a future major release.
|
||||
|
||||
**Fixes.** Android keyboards that autocorrect as you type (SwiftKey, Gboard) no longer duplicate the line in the prompt: the corrected word reaches the session exactly once, including when Enter arrives in the same keyboard transaction (#541). opencode tabs: a drag selects text again (so copy-on-select works and `Ctrl+C` copies instead of closing opencode), and the wheel and touch swipes page through opencode's conversation (#555). The Run dropdown no longer runs off the top of the screen: with many CLIs, endpoints and saved URLs it fits between the header and the toolbar, follows the on-screen keyboard and the iPhone safe areas, and scrolls (#542). Claude sessions show the Respawn and Ralph / Todo tabs and the auto-resume toggle in Session Options again, hidden since 1.33.0 (#550, fixes #549). The terminal refits when only its box changes size (a header that grows with the state rows or the lineage gutter used to leave the bottom rows clipped behind the toolbar until a tab switch). A device with any shortcut override no longer gets every App Settings save rejected while the toast still said "Settings saved". Image paste and dictation land in the session they started in. App Settings search finds Split and Tiles by what they do. The Run button family, the Help modal, the shortcut overlay leftovers and the exited-agent tab badge are translated into Chinese.
|
||||
|
||||
**For contributors.** The server reports the port it actually bound (`boundPort`), the tests that shared fixed ports bind ephemeral ones, and a static guard keeps new fixed ports out, so two test runs on one machine no longer collide (#556). A nightly (and on-demand) Playwright browser-suite workflow runs the suites the CI gate cannot, informational and never a gate (#540). The wiki's Contributing page says `npm test` is the CI gate (#552).
|
||||
|
||||
**A final review before shipping.** A last adversarially verified review of the whole release fixed these in the tile view: an image dropped on a tile uploads to that tile's session instead of navigating the browser away; a Claude session started into the grid keeps its welcome banner and transcript; a tile refresh never blanks the screen while it waits; a remote close or a reconnect no longer moves your keyboard into another session's tile; dictation and the phone keyboard's Path and Clear keys reach the focused tile or split pane; each tile caps its live-output backlog and recovers dropped output with one bounded refresh; Redraw on a tile forces the resize it reports; popping out the last tile leaves no frozen view; and "Open group as tiles" no longer pulls an open split into the grid. Also from that review: the By case layout stays one scrolling row on 600 to 767px tablets, the needs-you pulse animates opacity only, a codex session on the ultra effort shows its model, codex's launch defaults are CLI registry data instead of an id check, `npm run build` checks its dependencies before it deletes anything, and the release's new strings (case picker rows, Git status settings, toasts, tile and spreadsheet texts, the Redraw toasts) have zh-CN translations.
|
||||
|
||||
**Fixes applied while landing.** Tiles and the split's Pane B got the same two fixes the main terminal got from contributors: opencode's wheel paging and click reports (#555), and the Android keyboard handling that stops autocorrect duplicating a line (#541). Android: a word composed right before Enter in the same keyboard transaction was sent twice; it now arrives once (#541). Codex: App Settings refuses a Default Codex model the server would reject instead of failing the whole save behind a "Settings saved" toast, and the two Codex rows stack under their labels on phones (#546). Run dropdown: its height follows the on-screen keyboard and both iPhone safe areas, a long custom-endpoint label no longer adds a horizontal scrollbar, and the open menu sits above the keyboard accessory bar (#542). Git status: a lone repository git cannot read is no longer shown as clean and empty, a repeated `timeout` query parameter no longer answers 500, and the timeout field accepts any whole number of seconds (#543). Spreadsheets: cells clipped to nothing at the edge of a very large sheet no longer grow its scroll area, and the preview's fixed texts have zh-CN translations (#502). Pop-out windows: the bridge refuses to pop out without a window channel (the tab could never re-dock), the tab menu follows the host-aware default, and Close window works inside a host window (#432). Every deprecated `/api/screenshots` route now logs its warning, pinned per route (#551). Across the new tab layouts and header styles: lineage lines run between the state labels and the tabs and never through a case box, an open Tiles or Split button stays highlighted in the boxed header styles, a phone keeps the active chip in view when it changes state band, and the Tab Layout and Header Stats Style settings are translated.
|
||||
|
||||
## 1.35.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6f88e40: ### Thanks
|
||||
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
|
||||
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
|
||||
|
||||
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
|
||||
|
||||
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
|
||||
|
||||
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
|
||||
|
||||
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
|
||||
|
||||
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
|
||||
|
||||
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
|
||||
|
||||
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
|
||||
|
||||
## 1.34.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6aecc3b: ### Thanks
|
||||
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||
|
||||
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||
|
||||
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||
|
||||
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||
|
||||
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||
|
||||
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||
|
||||
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||
|
||||
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||
|
||||
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||
|
||||
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||
|
||||
## 1.33.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f776ad8: ### Thanks
|
||||
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
|
||||
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
|
||||
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
|
||||
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
|
||||
|
||||
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
|
||||
|
||||
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
|
||||
|
||||
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
|
||||
|
||||
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
|
||||
|
||||
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
|
||||
|
||||
## 1.33.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e439cf0: ### Thanks
|
||||
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
|
||||
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
|
||||
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
|
||||
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
|
||||
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
|
||||
|
||||
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
|
||||
|
||||
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
|
||||
|
||||
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
|
||||
|
||||
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
|
||||
|
||||
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
|
||||
|
||||
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
|
||||
|
||||
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
|
||||
|
||||
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
|
||||
|
||||
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
|
||||
|
||||
**Contributor tooling (#500).** `npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
|
||||
|
||||
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
|
||||
|
||||
## 1.33.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ### Thanks
|
||||
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
|
||||
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
|
||||
|
||||
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
||||
|
||||
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
|
||||
|
||||
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
|
||||
|
||||
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
|
||||
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
|
||||
|
||||
Thanks @opticon454.
|
||||
|
||||
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
|
||||
|
||||
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
|
||||
|
||||
Thanks @opticon454 (#487, #485).
|
||||
|
||||
## 1.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -6,14 +6,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
>
|
||||
> **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it.
|
||||
>
|
||||
> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `vitest.config.ts` (a one-line re-export of `config/vitest.ci.config.ts`: vitest finds its config by walking up from cwd, so without it a bare `npx vitest` ran with no setup file and wrote into the real `~/.codeman`, #640), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those.
|
||||
> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
|
||||
| Type check | `npm run typecheck` (= `tsc --noEmit` + `tsc -p config/tsconfig.scripts.json`) |
|
||||
| Type check | `npm run typecheck` (= `tsc --noEmit`) |
|
||||
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
|
||||
| Format | `npm run format` (check: `npm run format:check`) |
|
||||
| Tests | `npm test` (the CI gate — safe to run bare) · one file: `npm test -- test/<file>.test.ts` · see Testing for the excluded suites |
|
||||
@@ -34,7 +34,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
|
||||
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
|
||||
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
|
||||
- **A pre-push failure in a file you did not touch is another session's WIP.** Push with `CODEMAN_SKIP_PREPUSH=1 git push` and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)
|
||||
|
||||
## CRITICAL: Always Test Before Deploying
|
||||
|
||||
@@ -44,21 +43,47 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
|
||||
3. **Only after verification passes**, proceed with COM
|
||||
|
||||
The production server caches static files for 1 year, `immutable` (`setHeaders` in `server.ts`), except the `.json` data files under `public/` (`whats-new.json`, `security-notices.json`, `manifest.json`), which are served `no-cache` because they are neither content-hashed nor `?v=`-busted (pinned by `test/static-cache-headers.test.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
|
||||
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
|
||||
|
||||
## COM Shorthand (Deployment)
|
||||
|
||||
Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`. What SemVer actually covers (the CLI, documented env vars, **and the HTTP/SSE API under `/api/v1`**: endpoint paths, response envelope, `errorCode` values and SSE event names are public/stable; on-disk state, internal TS modules, and experimental features are internal/unstable) is defined in `docs/versioning-policy.md`. Third-party integration surfaces are documented in `docs/extending-codeman.md`. Security reporting + known limitations live in `.github/SECURITY.md`.
|
||||
|
||||
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
|
||||
When user says "COM":
|
||||
|
||||
**Version**: 1.50.0 (must match `package.json`)
|
||||
1. **Determine bump type**: `COM` = patch (default), `COM minor` = minor, `COM major` = major
|
||||
2. **Create a changeset file** (no interactive prompts). Write a `.md` file in `.changeset/` with a random filename:
|
||||
|
||||
```bash
|
||||
cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET'
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
|
||||
CHANGESET
|
||||
```
|
||||
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
|
||||
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
8. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.33.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
||||
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`) and GitHub Copilot (`copilot`, npm `@github/copilot`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'`).
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`) and OMP (`omp`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'`).
|
||||
|
||||
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
|
||||
|
||||
@@ -72,40 +97,51 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
| Task | Command |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Terminal dashboard | `codeman tui` (`--list` prints the numbered list and exits, `codeman tui <n>` attaches to row n; both short-circuit before any screen setup). Needs a TTY; without a server it starts attach-only. `docs/tui.md` |
|
||||
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
||||
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
|
||||
| Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` |
|
||||
| Mount under a reverse-proxy sub-path | `npx tsx src/index.ts web --base-url /codeman` (env `CODEMAN_BASE_URL`; default `/`). Normalized in `src/config/base-path.ts` (`''` = root). See Reverse-proxy base path below + `docs/wiki/Remote-Access.md` |
|
||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||
| Watch-mode test | `npm run test:watch -- test/<file>.test.ts` (runs the CI gate's config; pass a file to narrow it) |
|
||||
| Test coverage | `npm run test:coverage` |
|
||||
| Dead-code sweep | `npm run knip` (config in `config/knip.json`, passed via `--config`) |
|
||||
| Rebuild gesture overlay | `npm run build:gesture` (esbuild `packages/gesture-control/src/codeman/entry.ts` → `src/web/public/gesture/gesture-codeman.js`; commit the result) |
|
||||
| Regenerate the CLI catalogue | `npm run generate:cli-catalog` (`--check` to fail on drift). Rewrites `config/clis.stock.json` **and** the marked block in `install.sh` from `stock.ts`. ⚠ **Commit both.** They are what `install.sh` and the Docker agent image read, since neither can import TypeScript; `test/cli-catalog-sync.test.ts` and a CI `--check` step fail if either goes stale. See `docs/cli-registry.md` |
|
||||
| Build the docker agent image | `node scripts/build-agent-image.mjs --no-cache` (builds `codeman/agent:base` from `docker/agent.Dockerfile`; prerequisite for Docker cases; `--engine`/`--image`). ⚠ **Always `--no-cache`** — a plain rebuild re-uses the cached `npm install -g` layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See `docs/docker-cases.md` |
|
||||
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
|
||||
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
|
||||
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
|
||||
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
||||
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
|
||||
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
|
||||
| Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) |
|
||||
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
||||
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and the Playwright tests in `BROWSER_TEST_GLOBS`; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`). `browser-suite.yml` runs `npm run test:browser` (minus codex-predictive-echo) nightly and on demand via `workflow_dispatch`; it is informational and never gates a push or PR.
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||
|
||||
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
||||
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, upload.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). Multi-line text WITHOUT `\r` is pasted the same way; on a pane that has not enabled bracketed paste (POSIX sh, macOS `/bin/bash` 3.2) tmux delivers every line break as a CR, so every line but the last runs, where the old line-stripping typed the joined text and ran nothing. Leading and trailing line breaks are dropped. ⚠️ `SubmitVerifier` cannot reliably recognise a multi-line paste in the composer: it matches one Claude shows inline whose first line has 24+ non-space characters, but misses a short first line and the `[Pasted text #N +k lines]` placeholder, so it may not re-press a stranded Enter for one. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
||||
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*` vs `OMP_*` vs `COPILOT_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`, `docs/omp-integration.md`, `docs/copilot-integration.md`
|
||||
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
|
||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
||||
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
||||
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
|
||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||
- **Never start `git` without `gitSpawnAllowed()`** (`utils/git-availability.ts`): on a Mac without the developer tools `/usr/bin/git` is Apple's placeholder, and running it, even as `git --version`, opens macOS's "install the command line developer tools" window. The server did that on the first page load after a no-admin install (`isGitAvailable()` from `renderIndexHtml`) and would have again at every start. The gate resolves `git` on the PATH itself and, for the placeholder, proves the tools first with `xcode-select -p`, which never opens a window; a refusal is answered as ENOENT (`gitMissingError()`), which every caller already reads as "git is not installed". `test/git-spawn-gate.test.ts` pins the list of spawn sites, so a new one fails until it is gated. `install.sh` follows the same rule for the same reason.
|
||||
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
|
||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
@@ -114,7 +150,29 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Core Files (by domain)
|
||||
|
||||
**Read before touching a subsystem**: respawn → `docs/respawn-state-machine.md`; Ralph → `docs/ralph-wiggum-guide.md`; orchestrator → `docs/orchestrator-loop-architecture.md`; cron → `docs/cron-discovery.md`; DeepSeek (`dsh` is a PROFILE LAUNCHER, not an agent) → `docs/deepseek-integration.md`; TUI (`src/tui/`, a CLIENT of the server, never a second brain; only `tui-app` + `tui-client` do IO over a pure core) → `docs/tui-plan.md`. Pure helpers are split out of `session.ts` and the other large files on purpose, so tests exercise the shipped code: keep new logic that way.
|
||||
| Domain | Key files | Notes |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names`, `cli-style` | The last three back `web -d` / `service install`; `cli-style` is the shared palette/table/spinner/confirm kit |
|
||||
| **TUI** | `src/tui/`: `tui-app` ★ + `tui-client` (the only IO) over a pure core (`-model`, `-layout`, `-render`, `-keys`, `-ansi`, `-composer`, `-approvals`, `-digest`, `-sse`, `-types`) | `codeman tui`, a CLIENT of the server, never a second brain. Design doc: `docs/tui-plan.md`; user guide `docs/tui.md` |
|
||||
| **DeepSeek** | `src/utils/deepseek-cli-resolver.ts`, `src/deepseek-status-shim.ts`, `src/deepseek-web-server.ts` (background `dsh web`, not a session) | `dsh` is a PROFILE LAUNCHER, not an agent; read `docs/deepseek-integration.md` first |
|
||||
| **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `session-trust-dialog` (pure), `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★, `src/proc-tree.ts` (pure, bounded descendant walk) | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
|
||||
| **Orchestrator** | `src/orchestrator-loop.ts`, `-planner`, `-verifier` | Read `docs/orchestrator-loop-architecture.md` first |
|
||||
| **Cron** | `src/cron/cron-service.ts`, `cron-time.ts` (pure next-run math), `cron-input.ts` | Read `docs/cron-discovery.md` first. Distinct from legacy `ScheduledRun` (`/api/scheduled`) |
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `team-watcher`, `bash-tool-parser`, `transcript-watcher`, `workflow-run-watcher` | `workflow-run-watcher` is STANDALONE and never touches `subagent-watcher` |
|
||||
| **AI** | `src/ai-checker-base.ts`, `ai-idle-checker.ts`, `ai-plan-checker.ts` | |
|
||||
| **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts`, `intent-store.ts`, `tab-layout.ts` (pure model) + `-service` (sole mutation boundary) + `-persistence` + `-legacy-order` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` + `remote-wake` (IO: `dgram`/`net`/`child_process`), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns |
|
||||
| **Web tabs** | `src/webview-store.ts`, `webview-capabilities.ts`, `src/web/webview-proxy.ts` (pure), `src/web/routes/webview-routes.ts` | Dashboard URLs as tabs; NOT a SessionMode |
|
||||
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (one module per domain + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (core) + the modules listed in the Frontend load order + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
@@ -122,152 +180,129 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Config**: `src/config/` — flat files plus the `cli-registry/` subdir, no barrel (`index.ts`) exists; import from the specific file. ⚠️ There are TWO `config/` directories: the repo-root `config/` holds tooling only (ESLint, knip, the vitest configs, `test-suites.ts`), while runtime config lives in `src/config/`. Throughout this file a bare `config/<name>.ts` in a code context means `src/config/<name>.ts`.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver`/`copilot-cli-resolver` (CLI path resolution, one per `SessionMode`, all ten sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`).
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver` (CLI path resolution, one per `SessionMode`, all nine sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`).
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Session spawns `claude --dangerously-skip-permissions` via node-pty
|
||||
2. PTY output buffered, ANSI stripped, parsed for JSON messages
|
||||
3. WebServer broadcasts to SSE clients at `/api/events`
|
||||
4. State persists to `~/.codeman/state.json` via StateStore
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case <name>` removes copies). ⚠️ Existing installs that stored off are switched on once at the first 1.50.0 start (every Save up to 1.49.0 wrote `false`); turning it off afterwards sticks. That is `migrateAgentSkillDefault()` (`web/agent-skill-default-migration.ts`, from `WebServer.start()` before the first settings read and before listen, skipped in test mode, through `mutateSettingsFile()` only): it stores `true`, never deletes the key (the page's merge keeps a device's own `false` for a key the server lacks, and the next Save would write it back; the page also drops its copy when the server has no key), and its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir, never a settings key (so no Save sends or drops it, the `.strict()` schema never sees it, and a config-backup restore leaves it alone). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token, is never returned by these routes and is on the sensitive-path list; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) No other CLI is told. `codeman notify "<message>" [--urgent] [--file <path>]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md`
|
||||
|
||||
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
|
||||
|
||||
**Agent preamble cache GC**: the §0 preamble seeded per local session whose CLI takes the skill (`capabilities.agentSkillDir`) (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
||||
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The workspace-trust dialog's `❯` (on `No, exit`) arms the check too, so the same capture holds idle off while `isTrustDialogScreen()` matches inside `TRUST_DIALOG_WINDOW_MS`: an agent that waited for idle and prompted the dialog confirmed `No, exit` and killed the pane. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
||||
|
||||
⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
||||
|
||||
⚠️ **A codex dialog is BLOCKED, read off the screen, and only ever an ALERT.** Codex has no hook for its approval, question and trust dialogs, so `capabilities.workDetect.blockedLine` names their chrome and `web/screen-blocked.ts` raises what a `permission_prompt` hook raises, as an `answerInTerminal` item. ⚠️ Never key a keystroke off that match (the agent writes most of the pane), and never hand its item a `capture` (the inbox's sweep is Claude-shaped; the session's 2 s re-read owns the clear). A blocked screen beats the working line. → [architecture-invariants#screen-read-blocked-dialogs-codex](docs/architecture-invariants.md#screen-read-blocked-dialogs-codex)
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
||||
|
||||
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
|
||||
|
||||
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
|
||||
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
|
||||
|
||||
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
|
||||
|
||||
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (`compactScreenText()`, all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind `\r` (newer versions highlight "No, exit" first): `trustDialogNextKey()` returns ONE key per re-read frame (arrow, then Enter only once `❯` is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window `TRUST_DIALOG_WINDOW_MS` (90s), two-marker match (`isTrustDialogScreen`), attempt cap `TRUST_DIALOG_MAX_ATTEMPTS` (6). ⚠️ Read `capturePaneText()`; only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (`_trustDialogTimer`, cleared in `_clearAllTimers()`), not rely on PTY output, and a throttled read is DEFERRED onto that timer, never dropped (the dialog's paint is often the pane's last output); a stopped session never scans. → [architecture-invariants#workspace-trust-dialog-auto-accept](docs/architecture-invariants.md#workspace-trust-dialog-auto-accept)
|
||||
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (`compactScreenText()`, all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind `\r` (newer versions highlight "No, exit" first): `trustDialogNextKey()` returns ONE key per re-read frame (arrow, then Enter only once `❯` is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window `TRUST_DIALOG_WINDOW_MS` (90s), two-marker match (`isTrustDialogScreen`), attempt cap `TRUST_DIALOG_MAX_ATTEMPTS` (6). ⚠️ Read `capturePaneText()`; only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (`_trustDialogTimer`, cleared in `_clearAllTimers()`), not rely on PTY output. → [architecture-invariants#workspace-trust-dialog-auto-accept](docs/architecture-invariants.md#workspace-trust-dialog-auto-accept)
|
||||
|
||||
**Process-tree walks are bounded** (`proc-tree.ts`, pure): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by one cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: async, in-flight-shared, and ANY error discards the result rather than caching a truncated `ps`). ⚠️ Never walk a process tree with per-node `pgrep` or unbounded recursion (the unbounded version took a machine down): the walk must terminate on cycles, cap depth (`PROC_WALK_MAX_DEPTH`) and node count (`PROC_WALK_MAX_NODES`), and never spawn anything. ⚠️ Keep it in its own module so the test exercises the shipped code, and report truncation through `onTruncated` naming both caps, never silently. → [architecture-invariants#process-tree-walks-are-bounded](docs/architecture-invariants.md#process-tree-walks-are-bounded)
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, SYNCED; `docs/wiki/Config-Backups.md`): timestamped `cfg-*` snapshots of a FIXED allowlist of config files (`CONFIG_BACKUP_FILES`; `state.json` deliberately not) at startup and whenever their content changes, into `<data dir>/backups/config` or `configBackupDir`. ⚠️ A field a tracked file rewrites on its own goes in `VOLATILE_KEYS`, or every tick makes a snapshot and the history shrinks to about 95 minutes. ⚠️ Retention deletes only this instance's folders that carry a valid manifest, never the newest, and ages a snapshot from its SUCCESSOR; a retention key ABSENT from `settings.json` means what the newest manifest recorded (`withRecordedRetention`), never the defaults. ⚠️ The instance is identified by the random id in `config-backup-id`, never the hostname (a Docker recreate changes it). ⚠️ A custom folder is probed BOUNDED (#516) before ANY read, the recorded-retention fallback included, and a pass reads the folder once (`runConfigBackupPass`). ⚠️ Restore verifies every SHA-256 and refuses symlinks and group/world bits before it writes anything, takes the undo copy first, and reports a partial failure as 500 with `restored`/`failed`/`safetySnapshot` in `data`; every path that wrote files runs `afterRestore()` (intents reload, CLI registry reload, admin audit), and Settings re-reads the form after a `settings.json` restore. ⚠️ Compare never returns a credential (`REDACT_ALL_FILES`, `SECRET_KEY_RE`). Admin only in multi-user mode. → [architecture-invariants#config-backups](docs/architecture-invariants.md#config-backups)
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). Copilot comes from a read-only `GET https://api.github.com/copilot_internal/user` (`refreshCopilotPlanUsage()` in server.ts, at startup and every 10 minutes; `parseCopilotQuotaResponse()` keeps only the metered `premium_interactions` quota, as a `monthly` window with `used`/`limit`) sent with Copilot's OWN sign-in token (`resolveCopilotToken()`: `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a keychain sign-in shows no row). It is **on by default**, like the Codex poll: it rides `showPlanUsageLimits` (absent = ON), and Plan Usage Limits off stops the request. ⚠️ The gates live in `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) in this order: the setting, then a token, then `isCopilotAvailable()`, whose resolver can run a login-shell probe that a host with no sign-in must never pay each tick. ⚠️ `redirect: 'error'`, the token is never logged, stored or broadcast, any failure is silently no row, and the row is admin-only on the wire like Codex's. The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), so does a nudge whose write failed (retried a minute later, never past the last-chance line), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm)
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
**Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md`
|
||||
|
||||
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk (the one exception: the Edit history store keeps the diffs read from a remote session's transcripts), are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
|
||||
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk, are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
|
||||
|
||||
**Wake-on-LAN (`remote-wake.ts`)**: optional `RemoteHost.wakeMac` (magic packet) or `RemoteHost.wakeCommand` (single executable, no shell, takes precedence) lets HTTP input, `POST /api/sessions/:id/wake` and the user's create/attach (`ensureHostAwake`) wake a sleeping host. ⚠️ Only an explicit user request may wake: never give the registry to the auto-reconnect watcher, `handleRemoteSessionDropped`, boot recovery or `cron-service.ts`, and `GET /api/sessions/:id/reachability` must never wake. ⚠️ Detection is a throttled bare TCP probe; never add `ServerAliveInterval`, and a `jumpHost`/`socksProxy`/`ProxyCommand` host is reachability-UNKNOWN (`isProbeable()`): never buffer, gate or banner on it. ⚠️ In multi-user mode a non-admin attach 403s BEFORE host lookup. ⚠️ WS keystrokes bypass the registry, so the banner (`host-wake-ui.js`) must not promise queued input. Waiting requests use the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`. → [architecture-invariants#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases)
|
||||
|
||||
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
|
||||
|
||||
**Adopted tmux sessions** (`src/foreign-tmux.ts` pure, `foreign-tmux-discovery.ts`, `foreign-sessions.js`, `POST /api/sessions/adopt`, admin-only in multi-user mode): a tmux session a human started (local, in a container, over ssh) opens as a tab through a wrapper session on OUR socket, a LOCATION OVERLAY like remote and Docker, never a `SessionMode`. ⚠️ `session.remote` and `session.docker` are undefined on one (the connection is on `adopt.*`) and its `workingDir` is the neutral `/tmp`, so anything that treats `workingDir` as the session's workspace, or branches on remote or docker to mean "not here", must also test `session.isAdopted`. The file surfaces are the one standing exception (owner decision 2026-11-06, no privilege gain): the Files panel, File Viewer, `html-view` and absolute-path links still use this host's `/tmp` and paths, even on a container or ssh tab. Gating them for remote and Docker adoptions is the planned follow-up, so do not copy the exception to a new surface. ⚠️ `isAdopted` asks who launched the process, so it is never folded into `isExternalCliMode()`. ⚠️ `foreignTargetKey()` is the ONE identity of a target; a socket and a name collide across containers and hosts. ⚠️ Never set anything on the foreign server beyond our own `codeman-view-*`: no `window-size`, no key tables. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start (`startShell()`'s `clear\n` submitted a half-typed command, and `shell` is also the answer for vim or psql). ⚠️ Session names are someone else's text: they pass the allowlist at discovery, so a browser only ever sends an opaque id, and a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`) is refused there too, or the `has-session` guard passes and the join lands elsewhere. ⚠️ A stopped tmux server holds our stdout pipe open past its client, so the probe pings each server with stdout at `/dev/null` first and `runForeignProbe()` lets go of the pipe at the deadline; never give `list-panes` the real stdout unpinged. ⚠️ The attach line reaches `bash -c` single-quoted through `bashCArg()` at both launch sites, like a remote or docker launch, never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on this host. → [architecture-invariants#adopted-foreign-tmux-sessions](docs/architecture-invariants.md#adopted-foreign-tmux-sessions), `docs/api-reference.md`
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||
|
||||
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaveLine truncated
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order (Alt+[ / Alt+] and Ctrl+Tab included), except the phone swipe, which walks the drawn order like the strip's arrow keys (`nextSession({ visual: true })` through `_stripSessionOrder()`), and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session Codeman starts installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()` (an adopted session installs none: it has no workspace of ours), gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
|
||||
|
||||
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory queue fed by `/api/hook-event` (answerable items claude-only; DeepSeek's and codex's screen-read ones are alert-only `answerInTerminal`), at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
|
||||
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory, claude-only queue fed by `/api/hook-event`, at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
|
||||
|
||||
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
|
||||
|
||||
**Agent inbox** (discussion #558; persistence `agentInboxPersistEnabled`, SYNCED, default OFF): per-session mailbox in `web/agent-inbox.ts`, routes `routes/inbox-routes.ts`, CLI `codeman agent post|inbox|ack`. Never writes to a pane, never in `toState()` (counts via `GET /api/agent-inbox/summary`). ⚠️ Read until ack by id; seen/ack only for the receiver resolved via `resolveParentSessionId()` (ack from anyone else = 403); `?wait=` parks while nothing is UNSEEN; full = 409, never drop the oldest; dropped on delete (`killMux`) only, boot prune only after a successful restore. → [architecture-invariants#agent-inbox](docs/architecture-invariants.md#agent-inbox)
|
||||
|
||||
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph-and-pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and Shell loads the rest only via **Load full history**, never on ordinary scroll. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode, copilot and pi get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, tracked Copilot, or tracked Pi**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Pi has no version gate either, and is fullscreen by default since 1.0.0: it enables tracking once at start, so the flag is what survives a tab switch or reload (xterm's own mode does not). ⚠️ Pi deliberately has no `transcriptPageKeys`: its `tuiMode: regular` binds those keys to the editor, so paging there moves a draft's caret. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](docs/architecture-invariants.md#auto-copy-copy-on-select)
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY**; ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ An install with no git checkout (`install.sh` on a machine without a working git leaves the marker `.install-source`) is kind `archive`: it cannot update in place, and both the check and the UI tell it to re-run the installer, never the npm line. ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, or against a followed worktree for an absolute path inside it, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. The one exception is an absolute path inside a followed worktree outside `workingDir` (`SessionState.outsideWorktree`), which the file routes serve as a second root (see Files panel in a worktree). An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||
|
||||
**Files panel delete** (salvaged from #529; `DELETE /api/sessions/:id/files?path=`, the × on a file row of the Files panel, tree and search results): removes ONE regular file and is the **only file surface that deletes**. It takes the edit route's confinement and guards whole (ownership via `findSessionOrFail`, `validateLocalSessionFilePath` behind `withGuardedOutsideWorktree`, so an absolute path in a followed outside worktree obeys the same rules, sensitive/blocked/`.git` 403), and both spellings are checked, the realpath and the path as named. ⚠️ **A symbolic link is refused (400), never followed**: the confinement check returns the REALPATH, i.e. the link's target, so an `unlink` of it deleted `CLAUDE.md` for `AGENTS.md -> CLAUDE.md` and left the link dangling. The final component is `lstat`ed on the path AS NAMED, only a plain file goes on, the inode is compared again before `unlink`, and `unlink` never follows a final link. A dangling link fails realpath and reads as 404. ⚠️ Remote sessions are refused before any path check (a same-named local folder would take the delete), and so are adopted ones (the `/tmp` file-surface exception is not copied to a new surface); Docker workspaces are allowed like the edit route. The workspace (and the outside worktree root) is probed bounded first (#516, `422` on a dead mount). ⚠️ The button is bound from the row's escaped `data-path` in the `.file-tree-item` loops, NEVER an inline `onclick` (`encodeURIComponent` leaves `'` alone, so `x'-alert(1)-'.txt` ran script); it asks with `confirm()`, reloads through `_reloadFileBrowserKeepingQuery()` so a search re-runs, stays visible under `@media (hover: none)`, and is not drawn for remote or adopted sessions. Tests: `test/routes/file-delete-routes.test.ts`, `test/file-delete-ui.test.ts`. → [architecture-invariants#files-panel-delete](docs/architecture-invariants.md#files-panel-delete)
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. That is the lookup for a pane inside `workingDir`; a pane outside it goes to `findOutsideWorktree()`, described below. ⚠️ The security argument has two halves. For a worktree inside `workingDir` the lookup only answers a folder strictly below it, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make an INSIDE `liveWorktree` a confinement root. ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. The one exception is a listing without `root=live-any`, which omits an outside worktree; while the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `outside: true` and published on the wire as `SessionState.outsideWorktree`, never in `liveWorktree`, which stays inside-only: the session's own repository must vouch for it by FILE READS (`workingDir` must be the top of its repository or worktree, so a session in a subfolder follows nothing outside it; the candidate's `.git` FILE names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back, or its `.git` FOLDER is the session's common dir itself, which is the main worktree for a session started in a linked one, and a symlinked `.git` is refused; never spawn git from the pane watcher). It is refused at `/`, home or above, around `workingDir` and at/inside/around a Docker case workspace (an unreadable Docker list follows nothing). It is refused while attachment workspace confinement is on. It is also refused at, inside or around an attachment blocked tree, and at a root that is itself a sensitive folder such as `~/.ssh` (a child probe, since the sensitive patterns describe file locations). These rules are judged when the pane moves like the Docker rule, and multi-user mode applies `folderAllowedForUser`. The routes also re-check confinement and the blocked trees on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. ⚠️ `validateLocalSessionFilePath()` also refuses any file there that `isSensitivePath()` blocklists, so a root such as a credential folder can never serve its keys. Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it (`validateLocalSessionFilePath()`, route-helpers.ts), while a relative path still resolves against `workingDir` alone. ⚠️ It is recorded as a REAL path, and every request re-checks that the folder still resolves to exactly that path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), or a symlink swapped in would re-root the routes anywhere. Tests: `test/live-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
|
||||
|
||||
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a claude session (local or remote SSH) edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`, a remote session's only through `remoteProbePaths()`. ⚠️ **Remote reads never wake a host:** only the bounded ssh read path of `remote-files.ts`, a sleeping host answers `unavailable` (wiring-guard test). Docker, non-claude and adopted sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (the API adds no case-name subfolder; the Clone Repo tab's custom folder is a PARENT, so the page sends `<folder>/<case name>` through `_cloneTargetPath()`, as Create New does through `_newCaseTargetPath()`) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||
|
||||
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
|
||||
|
||||
@@ -277,14 +312,72 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
### Frontend
|
||||
|
||||
**Frontend invariants live in [`src/web/CLAUDE.md`](src/web/CLAUDE.md)**: module load-order notes and every `src/web/public/` UI subsystem (tab strip, sidebar, rail and tab layouts, tile grid, split pane, lineage lines, home screens, settings surface, skins and fonts, mobile keyboard/toolbar/composer, terminal touch, copy and paste, file-preview markdown, XLSX preview limits, z-index stack, connection-loss UI, SSE watchdog, keyboard shortcuts incl. the Ctrl+W owner decision). Claude Code loads it automatically when the Read tool opens a file under `src/web/`. ⚠️ Reading files through Bash (`cat`, `sed`, `grep`) does NOT load it, so **Read `src/web/CLAUDE.md` before editing anything in `src/web/public/`** if it is not already in context.
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Test: `test/mobile-tab-tap-zones.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||
|
||||
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. ⚠️ The one sanctioned screen taller than its box is the phone keyboard row hold (#672, `keyboardHoldsRows`, default ON, inline renderers only via `capabilities.keyboardHold`): the PTY keeps its rows while the keyboard is up (each height change makes Claude's inline renderer drop transcript lines from tmux) and `--kb-clip` keeps the last content row at the bottom of the visible box. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](docs/architecture-invariants.md#terminal-font-weight)
|
||||
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins)
|
||||
|
||||
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
|
||||
|
||||
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices)
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](docs/architecture-invariants.md#websocket-output-gap-reconcile)
|
||||
|
||||
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
|
||||
|
||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry.
|
||||
|
||||
### Security
|
||||
|
||||
**Full model: [`docs/security-architecture.md`](docs/security-architecture.md)** (network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, recommended setups). **Layer-by-layer detail with the history behind each: [architecture-invariants#security-layers](docs/architecture-invariants.md#security-layers).**
|
||||
@@ -297,11 +390,11 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
| **CSRF / Origin** | Always-on cross-site Origin guard on state-changing requests. **A missing Origin is allowed** so curl/CLI and hooks keep working. ⚠️ The body parser keeps `text/plain` RAW; auto-JSON-parsing it enabled simple-request CSRF |
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). Multi-user mode instead checks 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) BEFORE the password, so past either even a correct password from that IP waits (the account still logs in from another IP), plus 10 per username AFTER it (429 to wrong guesses from any IP, never to a correct password). QR and hook-secret have separate buckets, so neither can lock out login |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
|
||||
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
|
||||
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
|
||||
| **Tunnel** | Enabling a tunnel **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted). `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` acknowledges the BIND only and never satisfies this guard (#706: `install.sh` writes it for every passwordless LAN install). The authorization is decided ONCE per request, running or not, and required again at the `start()` site: the settings-file awaits between them let a concurrent stop land, and an unauthorized enable never writes `tunnelEnabled:true`. With no login the browser asks before every enable (welcome link, tunnel panel, App Settings save that turns it on) through ONE helper, `_confirmTunnelEnable()` (settings-ui.js), which reads `loginConfigured` from `GET /api/tunnel/status` (computed in the route by the guard's own test, never in `TunnelManager`); the 403 handler is the fallback when that read fails |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`/`OMP_*`/`COPILOT_*`) |
|
||||
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
**Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway).
|
||||
@@ -321,21 +414,19 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
|
||||
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ A setting the server ACTS on must be synced, never a display key and never stripped by `saveAppSettings`: a per-device value never reaches the server, so a scheduler or watcher reads nobody's number (the Git fetch rows are pinned by `test/app-settings-structure.test.ts`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate. ⚠️ Every write to the server's `settings.json` goes through `mutateSettingsFile()` (`src/web/settings-writer.ts`: one at a time, tmp+rename, and a file that does not parse is refused, never read as `{}`), and a route awaits it before answering: two unserialized plain `writeFile`s tore the file, and the `{}` fallback then erased every other setting. The one direct writer is config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that does not parse, which the writer refuses. Pinned by `test/settings-json-write-serialization.test.ts`.
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
|
||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
||||
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
|
||||
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
|
||||
|
||||
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
**Videos and GIFs**: only GIFs a public page embeds are tracked (`docs/images/`), and `.gitignore` blocks `*.mp4`/`*.webm`/`*.mov`/`*.gif` everywhere else. Masters, promo cuts, drafts and captures live in the local, gitignored media library `pr/media/` (guide `pr/media/README.md`, generated `INVENTORY.md` via `node pr/media/inventory.mjs`); never leave a render in `tmp/` or `out/`. A GIF leaving `docs/images/` needs a hotlink check first (articles embed raw `master` URLs). → `docs/media-assets.md`
|
||||
|
||||
## Testing
|
||||
|
||||
**`npm test` is the gate and is safe to run bare** — it runs `config/vitest.ci.config.ts`, exactly what CI runs, so local green means CI green.
|
||||
@@ -355,17 +446,17 @@ npm run test:perf # wall-clock benchmarks — need an otherwise idle machin
|
||||
npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default
|
||||
```
|
||||
|
||||
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note in `src/web/CLAUDE.md`). The browser suite (not mobile or perf) also runs nightly in `.github/workflows/browser-suite.yml`, which catches a regression after it lands, not before.
|
||||
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above).
|
||||
|
||||
⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
|
||||
|
||||
A raw `npx vitest` used to find no config, so `setup.ts` never ran and the suite wrote fixtures into the REAL `~/.codeman` (a `clis.json` with a test entry, `users.json`, `webhook.json`, `.env`, settings values: it looks exactly like "my settings were reset"). The root `vitest.config.ts` now re-exports `config/vitest.ci.config.ts`, so a bare run is isolated too, and `test/test-env-isolation.test.ts` pins that. Still prefer `npm test --`, and pass `--config config/vitest.config.ts` (or the browser/mobile/perf configs) deliberately for the suites the gate leaves out.
|
||||
Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test --` or pass `--config`.
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.config.ts` is the everything-config behind `test:all`; `config/vitest.ci.config.ts` is the gate and derives its excludes from `config/test-suites.ts`, which is also what `vitest.browser.config.ts` and `vitest.perf.config.ts` derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them.
|
||||
|
||||
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ A bare `npx vitest` gets `setup.ts` only through the root `vitest.config.ts` (a re-export of the gate config): keep that file, and never point `--config` at a config without `setupFiles`. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
||||
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
||||
|
||||
**Ports**: every in-process test server binds port 0 (the one exception, `test/codex-predictive-echo.test.ts`, starts a separate lab server process on 3222). A test that constructs `WebServer` reads `server.boundPort` (mobile tests: `createTestServer()`, then `server.boundPort`); a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a number or `…PORT` constant. A test that needs a port nothing listens on binds 0, reads it and closes (`closedPort()` in `test/daemon-control.test.ts`), never "the server's port + 1". Never 3000 (the live instance).
|
||||
**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance).
|
||||
|
||||
⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t <pane>` for what actually reached the PTY), not on HTTP 200.
|
||||
|
||||
@@ -383,7 +474,7 @@ curl -sk https://localhost:3000/api/subagents | jq # Background agents
|
||||
cat ~/.codeman/state.json | jq # Persisted state
|
||||
```
|
||||
|
||||
Legacy screenshots (deprecated): `GET/POST /api/screenshots` still read and write `~/.codeman/screenshots/`, but the upload page that fed them is gone, they log a one-time deprecation warning, and they are removed in a later MAJOR, after at least one MINOR release that carries the warning (`docs/versioning-policy.md`). To hand a file to an agent use `POST /api/sessions/:id/paste-image` (see Prompt uploads).
|
||||
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
|
||||
|
||||
## Performance & Limits
|
||||
|
||||
@@ -395,6 +486,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
|
||||
|
||||
## Scripts & Tunnel
|
||||
|
||||
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli <id>|none`, `install.sh preflight`, `install.sh agent <id>` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
|
||||
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
|
||||
|
||||
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
@@ -5,13 +5,7 @@
|
||||
<h2 align="center">Mission control for AI coding agents</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • Terminal - One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/Download_for_Mac-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="Download Codeman for Mac (Codeman.dmg)" width="354"></a>
|
||||
<br>
|
||||
<sub>The native Mac app: signed and notarized, Apple Silicon and Intel, macOS 11.3 or later. <a href="#on-a-mac-the-codeman-app">What it does</a></sub>
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Terminal - One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -30,10 +24,10 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||
</p>
|
||||
|
||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||
|
||||
Get started in one line (macOS & Linux, Windows via WSL):
|
||||
|
||||
@@ -48,18 +42,15 @@ codeman web
|
||||
|
||||
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
||||
|
||||
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
|
||||
|
||||
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
|
||||
- **One dashboard, nine CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
|
||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
||||
- **Agents can drive it too** - the [codeman skill](#the-agent-skill-start-here) (for every agent CLI), [`codeman agent`](#codeman-agent--session-to-session-verbs-in-every-cli-mode) and an [MCP server](#mcp-server-codeman-mcp-drive-codeman-from-any-mcp-client) let one agent start workers in any CLI, prompt them, wait for them and collect their answers
|
||||
- **Nothing gets lost** - tmux persistence across restarts and network drops, exactly-once input delivery, full-scrollback replay
|
||||
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -76,10 +67,9 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
|
||||
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
|
||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
|
||||
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
|
||||
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -93,32 +83,10 @@ 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. After updating, run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. 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.
|
||||
|
||||
**Prefer Docker Compose?** Clone the repo and run one script (Linux, Docker with the Compose v2 plugin):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
|
||||
bash docker/Start-Codeman.sh
|
||||
```
|
||||
|
||||
The first run asks three questions (data folder, port, password; Enter takes the default, including a generated password), writes `docker/.env` for you, builds the image and ends on the URL once Codeman answers. The image already includes Claude Code, Codex, Gemini CLI and OpenCode. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. To update, use **App Settings → Updates**, or `git pull` and run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for Unraid, direct Compose commands, storage and networking options.
|
||||
|
||||
### On a Mac: the Codeman app
|
||||
|
||||
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
|
||||
|
||||
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
||||
|
||||
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
|
||||
|
||||
- **Quitting the app doesn't stop Codeman.** An install from the app runs Codeman as a LaunchAgent that starts at login, so your agents keep working with the window closed, and your browser and phone reach it as before.
|
||||
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later installed. On an older WebKit the app says so and does not connect.
|
||||
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
|
||||
- **Updating.** The app doesn't update itself yet: download the new `Codeman.dmg` and drag it over the old copy.
|
||||
|
||||
First launch, known limits and how to build it yourself: [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app) in the wiki and [packages/desktop/README.md](packages/desktop/README.md).
|
||||
|
||||
<details>
|
||||
<summary><strong>Keep it running in the background</strong></summary>
|
||||
|
||||
@@ -244,8 +212,6 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
|
||||
|
||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident; on Codex sessions the bar also shows `⇧←` / `⇧→` (Shift+Left / Shift+Right: edit the last queued message / return through the prompt stack)
|
||||
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
|
||||
- **Attach button**: the picture icon beside the mic opens the system photo and file sheet without raising the keyboard. Photos and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into the session's folder and their path lands on your prompt, unsent
|
||||
- **Paste in a shell**: a multi-line paste goes in as one block and waits for your Enter, instead of running line by line as it arrives
|
||||
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
|
||||
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling; on a folding phone (iPhone Duo) dialogs stay clear of the hinge, and opening or closing the device is never mistaken for the keyboard
|
||||
|
||||
@@ -290,7 +256,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
|
||||
| Field | What it does |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, or `Terminal` (plain shell). |
|
||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, or `Terminal` (plain shell). |
|
||||
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
||||
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
||||
|
||||
@@ -298,18 +264,18 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
### 3. Read the dashboard
|
||||
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet. The rail also has a **Search sessions** box, named **tab groups** (a group's menu starts a **New session** straight into it), and a **Focus** section of shortcuts to the tabs you keep coming back to.
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet.
|
||||
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
|
||||
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||
|
||||
### 4. Talk to the agent
|
||||
|
||||
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
|
||||
- **Paste, drop or attach images, videos and documents** (PDF, Office files, zip and plain text) directly into the session. They are saved in a hidden `.codeman-uploads/` folder in the workspace (it ignores itself in git and is swept after 7 days), and the file's path lands on your prompt, unsent.
|
||||
- **Paste or drag-and-drop images** directly into the session.
|
||||
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop).
|
||||
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view, including `~/` and relative paths and paths broken across lines. An `.html` report opens as a real page, Markdown opens rendered, and a folder opens as a grid of its files.
|
||||
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues the prompts waiting on you from Claude Code, Codex and DeepSeek Harness sessions in one list. Claude prompts are answerable from the header bell, Claude dialogs also from the phone home screen, and a Claude permission prompt also from a push notification's Approve/Deny buttons; Codex and DeepSeek items are alerts that tell you to answer in the terminal, and prompts from the other CLIs do not enter it. 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
|
||||
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it. A program that copies with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard too.
|
||||
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view.
|
||||
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
|
||||
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it.
|
||||
|
||||
### 5. Make it autonomous
|
||||
|
||||
@@ -328,7 +294,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
### 7. Operate & maintain
|
||||
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance and tile animations (their own **Animations** section), notifications (with adjustable toast and browser-notification display times), display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language. **Apply** saves without closing Settings.
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance animations, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
||||
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
|
||||
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
|
||||
- **Deploy your own changes** — see [Development](#development).
|
||||
@@ -415,21 +381,6 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
|
||||
|
||||
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
|
||||
|
||||
### Tile Grid
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800">
|
||||
</p>
|
||||
|
||||
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
|
||||
|
||||
- **Opens what matters**: with no grid arranged yet, it fills the tiles with the sessions that are working first, then the ones waiting on you, then the most recently used, and always includes the one you are on.
|
||||
- **Keeps your layout**: once you arrange a grid (which session sits where, empty cells, tile count, divider sizes, the focused and zoomed tile), the Tiles button brings it back exactly, and so does a page reload while it was open.
|
||||
- **Tile animations** _(opt-in)_: **App Settings → Animations → Tile Animations** makes the grid open with a show: **CRT** (each tile switches on as a hot line, as in the GIF at the top), **Fly from tab**, **Deal**, **Beam down**, **Cascade**, **Pop** or **Soft**. Nothing moves under reduced motion.
|
||||
- **Scrolls like the main terminal**: the mouse wheel scrolls a fullscreen Claude conversation inside its tile, a Codex tile pages its transcript, and `Shift+wheel` scrolls the tile's local history.
|
||||
- **Detach Tiles** _(beta, opt-in)_: with **App Settings → Appearance → Detach Tiles** on, drag a tile's header out of the browser and it pops out into its own window; drop it on another Codeman window's tiles and it moves there.
|
||||
- **Hands-free**: with the [gesture overlay](#more-features) on, pinch a tile and carry it onto another tile to swap them, onto an empty cell to move it, or carry a tab onto the grid to add it.
|
||||
|
||||
### Persistent Sessions
|
||||
|
||||
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
|
||||
@@ -463,7 +414,7 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
### Tab Alerts
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="Tab states, annotated: a working tab with a spinning green ring, a red tab blocked on the agent's question shown below it, and a yellow tab whose turn is done, both alert tabs breathing" width="900"></a>
|
||||
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
|
||||
</p>
|
||||
|
||||
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
||||
@@ -489,15 +440,10 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## More Features
|
||||
|
||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. **Fetch** in that window's header runs `git fetch` for the repositories it lists, on your click or on an interval you turn on (**App Settings → Bottom bar**, off by default; **Session Options → Git** overrides both the switch and the interval per session). Sessions open on the same folder share one run and each sees its result. Apart from that fetch Codeman never fetches or changes the repo, so "behind" is as of the last fetch; not shown for Docker or remote sessions.
|
||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
||||
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
|
||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
@@ -507,17 +453,10 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input
|
||||
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons
|
||||
- **HTML reports open as pages**: click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer shows it as a real page, with the scripts, styles, images and data files from its folder; the **Page** pill switches to the source and its Edit button. The page runs sandboxed with an opaque origin, so it cannot read Codeman's cookies, call Codeman's API or read files outside its own folder. Network access stays open, so a dashboard that loads Chart.js or fonts from a CDN works as it would from disk. Broad folders (`/`, your home, `/tmp`) are refused, so give a report a folder of its own. A `.md` path clicked in the terminal opens rendered too
|
||||
- **Clickable paths and the folder grid**: paths written as `~/…`, relative to the session folder, or broken across terminal lines are clickable in the terminal and the Response Viewer, and so are folders: a folder opens a grid of its files (renders, screenshots, reports) that you page through with the arrow keys
|
||||
- **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies
|
||||
- **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
|
||||
- **Discord bridge**: opt-in and two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md)
|
||||
- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md)
|
||||
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones
|
||||
- **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart
|
||||
- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device
|
||||
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in animations for tabs, agent windows, the terminal pane, connection lines and [tile-grid tiles](#tile-grid), all in App Settings → Animations (with a lab to try them)
|
||||
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones
|
||||
- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order
|
||||
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in entrance animations for tabs, agent windows, the terminal pane and connection lines
|
||||
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
||||
|
||||
---
|
||||
@@ -548,8 +487,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
|
||||
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
|
||||
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
|
||||
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
|
||||
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host apart from the diffs the Edit history panel keeps; editing and Office previews answer a clear 400 instead of a misleading 404.
|
||||
- **Full scrollback**: loading a remote session's history reads it from the remote tmux in one bounded ssh round trip, so a session keeps its whole scrollback after a reattach or a tab switch, with no rows lost where the history meets the live screen.
|
||||
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
|
||||
|
||||
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
|
||||
|
||||
@@ -614,12 +552,10 @@ loginctl enable-linger $USER
|
||||
1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`)
|
||||
2. On success → server issues a `codeman_session` cookie (24h TTL, auto-extends on activity)
|
||||
3. Subsequent requests authenticate silently via cookie
|
||||
4. 10 failed attempts per IP → 429 rate limit (15-minute decay). In multi-user mode the per-IP cap is 50, and each account allows 10 failed guesses per IP; past either, even a correct password from that IP waits for the decay, while the account still logs in from another IP
|
||||
4. 10 failed attempts per IP → 429 rate limit (15-minute decay)
|
||||
|
||||
**Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions.
|
||||
|
||||
The tunnel Codeman manages (App Settings → System → Remote access) refuses to start without a login (`CODEMAN_PASSWORD` or multi-user mode) unless `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` acknowledges the exposure, or you accept the browser's confirm, which it asks before every enable while no login is set. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers only the network bind: it does not let the tunnel start.
|
||||
|
||||
</details>
|
||||
|
||||
### QR Code Authentication
|
||||
@@ -706,7 +642,7 @@ By default Codeman launches sessions with `--dangerously-skip-permissions`, so t
|
||||
|
||||
- **Loopback by default** — the server binary binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box (the guided installer asks about network access and configures the binding + password for you). Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
|
||||
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
|
||||
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. Multi-user mode allows 50 failures per IP instead, plus 10 failed guesses per account from one IP; both are checked before the password, so past either a correct password from that IP also waits for the decay, while the account still logs in from another IP. QR auth has its own separate limiter
|
||||
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
|
||||
- **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Agents & CLIs → Claude → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant
|
||||
|
||||
### Always-on browser hardening (v0.9.5)
|
||||
@@ -721,8 +657,8 @@ These run for **every** request — before auth, even on the default no-password
|
||||
|
||||
### Input, files & headers
|
||||
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` / `COPILOT_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes. The one place HTML renders is the File Viewer's page view: a separate `/html-view/<capability>/` address scoped to the file's own folder, served under `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page has an opaque origin and can never read Codeman's cookies or drive its API; hidden folders and broad roots (`/`, home, `/tmp`) are refused, and the capabilities are revoked on logout
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||
|
||||
### Supply chain & isolation
|
||||
@@ -756,7 +692,7 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | ------------------------------------------------------------- |
|
||||
| `Ctrl+W` | Not a Codeman shortcut: it reaches the terminal (delete the previous word). **Close Session** has no default key and can be bound in App Settings → Shortcuts |
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd/Option+K` | Find open session or start a new one |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
@@ -769,10 +705,6 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl+Shift+G` | Open or close the tile grid |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile one place (into an empty cell, or swap) |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid |
|
||||
| `Ctrl/Cmd +` / `-` | Font size |
|
||||
| `Ctrl/Cmd+?` | Keyboard help |
|
||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||
@@ -790,25 +722,19 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
||||
|
||||
### The agent skill (start here)
|
||||
|
||||
Everything in this section also ships as an **agent skill**, [`skills/codeman`](skills/codeman/SKILL.md), in the `SKILL.md` format Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself. Full guide, with every install route, the recordings and what it will and will not do on its own: [The Codeman Skill](https://github.com/Ark0N/Codeman/wiki/Codeman-Skill).
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
|
||||
</p>
|
||||
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||
|
||||
#### Step 1: install it
|
||||
|
||||
Usually there is nothing to install: since 1.50.0 every new session gets the skill in its folder, where its CLI looks for skills (the web UI row below, on by default). The copy ignores itself in git, so your repository never lists it as a new file. The other routes are for pi, or for a copy that does not depend on that setting.
|
||||
|
||||
| How | Command | Scope |
|
||||
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`). |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, works for any skills-aware agent |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
|
||||
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, **on by default** since 1.50.0; a Claude Code that also has the plugin lists the skill twice, both work, so drop one if the duplicate bothers you) |
|
||||
| Bundled CLI | `codeman skill install --case <name>` | One case only |
|
||||
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | Auto-injects into each case on Claude session create (`agentSkillEnabled`, SYNCED, default off) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every Codeman-managed copy in that case, whichever CLI's directory it sits in), and never touches a `skills/codeman` you wrote yourself.
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a `skills/codeman` you wrote yourself.
|
||||
|
||||
#### Step 2: ask for things
|
||||
|
||||
@@ -820,22 +746,11 @@ That is the entire interface. No curl, no endpoint names, no session ids. These
|
||||
| _"Start a shell worker on the `myapp` case, run the test suite, tell me if it passes."_ | Spawns, waits on a split completion marker, reads back the exit code, cleans up. |
|
||||
| _"Spin up 3 workers for lint, typecheck and tests. Run them in parallel, report failures."_ | The fan-out flow: one session per task, all started first, then gathered as each finishes. |
|
||||
| _"Have a claude worker on `refactor-auth` summarize `src/session.ts`, then close it."_ | Spawns, runs the readiness ladder (first-run trust dialog included), send-and-wait, reads the clean transcript answer, deletes. |
|
||||
| _"Use the local model to summarize every file in `docs/`, then check its summaries."_ | Starts a worker on the model Codeman installed on this Mac, gives it the files, reads its answer back and reviews it in the cloud. Says why instead when no local model is installed. |
|
||||
| _"Watch session w4 and tell me if it gets stuck on a permission prompt."_ | Blocks on the `blocked` signal and surfaces the question to **you**. It never answers another session's prompt itself. |
|
||||
|
||||
One short line is enough. Here a Claude Code lead gets `use codeman to start 5 claude workers, one per TODO item`, and five real Claude Code workers power on, shown side by side in the tile grid:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-skill-grid-20261015.gif" alt="A real codeman skill run in the tile grid: the lead Claude Code session powers on as the only tile, the prompt use codeman to start 5 claude workers, one per TODO item is typed, then five Claude Code worker sessions power on as new tiles one after another with the CRT animation, and all six agents work at once" width="900">
|
||||
</p>
|
||||
|
||||
#### Step 3: nothing
|
||||
|
||||
The agent deletes every session it started. Watch the tabs appear and disappear in the dashboard while it works. With eight workers, each one gets its own tab, and a lineage line runs back to the lead that spawned it:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-skill-tabs-20261015.gif" alt="A real codeman skill run in the normal view: the prompt use codeman to start 8 claude workers, one per TODO item is typed, eight worker tabs power on left to right while lineage lines draw from the lead to each one, then clicking a worker tab shows a live Claude Code worker receiving its task" width="900">
|
||||
</p>
|
||||
The agent deletes every session it started. Watch the tabs appear and disappear in the dashboard while it works.
|
||||
|
||||
#### A real run, start to finish
|
||||
|
||||
@@ -864,17 +779,15 @@ Those `DONE_<task>_<random>` strings are the skill's **split marker** trick, and
|
||||
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 worked flows: claude, DeepSeek Harness and shell workers, fan-out, blocked-worker watch, messaging fan-out. On demand. |
|
||||
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. |
|
||||
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. |
|
||||
| [`reference/history.md`](skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. On demand. |
|
||||
| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in: bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. On demand. |
|
||||
|
||||
Every recipe in there was verified against a live server, and the comments record the failure modes that were measured rather than guessed.
|
||||
|
||||
#### Two things worth knowing
|
||||
|
||||
- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
|
||||
|
||||
⚠️ The web UI injection is **on by default** since 1.50.0. A `settings.json` without the `agentSkillEnabled` key means on. Existing installs that stored off are switched on once at the first 1.50.0 start (every App Settings save on 1.49.0 or earlier wrote the old default, `false`, whether or not you touched this switch); turning it off afterwards sticks. Turning it off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` dir). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
---
|
||||
|
||||
@@ -893,13 +806,13 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
|
||||
|
||||
### Rules of the road (read before you POST)
|
||||
|
||||
1. **Input must end in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). With `useMux`, embedded newlines are kept and the lines go in as one paste, so `"echo A\necho B\r"` runs both commands in a shell; the check that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so put long multi-line content in a file the agent is told to read.
|
||||
1. **Single-line input, ending in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs the joined command `echo Aecho B`: send one line per call.
|
||||
2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt.
|
||||
3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). ⚠️ A `401` replies with the bare string `Unauthorized`, **not** the JSON envelope, so piping it into `jq` throws a parse error instead of showing the failure: check the status before parsing.
|
||||
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
||||
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
||||
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
|
||||
7. **Only `claude` and `deepseek` sessions emit `stop`; `codex` emits `blocked` too.** Those come from hooks (Claude Code's own, and the DeepSeek Harness status bridge), except codex's `blocked`, which Codeman reads off its screen while an approval, question or trust dialog is up (answer it in the terminal). `shell` and the other external CLIs (opencode/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`, and codex adds `blocked`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
|
||||
7. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Those two come from hooks (Claude Code's own, and the DeepSeek Harness status bridge); `shell` and the other external CLIs (opencode/codex/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
|
||||
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
|
||||
|
||||
### Recipes
|
||||
@@ -977,7 +890,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
|
||||
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot) have no transcript:
|
||||
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp) have no transcript:
|
||||
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
|
||||
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
|
||||
# BYTES, and what comes back is terminal data, ANSI included.
|
||||
@@ -1012,98 +925,10 @@ codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman attach <path> # show an attachment card for a local file
|
||||
codeman notify "<message>" [--urgent] [--file <path>] # post to this tab's Discord thread (see the Discord bridge)
|
||||
codeman tui --list # numbered session list (plain text when piped)
|
||||
codeman tui 3 # attach to session 3 of that list
|
||||
```
|
||||
|
||||
### `codeman agent` — session-to-session verbs in every CLI mode
|
||||
|
||||
The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental** and **opt-in**: turn on **Experimental agent coordination** in App Settings (`agentCoordinationEnabled`, synced, off by default; until then the routes answer `403 FORBIDDEN` naming it). Their commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases, so pin a compatible version when automating them; see [Experimental agent coordination](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
|
||||
SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default)
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled)
|
||||
codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only
|
||||
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
|
||||
codeman agent interrupt "$SID" # a bare ESC, conversation intact
|
||||
codeman agent post "$SID" 'context for later, not a prompt' # stored in its inbox, not typed into the pane
|
||||
codeman agent inbox --wait --timeout 300000 # your own inbox: blocks while nothing is new
|
||||
codeman agent ack <msg-id> # remove what you handled (read never removes)
|
||||
codeman agent rm "$SID" # any session except this one
|
||||
```
|
||||
|
||||
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd, and so does a cwd that is your home directory (Codeman's hooks would land in `~/.claude`), which needs an explicit `--working-dir ~`; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
|
||||
|
||||
### `codeman history read`: saved Codex conversations, offline
|
||||
|
||||
`codeman history read` reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else `~/.codex/sessions`) straight from disk: no server, no managed session, nothing written. Unlike `codeman agent` it works anywhere, including on a machine where Codeman is not running.
|
||||
|
||||
```bash
|
||||
codeman history read --session-id 019fc604 # one conversation, by native id or 8+ character prefix
|
||||
codeman history read --root-session-id <uuid> --topic auth # a conversation and every sub-agent or fork under it
|
||||
codeman history read --file /path/to/rollout.jsonl --format text
|
||||
```
|
||||
|
||||
Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitive match; `--after`/`--before` are inclusive UTC dates). A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Output is JSON by default and carries `complete: false` whenever the evidence is partial (a file that could not be attributed, a compressed `.jsonl.zst` rollout, a record a running Codex is still writing, an unknown ancestor). Every bound fails closed and is raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (default 65536), `--scan-bytes` (the selected transcripts, default 16 MiB) and `--max-bytes` (output, default 8192). The full reference is [`skills/codeman/reference/history.md`](skills/codeman/reference/history.md).
|
||||
|
||||
### MCP server (`codeman mcp`): drive Codeman from any MCP client
|
||||
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API. The MCP server adds no routes of its own, and auth and ownership are unchanged.
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `"CODEMAN_PASSWORD"` to `env` when the server has a password (or let it read the data dir's `.env`). Without the `codeman` command on PATH (a git checkout run without the installer), use `node /path/to/Codeman/dist/index.js mcp`.
|
||||
|
||||
| Tool | Does |
|
||||
| ---- | ---- |
|
||||
| `list_sessions` | Every session you can see, with its run mode, status, folder, and whether these tools may prompt it |
|
||||
| `start_session` | A new worker in a case (a fresh scratch folder, or an existing case) in any installed CLI; waits until it can take a prompt |
|
||||
| `send_prompt` | Types a one-line prompt and presses Enter; optionally waits for the end of the turn |
|
||||
| `wait_for_session` | Waits for a signal (`stop`, `blocked`, `idle`, `working`, `exit`) or a literal marker in the output |
|
||||
| `read_output` | The last answer, the whole conversation, or the last bytes of the terminal |
|
||||
| `interrupt_session` | One Escape: stops the current turn, keeps the conversation |
|
||||
| `delete_session` | Closes a worker (its case folder stays) |
|
||||
|
||||
Rules the server enforces rather than documents:
|
||||
|
||||
- **It never guesses a server.** The URL comes from `CODEMAN_API_URL` or `--url`; without one it still starts and lists its tools, and every call says how to configure it.
|
||||
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions this server started (and, run inside a Codeman session, that session's own workers). Listing, reading and waiting work on everything. `codeman mcp --allow-any-session` lifts the limit, for a user who wants an assistant to drive their existing tabs. It never acts on the session it runs in.
|
||||
- **Waits fit the client.** A wait defaults to 45 s (Codex cuts MCP tools off at 60 s by default) and is capped at 600 s; `timed_out` / `still_working` means call again.
|
||||
- **Both protocol eras.** It answers the 2026-07-28 revision (per-request `_meta`, `server/discover`) and the `initialize` handshake of 2024-11-05 to 2025-11-25.
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||
@@ -1128,7 +953,6 @@ REST over Fastify — **~230 handlers across 25 route modules**, plus an SSE str
|
||||
| `GET` | `/api/sessions/:id/last-response` | The last answer as clean text, read from the transcript (claude, codex, deepseek) |
|
||||
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
|
||||
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
|
||||
| `POST` / `GET` | `/api/sessions/:id/inbox` | Agent inbox: post `{text}` / read every pending message (`?wait=` blocks while nothing is new); `POST …/inbox/ack {ids}` removes them, receiver only |
|
||||
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
|
||||
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
|
||||
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
|
||||
@@ -1218,7 +1042,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["External"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
|
||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
end
|
||||
end
|
||||
@@ -1285,7 +1109,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 5.7 kB minified and gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 276 tests. Typing cost does not grow with prompt length.
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests.
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
|
||||
@@ -5,13 +5,7 @@
|
||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD_Mac_%E7%89%88-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="下载 Mac 版 Codeman(Codeman.dmg)" width="281"></a>
|
||||
<br>
|
||||
<sub>原生 Mac 应用:已签名并经过 Apple 公证,支持 Apple 芯片和 Intel,macOS 11.3 或更高版本。<a href="#在-mac-上codeman-应用">它能做什么</a></sub>
|
||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -30,12 +24,12 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||
|
||||
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP 或 GitHub Copilot CLI,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
||||
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness 或 OMP,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
||||
|
||||
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
||||
|
||||
@@ -50,18 +44,15 @@ codeman web
|
||||
|
||||
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
||||
|
||||
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
|
||||
|
||||
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
||||
- **一个仪表盘,九个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek 或 OMP](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
||||
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
||||
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
||||
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
||||
- **智能体也能驱动它**:[codeman 技能](#智能体技能从这里开始)(适用于所有智能体 CLI)、[`codeman agent`](#codeman-agent每种-cli-模式都能用的会话间命令) 和一个 [MCP 服务器](#mcp-服务器codeman-mcp从任意-mcp-客户端驱动-codeman),让一个智能体在任意 CLI 里拉起工作会话、给它们发提示、等它们完成并收回答案
|
||||
- **什么都不会丢**:tmux 让会话挺过重启和断网,输入精确一次送达,完整的回滚缓冲区回放
|
||||
- **自托管、私有**:默认仅环回、MIT 许可、无遥测,完全运行在你自己的机器上
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
|
||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -72,16 +63,14 @@ codeman web
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js、tmux 和一套构建工具链(node-pty 没有 Linux 预编译包,需要从源码编译),把 Codeman 克隆到 `~/.codeman/app` 并完成构建。它会先看看机器上已有什么,最多问三个问题,然后无人值守地完成所有工作,最后给出访问 URL 和一个供手机扫描的二维码。几点须知:
|
||||
该脚本会在缺失时自动安装 Node.js、tmux 和一套构建工具链(node-pty 没有 Linux 预编译包,需要从源码编译),把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
||||
|
||||
- **三个问题,全部在开头问完。** 仪表盘怎么访问、(可选)这台机器在你的 tailnet 里叫什么,以及是否把 Codeman 作为后台服务运行(systemd/launchd,开机自启;直接回车即为「是」)。所有需要你出面的事,包括对所有缺失软件包的一次性同意、一次 sudo 密码和 Tailscale 登录,都在构建之前完成,所以编译期间你可以走开。
|
||||
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。如果你节点上的 `:443` 已被别的应用占用,Codeman 会改用 `https://<机器名>.<tailnet>.ts.net/codeman` 或第二个端口,而不是把它替换掉。手动运行的 `codeman web` 仍默认仅环回。
|
||||
- **名字由你来定。** 默认情况下,URL 用的是这台机器在 tailnet 里现有的名字。第二个问题答「是」会把机器改名为 `codeman-<hostname>`(这也会改掉它在 SSH 里的名字,所以默认是「否」);之后也可以用 `install.sh name` 来改。
|
||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
|
||||
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
|
||||
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。
|
||||
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。手动运行的 `codeman web` 仍默认仅环回。
|
||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
|
||||
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -95,32 +84,10 @@ codeman users add alice --admin # 创建第一个管理员账号
|
||||
codeman web --multiuser # 命名登录 + 按用户隔离的案例空间
|
||||
```
|
||||
|
||||
**更喜欢 Docker Compose?** 克隆仓库,运行一个脚本即可(Linux,需要 Docker 和 Compose v2 插件):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
|
||||
bash docker/Start-Codeman.sh
|
||||
```
|
||||
|
||||
首次运行会问三个问题(数据目录、端口、密码;回车即采用默认值,包括自动生成的密码),替你写好 `docker/.env`,构建镜像,并在 Codeman 响应后给出访问地址。镜像已内置 Claude Code、Codex、Gemini CLI 和 OpenCode。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新时请使用 **App Settings → Updates**,或者先 `git pull` 再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。Unraid、直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。
|
||||
**更喜欢 Docker Compose?** `docker/` 里附带一套本地镜像的 Compose 部署:把 `docker/.env.example` 复制为 `docker/.env`,设置 `CODEMAN_PASSWORD`,然后在 Linux 上运行 `bash docker/Start-Codeman.sh`。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新之后请再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。
|
||||
|
||||
详见下文[多用户模式](#多用户模式可选启用)。
|
||||
|
||||
### 在 Mac 上:Codeman 应用
|
||||
|
||||
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
|
||||
|
||||
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
||||
|
||||
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
|
||||
|
||||
- **退出应用不会停止 Codeman。** 通过应用安装的 Codeman 以 LaunchAgent 的形式运行,登录时自动启动,所以关掉窗口后智能体照样工作,浏览器和手机也和以前一样能访问它。
|
||||
- **Codeman 的页面需要 Safari 16.4 的 WebKit。** 应用本身在 macOS 11.3 上就能启动,但 Codeman 需要 macOS 13.3 或更高版本,或者装有 Safari 16.4 或更高版本的 macOS 11 或 12。WebKit 更旧时,应用会说明原因,并且不会连接。
|
||||
- **在应用里安装需要 macOS 15,或者机器上已有 tmux。** tmux 来自 tmux 项目自己的构建,而这些构建需要 macOS 15。在没有 tmux 的旧 Mac 上,应用会改为提供终端安装方式,由 Homebrew 在终端里安装 tmux(这需要管理员操作一次)。
|
||||
- **更新。** 应用暂时不会自我更新:下载新的 `Codeman.dmg`,拖过去覆盖旧的那份即可。
|
||||
|
||||
首次启动、已知限制以及如何自行构建:见 wiki 的 [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app)(英文)和 [packages/desktop/README.md](packages/desktop/README.md)(英文)。
|
||||
|
||||
<details>
|
||||
<summary><strong>让它在后台一直运行</strong></summary>
|
||||
|
||||
@@ -246,8 +213,6 @@ Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.
|
||||
|
||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触;在 Codex 会话上还会显示 `⇧←` / `⇧→`(Shift+Left / Shift+Right:编辑上一条排队的消息 / 在提示栈里回退)
|
||||
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
|
||||
- **附件按钮**:麦克风旁边的图片图标会直接打开系统的照片与文件面板,不会弹出键盘。照片和相册里的视频(mp4、mov、webm,最大 1 GB)会上传到会话所在的文件夹,文件路径落在你的提示里,但不会自动发送
|
||||
- **在 shell 里粘贴**:多行粘贴会作为一整块插入并等你按 Enter,而不是一边到达一边逐行执行
|
||||
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
|
||||
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动;折叠屏手机(iPhone Duo)上对话框会避开铰链,开合设备也绝不会被误判成键盘弹出
|
||||
|
||||
@@ -256,7 +221,7 @@ codeman web --https
|
||||
# 在手机上打开:https://<你的IP>:3000
|
||||
```
|
||||
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐):安装器可以替你配好(在网络访问提示处选择 **Tailscale**,或在已有安装上运行 `bash ~/.codeman/app/install.sh tailscale`)。这样你会得到带真实证书的 `https://<你的机器>.<tailnet>.ts.net`:只对你的 tailnet 可见、无需密码,手机上的 PWA 安装和推送通知也都能用。安装器最后会给出这个 URL 和一个可扫描的二维码,之后随时可以用 `bash ~/.codeman/app/install.sh status` 再次打印。
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐):安装器可以替你配好(在网络访问提示处选择 **Tailscale**,或在已有安装上运行 `bash ~/.codeman/app/install.sh tailscale`)。这样你会得到带真实证书的 `https://<你的机器>.<tailnet>.ts.net`:只对你的 tailnet 可见、无需密码,手机上的 PWA 安装和推送通知也都能用。
|
||||
|
||||
### 安全的二维码认证
|
||||
|
||||
@@ -292,7 +257,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
||||
| 字段 | 作用 |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
|
||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot` 或 `Terminal`(普通 shell)。 |
|
||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP` 或 `Terminal`(普通 shell)。 |
|
||||
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
|
||||
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
||||
|
||||
@@ -300,18 +265,18 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
||||
|
||||
### 3. 读懂仪表盘
|
||||
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。更喜欢列表?**App Settings → Appearance → Tabs** 可以把它挪进左侧边栏(带筛选框,`Alt+B` 折叠)或一条竖向导轨,导轨的行按活动状态排序:先是等你处理的,然后是跑得最久的,最后是刚刚安静下来的。导轨上还有一个 **Search sessions** 搜索框、命名的**标签分组**(分组菜单里的 **New session** 会直接在该组里新建会话),以及一个 **Focus** 区,放着你常回去的那些标签的快捷方式。
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。更喜欢列表?**App Settings → Appearance → Tabs** 可以把它挪进左侧边栏(带筛选框,`Alt+B` 折叠)或一条竖向导轨,导轨的行按活动状态排序:先是等你处理的,然后是跑得最久的,最后是刚刚安静下来的。
|
||||
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
||||
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
|
||||
### 4. 与智能体对话
|
||||
|
||||
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
|
||||
- **粘贴、拖放或附加图片、视频和文档**(PDF、Office 文件、zip 和纯文本),直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
|
||||
- **粘贴或拖放图片**,直接进入会话。
|
||||
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。
|
||||
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行,包括 `~/` 路径、相对路径和被折成多行的路径。`.html` 报告会以真实网页打开,Markdown 会渲染后打开,文件夹则以其文件的网格打开。
|
||||
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把 Claude Code、Codex 和 DeepSeek Harness 会话里等着你的提示排成一个队列。Claude 的提示可以在页头的铃铛里直接作答,Claude 的对话框也可以在手机首页作答,Claude 的权限请求还可以用推送通知上的 Approve/Deny 按钮作答;Codex 和 DeepSeek 的条目只是提醒,会告诉你去终端里作答;其他 CLI 的提示不会进入收件箱。🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
|
||||
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。用 OSC 52 复制的程序(Claude Code 的 `/copy`、tmux 的 yank、vim)也会把文本放进你浏览器的剪贴板。
|
||||
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行。
|
||||
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
|
||||
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。
|
||||
|
||||
### 5. 让它自主运行
|
||||
|
||||
@@ -330,7 +295,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
||||
|
||||
### 7. 运维与维护
|
||||
|
||||
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场与平铺动画(有自己的 **Animations** 分区)、通知(toast 和浏览器通知的显示时长可调)、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。**Apply** 按钮保存设置但不关闭设置窗口。
|
||||
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场动画、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
|
||||
- **让它在后台运行** —— `codeman web -d` 脱离你的 shell(`--status`、`--stop`);`codeman service install` 把它装成 systemd 用户单元 / macOS LaunchAgent,重启后自动回来。两者都会先确认服务器真正应答再报告成功,也都拒绝在同一个数据目录上启动第二个服务器。见[让它在后台一直运行](#快速开始--安装)。
|
||||
- **自更新** —— git-clone 安装可在 **App Settings → System → Updates** 中原地更新。
|
||||
- **部署你自己的改动** —— 见[开发](#开发)。
|
||||
@@ -417,21 +382,6 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
|
||||
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
||||
|
||||
### 平铺网格
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tile-grid-20261009.gif" alt="平铺网格:点击平铺按钮,六个实时会话并排打开(DeepSeek Harness、Claude Code、Codex、一个 shell、OpenCode 和 Pi),再点击一次即回到单个会话" width="800">
|
||||
</p>
|
||||
|
||||
在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。
|
||||
|
||||
- **先开重要的**:还没有排好网格时,它会先放正在工作的会话,然后是等你处理的,再是最近用过的,并且总会包含你当前所在的会话。
|
||||
- **记住你的布局**:一旦你排好网格(哪个会话在哪、空着的格子、窗格数、分隔条大小、聚焦和放大的窗格),平铺按钮会原样恢复它,网格打开时刷新页面也一样。
|
||||
- **平铺动画**(可选启用):**App Settings → Animations → Tile Animations** 让网格华丽登场:**CRT**(每个窗格像老显像管一样以一道亮线开机,就是顶部 GIF 里那样)、**Fly from tab**、**Deal**、**Beam down**、**Cascade**、**Pop** 或 **Soft**。开启「减少动态效果」时一切静止。
|
||||
- **滚动和主终端一样**:鼠标滚轮会在窗格里滚动全屏 Claude 的对话,Codex 窗格会翻页查看它的记录,`Shift+滚轮` 滚动窗格的本地历史。
|
||||
- **分离窗格(Detach Tiles)**(测试版,可选启用):打开 **App Settings → Appearance → Detach Tiles** 后,把窗格标题栏拖出浏览器,它就弹出成独立窗口;拖到另一个 Codeman 窗口的窗格上,它就移到那边。
|
||||
- **徒手操作**:开启[手势叠加层](#更多特性)后,捏住一个窗格拖到另一个窗格上即可交换,拖到空格子上即可移动,把一个标签拖进网格即可加入。
|
||||
|
||||
### 持久化会话
|
||||
|
||||
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||
@@ -465,7 +415,7 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
|
||||
### 标签提醒(Tab Alerts)
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="标签状态(带标注):一个绿色描边在转的工作中标签,一个被智能体的问题挡住的红色标签(问题显示在它下方),以及一个回合已完成的黄色标签,两个提醒标签都在呼吸闪烁" width="900"></a>
|
||||
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="会话标签:一个普通的活动标签,旁边是黄色的等待输入标签和红色的需要决定标签,都带着呼吸式光晕" width="900">
|
||||
</p>
|
||||
|
||||
每个标签一眼就能看出状态。运行中的会话保持绿色状态点。会话停下来等待输入时,标签变**黄**:稳定的描边、着色的背景、黄色的点,上面叠一层缓慢的呼吸光晕。当权限提示或提问**挡住**了智能体,标签变**红**,脉动更快。底色永远不会闪灭,所以哪怕只瞥一眼(或截一张图)也能读到真实状态;标签被选中时描边依然可见,页面刷新后会从服务端重新装载待处理的提醒,因此一个被挡住的会话绝不可能藏在一个看起来正常的标签后面。
|
||||
@@ -491,13 +441,9 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
## 更多特性
|
||||
|
||||
- **后台守护进程与服务安装** —— `codeman web -d` 以脱离终端的方式运行服务器,带 pid 文件、`~/.codeman/web.log` 和经过校验的启动(它会轮询到服务器应答为止,所以端口冲突绝不会被当成成功);`codeman service install` 写入一个 systemd 用户单元(Linux)或 LaunchAgent(macOS),并把你 shell 的 PATH 一并写进去,这样 nvm 或 Homebrew 装的 `node`、`tmux` 和 `claude` 才真的找得到。机密永远不会写进单元文件
|
||||
- **设置里的诊断**:**App Settings → System → Diagnostics → Run checks** 会在服务器上运行 `codeman doctor`,列出 Node、tmux、每个智能体 CLI 以及可选的 Office 工具,附带版本、路径和安装提示。除了 `PATH`,它还会查找每个 CLI 惯常的安装目录(`~/.local/bin`、`~/.npm-global/bin` 之类),所以即使服务只拿到一个最小的 `PATH`,大多数安装也能被找到。多用户模式下仅管理员可用
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → System → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
|
||||
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
|
||||
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
|
||||
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **GitHub Copilot CLI**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md)、[`docs/omp-integration.md`](docs/omp-integration.md) 与 [`docs/copilot-integration.md`](docs/copilot-integration.md)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
@@ -508,15 +454,10 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Terminal & Input 启用
|
||||
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||
- **文件查看器按钮** _(可选)_ —— 页头新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Header & Panels → Header buttons 中启用
|
||||
- **HTML 报告以网页打开**:点击智能体打印出的 `.html` 路径(测试报告、覆盖率页面、仪表盘),文件查看器会把它显示成一个真实网页,并带上同一文件夹里的脚本、样式、图片和数据文件;**Page** 胶囊按钮可切到源码及其 Edit 按钮。页面在沙箱里以不透明源运行,所以它读不到 Codeman 的 cookie、调不了 Codeman 的 API,也读不了自己文件夹以外的文件。网络访问保持开放,因此从 CDN 加载 Chart.js 或字体的仪表盘和直接从磁盘打开时一样能用。范围过大的文件夹(`/`、你的主目录、`/tmp`)会被拒绝,所以请给报告一个单独的文件夹。在终端里点击的 `.md` 路径也会渲染后打开
|
||||
- **可点击的路径与文件夹网格**:写成 `~/…`、相对于会话文件夹或被折成多行的路径,在终端和回复查看器(Response Viewer)里都能点击,文件夹也可以:点击文件夹会打开它的文件网格(渲染图、截图、报告),用方向键翻看
|
||||
- **OSC 52 剪贴板**:会话里的程序用 OSC 52 设置剪贴板时(Claude Code 的 `/copy`、tmux 的 yank、vim),文本会进入你浏览器的剪贴板,主终端和每个平铺窗格都一样。读取剪贴板的请求一律拒绝,回放缓冲区也绝不会触发复制
|
||||
- **MCP 服务器同步**(可选启用):把每个已安装 CLI 的用户级 MCP 服务器(Claude、Codex、Gemini、OpenCode、Antigravity,以及 GitHub Copilot CLI)复制进其他 CLI 自己的配置文件。只增不改,你关掉的服务器绝不会被复制,**Preview** 会先列出改动,原文件保留为 `.codeman-bak`。位于 App Settings → Agents & CLIs;多用户模式下仅管理员可用
|
||||
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入,Ctrl、Alt 修饰的导航键也会原样透传给 CLI
|
||||
- **页头里的套餐用量** —— 页头实时显示 Claude 订阅用量(5 小时窗口与每周窗口),数据来自 Codeman 在拉起 `claude` 时临时交给它的 statusline 导出器,绝不会写进你的设置文件;Codex 的限额则来自它自己的 app-server。按设备生效:桌面默认开,手机默认关
|
||||
- **提示缓存状态**:每个 Claude 会话都会显示它的提示缓存是热的(`cache:until HH:MM`)还是冷的,显示在页脚(前提是你没有自己的 statusline)和 Session Options → Respawn 中,并给出重新缓存的代价,压缩上下文后还会附一条说明。只做显示,重启后依然保留
|
||||
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序。导轨上的 **Search sessions** 会把列表筛到名字匹配的标签,跨所有分组;带提醒的标签即使名字不匹配也始终可见。命名的**标签分组**可以折叠、重排和拖入拖出;分组菜单里有 **New session**,智能体拉起的工作会话会落在它的父会话后面(**Spawned tabs follow their parent**,默认关闭,开启后它会跟着父会话移动)。**Add to Focus** 会在导轨顶部的一个区里为任意标签放一个快捷方式,**CLI Logos on Tabs** 可以按设备关闭
|
||||
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的动画,覆盖标签、智能体窗口、终端面板、连接线和[平铺网格的窗格](#平铺网格),全部位于 App Settings → Animations(附带一个试玩用的动画实验室)
|
||||
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序
|
||||
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的入场动画,覆盖标签、智能体窗口、终端面板和连接线
|
||||
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
|
||||
|
||||
---
|
||||
@@ -531,7 +472,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
|
||||
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
|
||||
- **挂到你已经在跑的容器上** —— 在 Docker 面板勾选 **Attach to an existing container**,就能把 case 链接到一个现成容器,而不是新建一个。Codeman 只 `exec` 进去,绝不启动、停止、重启或删除它;一个被接管的容器可以在不同目录下支撑多个 case,**复制一个已有 case** 会用同一容器上的兄弟 case 预填表单。多用户模式下仅管理员可用,因为容器的挂载属于启动它的人。
|
||||
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上用 `docker load` 载入,再导入到一个新案例即可继续。
|
||||
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
|
||||
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
|
||||
|
||||
前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。
|
||||
@@ -547,8 +488,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
|
||||
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
|
||||
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
|
||||
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。除了 Edit history 面板保存的差异之外,什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
|
||||
- **完整的回滚历史**:加载远程会话的历史时,会通过一次有上限的 ssh 往返直接从远程 tmux 读取,所以重新附着或切换标签之后,会话依然保有完整的回滚缓冲区,历史与实时屏幕衔接处也不会丢行。
|
||||
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
|
||||
|
||||
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
|
||||
|
||||
@@ -613,12 +553,10 @@ loginctl enable-linger $USER
|
||||
1. 首次请求 → 浏览器弹出 Basic Auth 提示(用户名:`admin` 或 `CODEMAN_USERNAME`)
|
||||
2. 成功后 → 服务端签发 `codeman_session` cookie(24 小时 TTL,活动时自动延长)
|
||||
3. 后续请求通过 cookie 静默认证
|
||||
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)。多用户模式下每个 IP 的上限为 50 次,且同一 IP 对每个账户最多 10 次失败;超过任一上限后,来自该 IP 的正确密码也须等待衰减,但该账户仍可从其他 IP 登录
|
||||
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)
|
||||
|
||||
通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。
|
||||
|
||||
Codeman 自己管理的隧道(App Settings → System → Remote access)在没有登录(`CODEMAN_PASSWORD` 或多用户模式)时拒绝启动,除非用 `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` 或 `codeman web --allow-unauthenticated-tunnel` 确认这种暴露,或者你在浏览器的确认框里点了同意;没有登录时,每次开启前浏览器都会先问。`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` 只管网络绑定,并不能让隧道启动。
|
||||
|
||||
</details>
|
||||
|
||||
### 二维码认证
|
||||
@@ -703,9 +641,9 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
||||
|
||||
### 网络与访问
|
||||
|
||||
- **默认仅环回** —— 服务器程序本身绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全(引导式安装器会询问网络访问方式,并替你配置绑定和密码)。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
||||
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
||||
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
|
||||
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。多用户模式下每个 IP 改为 50 次,同一 IP 对每个账户另有 10 次失败上限;两者都在校验密码之前检查,超过任一上限后,来自该 IP 的正确密码也须等待衰减,但该账户仍可从其他 IP 登录。二维码认证有自己独立的限制器
|
||||
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
|
||||
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
|
||||
|
||||
### 始终开启的浏览器加固(v0.9.5)
|
||||
@@ -720,8 +658,8 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
||||
|
||||
### 输入、文件与响应头
|
||||
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` / `COPILOT_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行。HTML 唯一会被渲染的地方是文件查看器的网页视图:一个独立的 `/html-view/<capability>/` 地址,只覆盖该文件所在的文件夹,响应带 `Content-Security-Policy: sandbox` 且不含 `allow-same-origin`,因此页面是不透明源,永远读不到 Codeman 的 cookie,也驱动不了它的 API;隐藏文件夹和范围过大的根目录(`/`、主目录、`/tmp`)会被拒绝,退出登录时这些凭证也会被吊销
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||
|
||||
### 供应链与隔离
|
||||
@@ -755,7 +693,7 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
|
||||
| 快捷键 | 动作 |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `Ctrl+W` | 不是 Codeman 快捷键:它会到达终端(删除前一个词)。**Close Session** 默认没有按键,可在 App Settings → Shortcuts 里绑定 |
|
||||
| `Ctrl/Cmd+W` | 杀掉当前会话 |
|
||||
| `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 |
|
||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
|
||||
@@ -768,10 +706,6 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
| `Ctrl+Shift+G` | 打开或关闭平铺网格 |
|
||||
| `Alt+Shift+方向键` | 聚焦左、右、上、下方的窗格 |
|
||||
| `Ctrl+Shift+方向键` | 把聚焦的窗格移动一格(移进空格子,或与邻居交换) |
|
||||
| `Alt+Shift+Enter` | 放大聚焦的窗格,或恢复网格 |
|
||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||
@@ -789,25 +723,19 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
|
||||
### 智能体技能(从这里开始)
|
||||
|
||||
这一节的所有内容也打包成了一个**智能体技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md),采用 Claude Code、Codex、Gemini CLI、OpenCode、Grok、OMP 以及大多数其他智能体 CLI 都能读取的 `SKILL.md` 格式。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。完整说明(所有安装方式、录屏,以及它会自己做什么、不会做什么):[The Codeman Skill](https://github.com/Ark0N/Codeman/wiki/Codeman-Skill)(英文)。
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="一次真实的 codeman 技能运行:在 Claude Code 里输入一句简短的提示,平铺网格开机,然后一个跑在本地 qwen 模型上的 DeepSeek Harness 工作会话和一个 Claude Code 工作会话作为新窗格开机,主会话到两者之间画出派生连线" width="900"></a>
|
||||
</p>
|
||||
这一节的所有内容也打包成了一个 **Claude Code 技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md)。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。
|
||||
|
||||
#### 第 1 步:安装
|
||||
|
||||
通常什么都不用装:从 1.50.0 起,每个新会话都会在自己的文件夹里、在它的 CLI 查找技能的位置拿到这个技能(即下表的 Web UI 一行,默认开启)。这份副本会让 git 忽略它自己,所以你的仓库永远不会把它列为新文件。其他方式适用于 pi,或者你想要一份不依赖这个设置的副本的情况。
|
||||
|
||||
| 方式 | 命令 | 范围 |
|
||||
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用([skills.sh](https://skills.sh));给 pi 装技能就用这个方式 |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用 |
|
||||
| Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) |
|
||||
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
|
||||
| 内置 CLI | `codeman skill install --case <name> [--mode <mode>]` | 仅一个 case;`--mode codex`(或任何其他运行模式)会把它写到那个 CLI 查找技能的位置 |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,从 1.50.0 起**默认开启**;同时装了插件的 Claude Code 会把技能列出两次,两个都能用,嫌重复就去掉其中一个) |
|
||||
| 内置 CLI | `codeman skill install --case <name>` | 仅一个 case |
|
||||
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | 每次在某个 case 创建 Claude 会话时自动注入(`agentSkillEnabled`,跨设备同步,默认关闭) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装(带 `--case` 时,删除该 case 里所有由 Codeman 管理的副本,不论它在哪个 CLI 的目录里),并且绝不会碰你自己写的 `skills/codeman`。
|
||||
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装,并且绝不会碰你自己写的 `skills/codeman`。
|
||||
|
||||
#### 第 2 步:开口要
|
||||
|
||||
@@ -821,19 +749,9 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
| _「让一个 claude 工作会话在 `refactor-auth` 上总结 `src/session.ts`,然后关掉它。」_ | 拉起、走完就绪阶梯(包括首次运行的信任对话框)、发送并等待、读取干净的 transcript 答案、删除。 |
|
||||
| _「盯着会话 w4,如果它卡在权限提示上就告诉我。」_ | 阻塞在 `blocked` 信号上,并把问题交给**你**。它绝不会替另一个会话回答提示。 |
|
||||
|
||||
一句简短的话就够了。下面这个 Claude Code 主会话收到 `use codeman to start 5 claude workers, one per TODO item`,五个真实的 Claude Code 工作会话依次开机,并排显示在平铺网格里:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-skill-grid-20261015.gif" alt="一次真实的 codeman 技能运行(平铺网格):主 Claude Code 会话作为唯一的窗格开机,输入提示 use codeman to start 5 claude workers, one per TODO item,然后五个 Claude Code 工作会话以 CRT 动画依次作为新窗格开机,六个智能体同时工作" width="900">
|
||||
</p>
|
||||
|
||||
#### 第 3 步:没有了
|
||||
|
||||
智能体会删掉它启动的每一个会话。你可以在仪表盘里看着标签出现又消失。起八个工作会话时,每个都有自己的标签,还有一条派生连线连回启动它的主会话:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-skill-tabs-20261015.gif" alt="一次真实的 codeman 技能运行(普通视图):输入提示 use codeman to start 8 claude workers, one per TODO item,八个工作会话标签从左到右依次开机,主会话到每个标签之间画出派生连线,然后点开一个工作会话标签,看到一个正在接收任务的真实 Claude Code 工作会话" width="900">
|
||||
</p>
|
||||
智能体会删掉它启动的每一个会话。你可以在仪表盘里看着标签出现又消失。
|
||||
|
||||
#### 一次真实的运行,从头到尾
|
||||
|
||||
@@ -862,17 +780,15 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 个完整流程:claude、DeepSeek Harness 与 shell 工作会话、扇出、盯住被卡住的工作会话、消息扇出。按需加载。 |
|
||||
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | 完整端点表、错误码、各模式的信号表、容量限制。按需加载。 |
|
||||
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | 通过 Claude Code 跨会话消息直接和 claude 工作会话对话。按需加载。 |
|
||||
| [`reference/history.md`](skills/codeman/reference/history.md) | 用 `codeman history read` 离线读取已保存的 Codex 对话:选择器、过滤条件、分叉、预算。按需加载。 |
|
||||
| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | 实验性功能,需要手动开启:用 `codeman agent inspect`、`stage` 和 `submit` 做有界的窗格检查和受保护的提交。按需加载。 |
|
||||
|
||||
里面的每一个配方都在真实服务器上验证过,注释记录的是实测出来而不是猜出来的失败模式。
|
||||
|
||||
#### 两件值得知道的事
|
||||
|
||||
- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||
- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
|
||||
|
||||
⚠️ 从 1.50.0 起,Web UI 注入**默认开启**。`settings.json` 里没有 `agentSkillEnabled` 这个键就表示开启。已经存了关闭的旧安装,会在第一次启动 1.50.0 时被打开一次(1.49.0 及更早版本每次保存 App Settings 都会写入旧默认值 `false`,不管你有没有动过这个开关);之后你再把它关掉,就会一直保持关闭。把它关掉**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个 `.claude/` 目录的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
|
||||
---
|
||||
|
||||
@@ -891,13 +807,13 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||
|
||||
### 行路规则(POST 之前先读)
|
||||
|
||||
1. **输入必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。使用 `useMux` 时内嵌的换行会保留,各行作为一次粘贴送入,因此 `"echo A\necho B\r"` 在 shell 里会执行两条命令;但重新按下滞留 Enter 的检查无法可靠识别多行粘贴,所以较长的多行内容最好写进文件,让 agent 自己去读。
|
||||
1. **只发单行输入,而且必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。内嵌的换行会被剥掉而不是报错,因此 `"echo A\necho B\r"` 执行的是拼起来的 `echo Aecho B`:一次调用只发一行。
|
||||
2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。
|
||||
3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。⚠️ `401` 回的是裸字符串 `Unauthorized`,**不是** JSON 信封,直接喂给 `jq` 只会抛解析错误而看不到真正的失败原因:先看状态码,再解析。
|
||||
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
||||
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
||||
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
|
||||
7. **只有 `claude` 与 `deepseek` 会话会发出 `stop`;`codex` 还会发出 `blocked`。** 它们来自 hook(Claude Code 自己的,以及 DeepSeek Harness 的状态桥接),codex 的 `blocked` 除外:批准、提问或信任对话框出现时,Codeman 从它的屏幕上读出这个信号(请在终端里回答)。`shell` 与其他外部 CLI(opencode/gemini/antigravity/pi/grok/omp)只接受 `idle`、`working` 与 `exit`,codex 另外接受 `blocked`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
|
||||
7. **只有 `claude` 与 `deepseek` 会话会发出 `stop` 与 `blocked`。** 这两个来自 hook(Claude Code 自己的,以及 DeepSeek Harness 的状态桥接);`shell` 与其他外部 CLI(opencode/codex/gemini/antigravity/pi/grok/omp)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
|
||||
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
|
||||
|
||||
### 常用配方
|
||||
@@ -971,7 +887,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
|
||||
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot)没有 transcript,读终端。
|
||||
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp)没有 transcript,读终端。
|
||||
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
||||
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||
@@ -1009,76 +925,6 @@ codeman tui --list # 带编号的会话列表(管
|
||||
codeman tui 3 # 附着到该列表里的第 3 个会话
|
||||
```
|
||||
|
||||
### `codeman agent`:每种 CLI 模式都能用的会话间命令
|
||||
|
||||
上面的技能靠 Codeman 为每个会话写入的一段前导脚本,用 `curl` 和 `jq` 驱动 API。在它够不着的地方(`pi` 或 `shell` 会话、没有 `jq` 的机器),同样的环境依然存在(每个面板里都导出了 `CODEMAN_MUX=1`、`CODEMAN_SESSION_ID`、`CODEMAN_API_URL`),所以 `codeman agent` 把同一批动词打包成了命令。它只是 [API](#api) 一节所列端点之上的一层薄客户端:没有新路由,没有新的传输方式,认证和归属规则不变。在 case 的 `AGENTS.md` 里写一行就够了:*"other sessions: `codeman agent --help`"*。
|
||||
|
||||
```bash
|
||||
codeman agent ls # 列出会话;* 标记当前这个
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start,并在该模式有就绪标记时等到输入框出现
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = 默认信号集
|
||||
codeman agent read "$SID" # 最后一条回答(按服务端对该模式的读取方式)
|
||||
codeman agent read "$SID" --tail 3000 # 终端尾部,已去掉 ANSI(所有模式)
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # 没有 hook 的模式(opencode、pi 等):让它分两半输出标记……
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # ……然后等拼接后的形式,提示本身的回显里永远不会出现它
|
||||
codeman agent interrupt "$SID" # 只发一个 ESC,对话保持完整
|
||||
codeman agent rm "$SID" # 除当前会话外的任何会话
|
||||
```
|
||||
|
||||
这些规则由命令强制执行,而不只是写在文档里:在 Codeman 会话之外它们拒绝运行,也绝不猜测 URL;`send` 只发送可打印文本加 Enter(像 `Ctrl+C` 这样的控制字节在 opencode 里就是 `app_exit`,所以 ESC 只以 `interrupt` 的形式存在,而且它从不追加 Enter);`rm` 拒绝空 id、无法证明不是自己的 id,以及任一方向的前缀匹配。id 可以用 `ls` 打印的 8 字符前缀(通过列表解析;有歧义的前缀会被拒绝,短于 8 个字符的 id 在所有动词上都会被拒绝)。以 `-` 开头的提示要放在 `--` 之后(`send "$SID" -- "- fix the bug"`)。
|
||||
|
||||
你发出的提示的回显也算输出:如果 `--match` 标记原样出现在提示里,它会立刻匹配,工作会话还什么都没做,所以提示要让它分两半输出。主机正在休眠的远程会话,对不等待结果的 `send` 会回答 `buffered`(Codeman 唤醒主机,等面板恢复后再敲入提示)或 `dropped`(超出唤醒缓冲区的上限,什么都不会敲入:退出码 `1`)。退出码:`0` 已送达/已匹配/收到信号,`1` 错误,`2` 超时,`3` 工作会话已退出或等待在没有回答的情况下结束(`delivered:false`、`ended:true`),`4` 被拒绝。`spawn` 在 stdout 上只打印 id(说明文字走 stderr),所以 `SID=$(…)` 恰好拿到 id。`--json` 让每个动词打印响应信封里的 `data`。在没有 hook 信号的模式上使用 `--until stop`,会原样透传服务端的 400:那里的答案是标记路径(`--match`),和技能完全一样。
|
||||
|
||||
### MCP 服务器(`codeman mcp`):从任意 MCP 客户端驱动 Codeman
|
||||
|
||||
`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端。MCP 服务器自己不新增任何路由,认证和归属规则不变。
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex(~/.codex/config.toml):为长时间等待调高默认 60 秒的工具超时
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex 只给 MCP 服务器一个最小环境;这两个变量让它知道自己运行在哪个 Codeman 会话里
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
JSON 形式适用于 Claude Desktop、Cursor 和 Gemini CLI。服务器设置了密码时,在 `env` 里加上 `"CODEMAN_PASSWORD"`(或者让它读取数据目录里的 `.env`)。如果 PATH 上没有 `codeman` 命令(没走安装器的 git 检出),改用 `node /path/to/Codeman/dist/index.js mcp`。
|
||||
|
||||
| 工具 | 作用 |
|
||||
| ---- | ---- |
|
||||
| `list_sessions` | 你能看到的每个会话,附带运行模式、状态、文件夹,以及这些工具是否可以给它发提示 |
|
||||
| `start_session` | 在一个 case 里(全新的临时文件夹或已有的 case)用任意已安装的 CLI 起一个新工作会话;等到它能接收提示为止 |
|
||||
| `send_prompt` | 输入一行提示并按 Enter;可选择等到这一回合结束 |
|
||||
| `wait_for_session` | 等待一个信号(`stop`、`blocked`、`idle`、`working`、`exit`)或输出里的一个字面标记 |
|
||||
| `read_output` | 最后一条回答、整段对话,或终端的最后若干字节 |
|
||||
| `interrupt_session` | 一次 Escape:停止当前回合,保留对话 |
|
||||
| `delete_session` | 关闭一个工作会话(它的 case 文件夹会保留) |
|
||||
|
||||
这些规则由服务器强制执行,而不只是写在文档里:
|
||||
|
||||
- **它绝不猜测服务器。** URL 来自 `CODEMAN_API_URL` 或 `--url`;没有配置时它照样启动并列出工具,每次调用都会说明该如何配置。
|
||||
- **只动自己的会话。** 发提示、中断和删除只限于这个服务器自己启动的会话(以及它运行在某个 Codeman 会话里时,该会话自己的工作会话)。列出、读取和等待对所有会话都可用。`codeman mcp --allow-any-session` 会解除这个限制,适合想让助手驱动自己现有标签的用户。它永远不会对自己所在的会话下手。
|
||||
- **等待时长适配客户端。** 一次等待默认 45 秒(Codex 默认在 60 秒时切断 MCP 工具),上限 600 秒;返回 `timed_out` / `still_working` 时再调用一次即可。
|
||||
- **两个协议时代都支持。** 它既能应答 2026-07-28 版本(每个请求带 `_meta`、`server/discover`),也能应答 2024-11-05 到 2025-11-25 的 `initialize` 握手。
|
||||
|
||||
### Hook(事件*回流*到 Codeman)
|
||||
|
||||
Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。
|
||||
@@ -1192,7 +1038,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["外部"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
|
||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||
end
|
||||
end
|
||||
@@ -1259,7 +1105,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、压缩并 gzip 后 5.7 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 276 个测试的完整状态机。输入开销不随提示词长度增长。
|
||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
|
||||
@@ -238,8 +238,8 @@
|
||||
},
|
||||
"install": {
|
||||
"command": {
|
||||
"linux": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)",
|
||||
"darwin": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)"
|
||||
"linux": "npm install -g @deepseek-ai/dsh",
|
||||
"darwin": "npm install -g @deepseek-ai/dsh"
|
||||
},
|
||||
"npmPackage": "@deepseek-ai/dsh",
|
||||
"docsUrl": "https://github.com/deepseek-ai/deepseek-harness",
|
||||
@@ -277,33 +277,5 @@
|
||||
"docsUrl": "https://omp.sh"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "copilot",
|
||||
"label": "GitHub Copilot",
|
||||
"shortBadge": "CP",
|
||||
"enabled": true,
|
||||
"order": 100,
|
||||
"kind": "agent",
|
||||
"discovery": {
|
||||
"binaries": [
|
||||
"copilot"
|
||||
],
|
||||
"searchDirs": [
|
||||
"~/.local/bin",
|
||||
"/usr/local/bin",
|
||||
"~/.bun/bin",
|
||||
"~/.npm-global/bin",
|
||||
"~/bin"
|
||||
],
|
||||
"install": {
|
||||
"command": {
|
||||
"linux": "npm install -g @github/copilot",
|
||||
"darwin": "npm install -g @github/copilot"
|
||||
},
|
||||
"npmPackage": "@github/copilot",
|
||||
"docsUrl": "https://github.com/github/copilot-cli"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.50.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
"source": "github"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.50.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"packageArguments": [
|
||||
{
|
||||
"type": "positional",
|
||||
"value": "mcp"
|
||||
}
|
||||
],
|
||||
"environmentVariables": [
|
||||
{
|
||||
"name": "CODEMAN_API_URL",
|
||||
"description": "Where your Codeman server runs, for example https://127.0.0.1:3000. Never guessed.",
|
||||
"isRequired": true,
|
||||
"format": "string"
|
||||
},
|
||||
{
|
||||
"name": "CODEMAN_PASSWORD",
|
||||
"description": "Codeman's HTTP Basic password, when the server has one.",
|
||||
"isRequired": false,
|
||||
"isSecret": true,
|
||||
"format": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,18 +19,8 @@
|
||||
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/wrapped-file-paths.browser.test.ts',
|
||||
'test/command-palette-agent-arrows.browser.test.ts',
|
||||
'test/tmux-capture-sgr.browser.test.ts',
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/tab-layout-editing.browser.test.ts',
|
||||
'test/tab-rail-search.browser.test.ts',
|
||||
'test/tab-focus.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/header-divider.browser.test.ts',
|
||||
'test/tab-strip-corner.browser.test.ts',
|
||||
'test/header-host-sensors.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
'test/opencode-resize.test.ts',
|
||||
@@ -41,27 +31,8 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/terminal-tile-scroll.browser.test.ts',
|
||||
'test/codex-wheel-routing.browser.test.ts',
|
||||
'test/terminal-touch-paging.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
'test/case-custom-path.browser.test.ts',
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/edit-history.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/spreadsheet-preview.browser.test.ts',
|
||||
'test/mobile-ime-preview.browser.test.ts',
|
||||
'test/run-mode-menu-scroll.browser.test.ts',
|
||||
'test/markdown-anchor-links.browser.test.ts',
|
||||
'test/fullscreen-tab-switch-capture.browser.test.ts',
|
||||
'test/terminal-touch-swipe-axis.browser.test.ts',
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||
'test/local-model-ui.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
# =============================================================================
|
||||
# Codeman Docker Compose environment template
|
||||
#
|
||||
# Usually there is no need to copy this by hand: on its first run,
|
||||
# `bash docker/Start-Codeman.sh` writes docker/.env from this file, asking for
|
||||
# the data folder, port and password and filling in this host's time zone.
|
||||
# Copy it yourself (to docker/.env) only for a hand-built setup, such as a host
|
||||
# where everything runs as root (Unraid) or Compose started without the script.
|
||||
# Copy this file to .env and set the values for the Docker host.
|
||||
# =============================================================================
|
||||
|
||||
TZ=Etc/UTC
|
||||
TZ=Australia/Perth
|
||||
|
||||
# Optional overrides for direct `docker compose` use. The Bash start script
|
||||
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
|
||||
@@ -20,16 +15,9 @@ TZ=Etc/UTC
|
||||
# this value rebuilds the image with a matching account.
|
||||
CODEMAN_RUNTIME_USER=codeman
|
||||
|
||||
# Optional Git identity for commits made by Codeman and Docker-case agents. These values
|
||||
# are written to each image's system Git configuration when it is rebuilt, so
|
||||
# deployments can configure a consistent default. Set both values together.
|
||||
# GIT_USER_NAME=
|
||||
# GIT_USER_EMAIL=
|
||||
|
||||
# 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. The value below is the Unraid layout; the first
|
||||
# run of Start-Codeman.sh suggests ~/codeman-docker instead.
|
||||
# directory in the container.
|
||||
CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman
|
||||
|
||||
# Optional. Absolute host path of this Codeman checkout, mounted at
|
||||
@@ -51,7 +39,6 @@ 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.
|
||||
# Start-Codeman.sh refuses to start while it is still `changeme`.
|
||||
CODEMAN_PASSWORD=changeme
|
||||
|
||||
# Required. Username for Codeman HTTP Basic authentication.
|
||||
|
||||
@@ -4,36 +4,7 @@ This folder contains the Compose configuration, server image Dockerfile, and env
|
||||
|
||||
## Start
|
||||
|
||||
From a fresh clone, on Linux:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
|
||||
bash docker/Start-Codeman.sh
|
||||
```
|
||||
|
||||
The first run checks that Docker, the Compose v2 plugin (2.27.2 or newer) and the daemon are usable, naming the fix when one is not. It then asks three questions, and Enter takes the default for each:
|
||||
|
||||
| Question | Default |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Data folder | `~/codeman-docker`. It becomes the container's home: Codeman's state, CLI logins, and the `codeman-cases` folder for projects. |
|
||||
| Port | 3000, or the next free port when something on the machine already uses 3000. |
|
||||
| Password | A generated 24-character password, shown on your terminal and kept in `docker/.env`. |
|
||||
|
||||
It writes `docker/.env` from `.env.example` (readable only by you, with this host's time zone filled in), builds the image, starts the container, waits until Codeman answers, and prints the URL to open, the address for other devices on your network, and the commands for logs and stopping. The first build takes a few minutes.
|
||||
|
||||
Your account has to be able to use Docker without sudo. If it cannot, the script says so: run `sudo usermod -aG docker $USER`, then log out and back in. The first run refuses to set up as root, because Codeman's data folder must belong to a normal account.
|
||||
|
||||
| Option | Effect |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `--yes`, `-y` | Take every default without asking. This also happens when no terminal is attached. |
|
||||
| `--setup-only` | Write `docker/.env` and stop, so you can review it (or add the optional settings below) before the build. |
|
||||
| `--no-wait` | Start the container without waiting for Codeman to answer. |
|
||||
|
||||
On a first run, `CODEMAN_APPDATA_PATH`, `CODEMAN_PORT` and `CODEMAN_PASSWORD` set in the environment replace the defaults, a different answer typed at the question still wins, and a preset password skips its question. A password the script generates is only ever shown on a terminal: when the output is captured (a log, a pipe), read it from `docker/.env`. Every later run reads `docker/.env` as it is, asks nothing and never edits it, except that Compose prefers a variable exported in your shell over the file. Change a value by editing the file and running the script again. The script refuses to start while `CODEMAN_PASSWORD` is still the example's `changeme`, because the container is reachable from your network and controls Docker on the host.
|
||||
|
||||
### Setting it up by hand
|
||||
|
||||
Hosts where everything runs as root (Unraid), and Compose run without the script, take a hand-written `.env` instead. From the repository root, copy the template and set the required values, especially `CODEMAN_PASSWORD` and a `CODEMAN_APPDATA_PATH` owned by an unprivileged account:
|
||||
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`.
|
||||
|
||||
```sh
|
||||
cp docker/.env.example docker/.env
|
||||
@@ -96,27 +67,6 @@ two volumes are removed, by name within this Compose project; any volume a
|
||||
`docker-compose.override.yml` adds is left alone, and application data and
|
||||
case workspaces are host bind mounts, never touched either way.
|
||||
|
||||
## Git commit identity
|
||||
|
||||
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
|
||||
|
||||
```sh
|
||||
GIT_USER_NAME='Your Name'
|
||||
GIT_USER_EMAIL='you@example.com'
|
||||
```
|
||||
|
||||
Compose passes the values to the Codeman server build, and to the server process
|
||||
when it builds Docker-case agent images. Both images write the pair to Git's
|
||||
system configuration during their build, so commits retain the same identity
|
||||
after a container or agent image is recreated. Set both values together; an
|
||||
image build with only one value fails rather than using a partial identity. An
|
||||
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
|
||||
the server image's system-level default.
|
||||
|
||||
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
|
||||
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
|
||||
server container, then recreate any Docker cases that should use it.
|
||||
|
||||
## Private repositories (GitHub and Azure DevOps)
|
||||
|
||||
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
|
||||
@@ -227,7 +177,7 @@ volumes:
|
||||
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/codeman` (an Unraid layout); the first run of `Start-Codeman.sh` suggests `~/codeman-docker` instead.
|
||||
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/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.
|
||||
|
||||
|
||||
@@ -1,479 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Sets up (on the first run) and starts the Docker Compose deployment.
|
||||
#
|
||||
# A new install is two commands, from a fresh clone:
|
||||
#
|
||||
# git clone https://github.com/Ark0N/Codeman.git && cd Codeman
|
||||
# bash docker/Start-Codeman.sh
|
||||
#
|
||||
# With no docker/.env yet, this asks three questions (data folder, port,
|
||||
# password; Enter takes the default each time), writes docker/.env from
|
||||
# .env.example, builds the image, starts the container, waits until Codeman
|
||||
# answers and prints the URL to open. Every later run (after a `git pull`, or
|
||||
# when the in-app updater asks for it) skips the questions and rebuilds and
|
||||
# restarts the stack.
|
||||
#
|
||||
# Usage: bash docker/Start-Codeman.sh [--yes] [--setup-only] [--no-wait]
|
||||
# --yes, -y First run: take every default without asking. Also what
|
||||
# happens when no terminal is attached.
|
||||
# --setup-only Write docker/.env and stop, so it can be reviewed first.
|
||||
# --no-wait Do not wait for Codeman to answer after starting it.
|
||||
#
|
||||
# A first run takes its defaults from CODEMAN_APPDATA_PATH, CODEMAN_PORT and
|
||||
# CODEMAN_PASSWORD when they are set in the environment.
|
||||
#
|
||||
# Bash 3.2 clean on purpose: Docker Desktop on macOS runs this with
|
||||
# /bin/bash 3.2 (no ${x,,}, mapfile, associative arrays or here-strings).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
env_file="$script_dir/.env"
|
||||
example_file="$script_dir/.env.example"
|
||||
compose_file="$script_dir/docker-compose.yaml"
|
||||
|
||||
assume_yes=0
|
||||
setup_only=0
|
||||
no_wait=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes | -y) assume_yes=1 ;;
|
||||
--setup-only) setup_only=1 ;;
|
||||
--no-wait) no_wait=1 ;;
|
||||
--help | -h)
|
||||
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0"
|
||||
printf ' --yes, -y First run: take every default without asking\n'
|
||||
printf ' --setup-only Write docker/.env and stop, so it can be reviewed first\n'
|
||||
printf ' --no-wait Do not wait for Codeman to answer after starting it\n'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
|
||||
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Preflight ────────────────────────────────────────────────────────────────
|
||||
# The three things a new machine most often lacks, each named with its fix
|
||||
# before anything else runs (a missing daemon used to surface as a bare Compose
|
||||
# error from the first `config` call below).
|
||||
|
||||
# PURE: is dotted version $1 older than $2? An unparseable $1 is never "older":
|
||||
# the `config --environment` failure handler below still catches a real miss.
|
||||
version_older_than() {
|
||||
local re='^([0-9]+)\.([0-9]+)\.([0-9]+)'
|
||||
local a b c x y z
|
||||
[[ "$1" =~ $re ]] || return 1
|
||||
a=$((10#${BASH_REMATCH[1]})) b=$((10#${BASH_REMATCH[2]})) c=$((10#${BASH_REMATCH[3]}))
|
||||
[[ "$2" =~ $re ]] || return 1
|
||||
x=$((10#${BASH_REMATCH[1]})) y=$((10#${BASH_REMATCH[2]})) z=$((10#${BASH_REMATCH[3]}))
|
||||
if ((a != x)); then
|
||||
((a < x))
|
||||
return
|
||||
fi
|
||||
if ((b != y)); then
|
||||
((b < y))
|
||||
return
|
||||
fi
|
||||
((c < z))
|
||||
}
|
||||
|
||||
# `docker compose config --environment`, which everything below reads the
|
||||
# settings through, first shipped in Compose v2.27.2 (docker/compose#11891).
|
||||
min_compose_version='2.27.2'
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
printf 'Error: Docker is not installed (no `docker` command on PATH).\n' >&2
|
||||
printf 'Install Docker Engine (Linux: https://docs.docker.com/engine/install/) or\n' >&2
|
||||
printf 'Docker Desktop (macOS, Windows), then rerun this script.\n' >&2
|
||||
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
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
printf 'Error: the Docker Compose v2 plugin is missing (`docker compose version` failed).\n' >&2
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
printf 'The standalone `docker-compose` found on PATH is not a substitute for it.\n' >&2
|
||||
fi
|
||||
printf 'Install it from https://docs.docker.com/compose/install/linux/\n' >&2
|
||||
printf '(Debian/Ubuntu with Docker'"'"'s apt repository: sudo apt-get install docker-compose-plugin).\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose_version=$(docker compose version --short 2>/dev/null || true)
|
||||
compose_version=${compose_version#v}
|
||||
if version_older_than "$compose_version" "$min_compose_version"; then
|
||||
printf 'Error: Docker Compose %s is too old; Codeman needs %s or newer.\n' \
|
||||
"$compose_version" "$min_compose_version" >&2
|
||||
printf 'Update the Compose plugin (https://docs.docker.com/compose/install/linux/), then rerun.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `docker version`, not `docker info`: on CLI 24 through 27 `info --format` exits
|
||||
# 0 with the daemon unreachable (measured on 27.5.1), so this check never fired.
|
||||
if ! docker_error=$(docker version --format '{{.Server.Version}}' 2>&1 >/dev/null); then
|
||||
case "$docker_error" in
|
||||
*[Pp]ermission\ denied*)
|
||||
account=$(id -un 2>/dev/null || printf 'your account')
|
||||
printf 'Error: %s is not allowed to use Docker yet.\n' "$account" >&2
|
||||
printf 'Add it to the docker group, then log out and back in (or run `newgrp docker`):\n' >&2
|
||||
printf ' sudo usermod -aG docker %s\n' "$account" >&2
|
||||
printf 'Prefer that over running this script with sudo: Codeman'"'"'s data folder has to\n' >&2
|
||||
printf 'belong to a normal account, and a first run refuses to set it up as root.\n' >&2
|
||||
;;
|
||||
*)
|
||||
printf 'Error: the Docker daemon is not reachable. Start it (Linux: sudo systemctl start\n' >&2
|
||||
printf 'docker; macOS and Windows: open Docker Desktop), then rerun this script.\n' >&2
|
||||
printf 'Docker said: %s\n' "$docker_error" >&2
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── First-run setup ──────────────────────────────────────────────────────────
|
||||
# Runs only while docker/.env does not exist, and never edits an existing one.
|
||||
# The file is generated FROM .env.example (its KEY= lines rewritten in place),
|
||||
# so every key the example sets is present: the in-app updater refuses an
|
||||
# update while the user's .env lacks a key the target release's example sets
|
||||
# (diffRequiredEnvKeys, src/web/self-update.ts), and a hand-picked subset would
|
||||
# trip that on the very next release.
|
||||
|
||||
first_run=0
|
||||
generated_password=''
|
||||
|
||||
is_interactive() {
|
||||
[[ "$assume_yes" != '1' && "${CODEMAN_NONINTERACTIVE:-0}" != '1' && -t 0 ]]
|
||||
}
|
||||
|
||||
# Reads one answer into $answer (with -s, without echo). End of input (Ctrl+D)
|
||||
# cancels the setup rather than looping on a default that was just refused.
|
||||
ask() {
|
||||
if ! IFS= read -r "$@" answer; then
|
||||
printf '\nSetup cancelled; nothing was written.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# True when something on this host already accepts connections on the port.
|
||||
# bash's /dev/tcp needs no extra tool on Linux or macOS.
|
||||
port_in_use() {
|
||||
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null || (exec 3<>"/dev/tcp/::1/$1") 2>/dev/null
|
||||
}
|
||||
|
||||
first_free_port() {
|
||||
local port=$1
|
||||
local last=$(($1 + 99))
|
||||
while ((port <= last)); do
|
||||
if ! port_in_use "$port"; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
port=$((port + 1))
|
||||
done
|
||||
printf '%s' "$1"
|
||||
}
|
||||
|
||||
host_timezone() {
|
||||
local tz='' re='^[A-Za-z0-9_+/-]+$'
|
||||
if [[ -r /etc/timezone ]]; then
|
||||
tz=$(head -n1 /etc/timezone 2>/dev/null) || tz=''
|
||||
fi
|
||||
if [[ -z "$tz" ]] && command -v timedatectl >/dev/null 2>&1; then
|
||||
tz=$(timedatectl show -p Timezone --value 2>/dev/null) || tz=''
|
||||
fi
|
||||
if [[ -z "$tz" && -L /etc/localtime ]]; then
|
||||
tz=$(readlink /etc/localtime 2>/dev/null) || tz=''
|
||||
tz=${tz##*zoneinfo/}
|
||||
fi
|
||||
if [[ ! "$tz" =~ $re ]]; then
|
||||
tz='Etc/UTC'
|
||||
fi
|
||||
printf '%s' "$tz"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local pw=''
|
||||
# `|| true`: head closing the pipe early is the normal case, not a failure.
|
||||
pw=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 24) || true
|
||||
if ((${#pw} != 24)) && command -v openssl >/dev/null 2>&1; then
|
||||
pw=$(openssl rand -base64 48 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 24) || true
|
||||
fi
|
||||
if ((${#pw} != 24)); then
|
||||
printf 'Error: could not generate a password; set CODEMAN_PASSWORD and rerun.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$pw"
|
||||
}
|
||||
|
||||
# Compose reads .env values with its own dotenv rules: `$` interpolates and an
|
||||
# unquoted ` #` starts a comment. A single-quoted value is taken literally, so
|
||||
# anything beyond plain path characters is written that way. It cannot hold a
|
||||
# single quote, and a backslash just before the closing quote escapes that quote,
|
||||
# so the whole file fails to parse: the accept_* checks below refuse both.
|
||||
env_quote() {
|
||||
local re='^[A-Za-z0-9._/@:+-]*$'
|
||||
if [[ "$1" =~ $re ]]; then
|
||||
printf '%s' "$1"
|
||||
else
|
||||
printf "'%s'" "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
repo_root=$(cd -- "$script_dir/.." && pwd)
|
||||
|
||||
# Sets setup_appdata, or says why the answer cannot be used and returns 1.
|
||||
accept_appdata_path() {
|
||||
local p=$1
|
||||
case "$p" in
|
||||
'~') p=$HOME ;;
|
||||
'~/'*) p="$HOME/${p#\~/}" ;;
|
||||
esac
|
||||
while [[ "$p" == *//* ]]; do
|
||||
p=${p//\/\//\/}
|
||||
done
|
||||
while [[ "$p" == */ && "$p" != / ]]; do
|
||||
p=${p%/}
|
||||
done
|
||||
case "$p" in
|
||||
*"'"* | *$'\n'*)
|
||||
printf ' The path cannot contain a single quote or a line break.\n' >&2
|
||||
return 1
|
||||
;;
|
||||
*\\)
|
||||
printf ' The path cannot end in a backslash.\n' >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [[ "$p" != /* ]]; then
|
||||
printf ' Use an absolute path, one that starts with /.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
# The refusals below compare strings, so a dot segment could spell any folder
|
||||
# they exist to keep out.
|
||||
case "$p/" in
|
||||
*/./* | */../*)
|
||||
printf ' Spell the path out, without . or .. in it.\n' >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
# The folder becomes the container's home directory, so its `.codeman` is
|
||||
# the server's state directory: $HOME itself would share state.json with a
|
||||
# Codeman installed directly on this machine.
|
||||
if [[ "$p" == / || "$p" == "$HOME" ]]; then
|
||||
printf ' Pick a folder of its own; it becomes the container'"'"'s home directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$p" == "$HOME/.codeman" || "$p" == "$HOME/.codeman/"* ]]; then
|
||||
printf ' %s belongs to a Codeman installed directly on this machine; pick another folder.\n' "$HOME/.codeman" >&2
|
||||
return 1
|
||||
fi
|
||||
# Inside the checkout it would sit in the image build context (COPY . .),
|
||||
# CLI logins and all.
|
||||
if [[ "$p" == "$repo_root" || "$p" == "$repo_root/"* ]]; then
|
||||
printf ' Pick a folder outside %s; that folder is copied into the image when it is built.\n' "$repo_root" >&2
|
||||
return 1
|
||||
fi
|
||||
setup_appdata=$p
|
||||
}
|
||||
|
||||
accept_port() {
|
||||
local re='^[0-9]+$'
|
||||
if [[ ! "$1" =~ $re ]] || ((10#$1 < 1 || 10#$1 > 65535)); then
|
||||
printf ' Use a port number from 1 to 65535.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
setup_port=$((10#$1))
|
||||
}
|
||||
|
||||
accept_password() {
|
||||
case "$1" in
|
||||
*"'"* | *$'\n'*)
|
||||
printf ' The password cannot contain a single quote or a line break.\n' >&2
|
||||
return 1
|
||||
;;
|
||||
*\\)
|
||||
printf ' The password cannot end in a backslash.\n' >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if ((${#1} < 8)); then
|
||||
printf ' Use at least 8 characters.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$1" == 'changeme' ]]; then
|
||||
printf ' That is the published example password; pick another.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
setup_password=$1
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
local tmp="$env_file.tmp.$$" line
|
||||
(
|
||||
umask 077
|
||||
{
|
||||
printf '# Written by Start-Codeman.sh on its first run, from .env.example.\n'
|
||||
printf '# Change any value here, then rerun: bash docker/Start-Codeman.sh\n'
|
||||
printf '\n'
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
TZ=*) printf 'TZ=%s\n' "$(env_quote "$setup_tz")" ;;
|
||||
CODEMAN_APPDATA_PATH=*) printf 'CODEMAN_APPDATA_PATH=%s\n' "$(env_quote "$setup_appdata")" ;;
|
||||
CODEMAN_CASES_PATH=*) printf 'CODEMAN_CASES_PATH=%s\n' "$(env_quote "$setup_cases")" ;;
|
||||
CODEMAN_PORT=*) printf 'CODEMAN_PORT=%s\n' "$setup_port" ;;
|
||||
CODEMAN_PASSWORD=*) printf 'CODEMAN_PASSWORD=%s\n' "$(env_quote "$setup_password")" ;;
|
||||
*) printf '%s\n' "$line" ;;
|
||||
esac
|
||||
done <"$example_file"
|
||||
} >"$tmp"
|
||||
)
|
||||
chmod 600 "$tmp"
|
||||
mv -- "$tmp" "$env_file"
|
||||
}
|
||||
|
||||
run_first_run_setup() {
|
||||
local answer again default_appdata default_port port_note='' password_note username
|
||||
if [[ "$EUID" == '0' ]]; then
|
||||
printf 'Error: %s does not exist yet, and the first-run setup does not run as root.\n' "$env_file" >&2
|
||||
printf 'Run it as the normal account that should own Codeman'"'"'s data (that account\n' >&2
|
||||
printf 'needs to be in the docker group). On a root-only host such as Unraid, copy\n' >&2
|
||||
printf '%s to %s by hand instead,\n' "$example_file" "$env_file" >&2
|
||||
printf 'point CODEMAN_APPDATA_PATH at a folder an unprivileged account owns, set\n' >&2
|
||||
printf 'CODEMAN_PASSWORD, and rerun.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$example_file" ]]; then
|
||||
printf 'Error: %s is missing, so there is nothing to build docker/.env from.\n' "$example_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
default_appdata=${CODEMAN_APPDATA_PATH:-$HOME/codeman-docker}
|
||||
if [[ -n "${CODEMAN_PORT:-}" ]]; then
|
||||
default_port=$CODEMAN_PORT
|
||||
else
|
||||
default_port=$(first_free_port 3000)
|
||||
if [[ "$default_port" != '3000' ]]; then
|
||||
port_note=' (3000 is already taken on this machine)'
|
||||
fi
|
||||
fi
|
||||
setup_tz=$(host_timezone)
|
||||
username=$(sed -n 's/^CODEMAN_USERNAME=//p' "$example_file" | head -n1)
|
||||
|
||||
printf '\nCodeman Docker setup\n'
|
||||
printf 'There is no docker/.env yet, so this first run writes one. Enter takes the [default].\n\n'
|
||||
|
||||
if is_interactive; then
|
||||
while :; do
|
||||
printf ' Data folder (state, CLI logins, projects) [%s]: ' "$default_appdata"
|
||||
ask
|
||||
if [[ -z "$answer" ]]; then
|
||||
answer=$default_appdata
|
||||
fi
|
||||
if accept_appdata_path "$answer"; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
while :; do
|
||||
printf ' Port [%s]%s: ' "$default_port" "$port_note"
|
||||
ask
|
||||
if [[ -z "$answer" ]]; then
|
||||
answer=$default_port
|
||||
fi
|
||||
if accept_port "$answer"; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
|
||||
accept_password "$CODEMAN_PASSWORD" || exit 1
|
||||
printf ' Password: taken from CODEMAN_PASSWORD\n'
|
||||
else
|
||||
while :; do
|
||||
printf ' Password [Enter generates a strong one]: '
|
||||
ask -s
|
||||
printf '\n'
|
||||
if [[ -z "$answer" ]]; then
|
||||
setup_password=$(generate_password)
|
||||
generated_password=$setup_password
|
||||
break
|
||||
fi
|
||||
if ! accept_password "$answer"; then
|
||||
continue
|
||||
fi
|
||||
printf ' Repeat the password: '
|
||||
again=$answer
|
||||
ask -s
|
||||
printf '\n'
|
||||
if [[ "$again" == "$answer" ]]; then
|
||||
break
|
||||
fi
|
||||
printf ' The two entries differ; try again.\n' >&2
|
||||
done
|
||||
fi
|
||||
else
|
||||
printf ' No questions asked (no terminal attached, or --yes): taking the defaults.\n'
|
||||
accept_appdata_path "$default_appdata" || exit 1
|
||||
accept_port "$default_port" || exit 1
|
||||
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
|
||||
accept_password "$CODEMAN_PASSWORD" || exit 1
|
||||
else
|
||||
setup_password=$(generate_password)
|
||||
generated_password=$setup_password
|
||||
fi
|
||||
fi
|
||||
|
||||
if port_in_use "$setup_port"; then
|
||||
printf ' Note: something on this machine already listens on port %s, so starting will\n' "$setup_port" >&2
|
||||
printf ' fail until it stops or CODEMAN_PORT in docker/.env names a free port.\n' >&2
|
||||
fi
|
||||
|
||||
setup_cases="$setup_appdata/codeman-cases"
|
||||
write_env_file
|
||||
first_run=1
|
||||
# Compose prefers the shell environment over the env file, so a preset still
|
||||
# exported here would beat a different typed answer: docker/.env and the
|
||||
# summary would name one folder and port and the stack would run on another.
|
||||
unset TZ CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH CODEMAN_PORT CODEMAN_PASSWORD
|
||||
|
||||
# A generated password goes to a terminal only: a captured log would be a
|
||||
# weaker copy of a credential that controls Docker on this host.
|
||||
if [[ -z "$generated_password" ]]; then
|
||||
password_note='the one you chose'
|
||||
elif [[ -t 1 ]]; then
|
||||
password_note="$generated_password (generated; it stays in docker/.env)"
|
||||
else
|
||||
password_note='generated; read it from docker/.env'
|
||||
fi
|
||||
printf '\nWrote %s (readable only by you):\n' "$env_file"
|
||||
printf ' Data folder %s\n' "$setup_appdata"
|
||||
printf ' Projects %s\n' "$setup_cases"
|
||||
printf ' Port %s\n' "$setup_port"
|
||||
printf ' Time zone %s\n' "$setup_tz"
|
||||
printf ' Username %s\n' "${username:-admin}"
|
||||
printf ' Password %s\n' "$password_note"
|
||||
printf 'Everything else in it is optional (Git identity, private repositories, reverse\n'
|
||||
printf 'proxy); docker/README.md explains each setting.\n\n'
|
||||
}
|
||||
|
||||
if [[ ! -e "$env_file" && ! -L "$env_file" ]]; then
|
||||
run_first_run_setup
|
||||
elif [[ ! -f "$env_file" ]]; then
|
||||
# A dangling symlink or a directory is not "no .env yet": setup would replace
|
||||
# the link, or leave its password file inside the directory and run again on
|
||||
# every start.
|
||||
printf 'Error: %s exists but is not a regular file.\n' "$env_file" >&2
|
||||
printf 'Move it out of the way or repoint it, then rerun this script.\n' >&2
|
||||
exit 1
|
||||
elif [[ "$setup_only" == '1' ]]; then
|
||||
printf '%s already exists; the setup only runs when it does not, and never edits it.\n' "$env_file"
|
||||
fi
|
||||
|
||||
if [[ "$setup_only" == '1' ]]; then
|
||||
printf 'Start Codeman with: bash %s\n' "$script_dir/Start-Codeman.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Naming a Compose file explicitly disables Compose's automatic discovery of
|
||||
# the override file, so it has to be added back by hand. Without this, local
|
||||
# customisation in docker-compose.override.yml is silently ignored. The
|
||||
@@ -494,54 +32,18 @@ for override_file in "$override_yml" "$override_yaml"; do
|
||||
fi
|
||||
done
|
||||
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
|
||||
|
||||
# Every setting is read through Compose's own resolution (shell environment over
|
||||
# .env, quoting, interpolation), once. A failure is reported here with what
|
||||
# Compose said, instead of `set -e` ending the script at an empty value.
|
||||
compose_stderr=$(mktemp "${TMPDIR:-/tmp}/codeman-compose.XXXXXX")
|
||||
if ! compose_environment=$("${compose_command[@]}" config --environment 2>"$compose_stderr"); then
|
||||
if grep -q -- 'unknown flag: --environment' "$compose_stderr"; then
|
||||
printf 'Error: this Docker Compose (%s) is too old; Codeman needs %s or newer.\n' \
|
||||
"${compose_version:-unknown version}" "$min_compose_version" >&2
|
||||
else
|
||||
cat -- "$compose_stderr" >&2
|
||||
printf 'Error: `docker compose config` could not read %s and the Compose files (see above).\n' "$env_file" >&2
|
||||
fi
|
||||
rm -f -- "$compose_stderr"
|
||||
exit 1
|
||||
fi
|
||||
# Compose's own warnings (an unset variable, say) still reach the terminal, once.
|
||||
cat -- "$compose_stderr" >&2
|
||||
rm -f -- "$compose_stderr"
|
||||
|
||||
# No early `exit` in the awk program: it reads all of its input, so printf never
|
||||
# meets a closed pipe (a SIGPIPE would end the script under pipefail).
|
||||
compose_env_value() {
|
||||
printf '%s\n' "$compose_environment" |
|
||||
awk -F= -v key="$1" '$1 == key && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
|
||||
}
|
||||
appdata_path=$(compose_env_value CODEMAN_APPDATA_PATH)
|
||||
cases_path=$(compose_env_value CODEMAN_CASES_PATH)
|
||||
docker_socket=$(compose_env_value DOCKER_SOCKET)
|
||||
codeman_port=$(compose_env_value CODEMAN_PORT)
|
||||
codeman_username=$(compose_env_value CODEMAN_USERNAME)
|
||||
codeman_password=$(compose_env_value CODEMAN_PASSWORD)
|
||||
|
||||
# The container publishes its port on every interface and holds the host's
|
||||
# Docker socket, so whoever signs in to Codeman can run anything on this host.
|
||||
# The example's placeholder is a published password: refuse it outright.
|
||||
if [[ "$codeman_password" == 'changeme' ]]; then
|
||||
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
|
||||
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
|
||||
printf 'set a real password in %s, then rerun this script.\n' "$env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$codeman_password" ]]; then
|
||||
printf 'Warning: CODEMAN_PASSWORD is empty, so anyone who can reach port %s can use Codeman,\n' "${codeman_port:-?}" >&2
|
||||
printf 'which controls Docker on this machine. Set one in %s unless something in front of it\n' "$env_file" >&2
|
||||
printf 'already asks for a login.\n' >&2
|
||||
fi
|
||||
unset codeman_password
|
||||
appdata_path=$(
|
||||
"${compose_command[@]}" config --environment |
|
||||
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
||||
)
|
||||
cases_path=$(
|
||||
"${compose_command[@]}" config --environment |
|
||||
awk -F= '$1 == "CODEMAN_CASES_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
|
||||
@@ -554,11 +56,7 @@ if [[ ! -d "$appdata_path" ]]; then
|
||||
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! mkdir -p -- "$appdata_path"; then
|
||||
printf 'Error: cannot create CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
|
||||
printf 'Create it as the account that should run Codeman, or pick another folder in %s.\n' "$env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p -- "$appdata_path"
|
||||
fi
|
||||
|
||||
if [[ -z "$cases_path" ]]; then
|
||||
@@ -616,7 +114,6 @@ fi
|
||||
|
||||
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
|
||||
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
|
||||
printf 'Set DOCKER_SOCKET in %s to the socket your Docker daemon listens on.\n' "$env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -712,148 +209,6 @@ else
|
||||
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
||||
fi
|
||||
|
||||
# ── Start, then wait until Codeman answers ──────────────────────────────────
|
||||
# `up -d` returns as soon as the container exists, which says nothing about the
|
||||
# server inside it. The wait asks the server itself through `docker exec` (the
|
||||
# same request as the compose healthcheck, which first runs only after its 30 s
|
||||
# interval), notices a crash loop by the restart count moving, and ends on the
|
||||
# URL to open. Docker's own view works with any network setup, macvlan included.
|
||||
|
||||
logs_hint="cd $(printf '%q' "$script_dir") && docker compose logs -f codeman"
|
||||
|
||||
# Docker's own output above says what broke; this adds the next step. A first
|
||||
# build fetches hundreds of packages, so a network hiccup is the usual cause of
|
||||
# a failed build, and a rerun picks up from Docker's layer cache.
|
||||
compose_failed() {
|
||||
printf '\nError: `docker compose %s` failed (see the output above).\n' "$1" >&2
|
||||
case "$1" in
|
||||
build)
|
||||
printf 'A network hiccup while building is the usual cause; rerunning this script\n' >&2
|
||||
printf 'resumes from Docker'"'"'s cache.\n' >&2
|
||||
;;
|
||||
*--build*)
|
||||
printf 'If it stopped while building the image, a network hiccup is the usual cause,\n' >&2
|
||||
printf 'and rerunning this script resumes from Docker'"'"'s cache.\n' >&2
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
}
|
||||
|
||||
lan_ip() {
|
||||
local ip=''
|
||||
if [[ "$(uname -s)" == 'Darwin' ]]; then
|
||||
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) || ip=''
|
||||
else
|
||||
# The address the default route leaves from: the first one `hostname -I`
|
||||
# lists is often docker0 or a VPN interface.
|
||||
ip=$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p') || ip=''
|
||||
ip=${ip%%$'\n'*}
|
||||
if [[ -z "$ip" ]]; then
|
||||
ip=$(hostname -I 2>/dev/null) || ip=''
|
||||
ip=${ip%% *}
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$ip"
|
||||
}
|
||||
|
||||
print_access_summary() {
|
||||
local cid=$1 published host_port bind_host lan addresses dir_q
|
||||
dir_q=$(printf '%q' "$script_dir")
|
||||
published=$("${compose_command[@]}" port codeman "$codeman_port" 2>/dev/null) || published=''
|
||||
published=${published%%$'\n'*}
|
||||
printf '\n'
|
||||
if [[ -n "$published" ]]; then
|
||||
host_port=${published##*:}
|
||||
bind_host=${published%:*}
|
||||
printf ' Open http://localhost:%s\n' "$host_port"
|
||||
case "$bind_host" in
|
||||
127.0.0.1 | '[::1]' | ::1) ;;
|
||||
*)
|
||||
lan=$(lan_ip)
|
||||
if [[ -n "$lan" ]]; then
|
||||
printf ' http://%s:%s (from other devices on your network)\n' "$lan" "$host_port"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
else
|
||||
addresses=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$cid" 2>/dev/null) || addresses=''
|
||||
printf ' Open http://<container address>:%s (no host port is published; container addresses: %s)\n' \
|
||||
"$codeman_port" "${addresses:-unknown}"
|
||||
fi
|
||||
printf ' Sign in %s, with the CODEMAN_PASSWORD from %s\n' "${codeman_username:-admin}" "$env_file"
|
||||
if [[ -n "$generated_password" && -t 1 ]]; then
|
||||
printf ' Password %s (generated on this first run)\n' "$generated_password"
|
||||
fi
|
||||
if [[ "$first_run" == '1' ]]; then
|
||||
printf '\n Next: start a session from the dashboard and log its CLI in once. Logins are\n'
|
||||
printf ' kept in %s, so they survive rebuilds.\n' "$appdata_path"
|
||||
fi
|
||||
printf '\n'
|
||||
printf ' Logs cd %s && docker compose logs -f codeman\n' "$dir_q"
|
||||
printf ' Stop cd %s && docker compose down\n' "$dir_q"
|
||||
printf ' Update App Settings > Updates in the dashboard, or git pull and rerun this script\n'
|
||||
}
|
||||
|
||||
report_when_ready() {
|
||||
local cid state status health restarts first_restarts='' waited=0 limit=180 probe dots=0
|
||||
cid=$("${compose_command[@]}" ps -q codeman 2>/dev/null) || cid=''
|
||||
cid=${cid%%$'\n'*}
|
||||
if [[ -z "$cid" ]]; then
|
||||
printf 'Error: Compose started no codeman container. Look at: %s\n' "$logs_hint" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$no_wait" == '1' ]]; then
|
||||
printf '\nCodeman is starting (--no-wait given, so not waiting for it).\n'
|
||||
print_access_summary "$cid"
|
||||
return 0
|
||||
fi
|
||||
if [[ -t 1 ]]; then
|
||||
dots=1
|
||||
fi
|
||||
probe="fetch('http://127.0.0.1:${codeman_port}/api/status').then((r) => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
|
||||
printf 'Waiting for Codeman to answer...'
|
||||
while :; do
|
||||
state=$(docker inspect --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{.RestartCount}}' "$cid" 2>/dev/null) || state='missing||0'
|
||||
status=${state%%|*}
|
||||
health=${state#*|}
|
||||
health=${health%%|*}
|
||||
restarts=${state##*|}
|
||||
if [[ -z "$first_restarts" ]]; then
|
||||
first_restarts=$restarts
|
||||
fi
|
||||
if [[ "$health" == 'healthy' ]] ||
|
||||
{ [[ "$status" == 'running' ]] && docker exec "$cid" node -e "$probe" >/dev/null 2>&1; }; then
|
||||
printf ' ready.\n'
|
||||
print_access_summary "$cid"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$status" == 'exited' || "$status" == 'dead' || "$status" == 'missing' || "$status" == 'restarting' ||
|
||||
"$health" == 'unhealthy' || "$restarts" != "$first_restarts" ]]; then
|
||||
printf '\n'
|
||||
printf 'Error: Codeman did not come up (container %s). Its last log lines:\n\n' "${status:-unknown}" >&2
|
||||
"${compose_command[@]}" logs --tail 40 codeman >&2 || true
|
||||
printf '\nFollow the full log with: %s\n' "$logs_hint" >&2
|
||||
return 1
|
||||
fi
|
||||
if ((waited >= limit)); then
|
||||
printf '\n'
|
||||
printf 'Warning: Codeman has not answered after %s seconds. It may still be starting;\n' "$limit" >&2
|
||||
printf 'follow it with: %s\n' "$logs_hint" >&2
|
||||
return 1
|
||||
fi
|
||||
if ((dots)); then
|
||||
printf '.'
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "$first_run" == '1' ]]; then
|
||||
printf 'Building the image. The first build downloads and compiles everything and takes a\n'
|
||||
printf 'few minutes; later starts reuse most of it.\n'
|
||||
fi
|
||||
|
||||
# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from
|
||||
# the image only while EMPTY, so a rebuilt image's fresh output sits unused
|
||||
# behind old volume content until something clears it. The in-app self-updater
|
||||
@@ -880,9 +235,7 @@ if [[ -n "$dockerfile_sha" ]]; then
|
||||
fi
|
||||
|
||||
if [[ ${#volumes_to_refresh[@]} -eq 0 ]]; then
|
||||
"${compose_command[@]}" up --build -d || compose_failed 'up --build'
|
||||
report_when_ready || exit 1
|
||||
exit 0
|
||||
exec "${compose_command[@]}" up --build -d
|
||||
fi
|
||||
|
||||
# Runs even on this script's very first invocation against an EXISTING
|
||||
@@ -894,7 +247,7 @@ printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_ref
|
||||
|
||||
# Build BEFORE taking the stack down: the image build is the slow part and needs
|
||||
# no container stopped, so the deployment is offline only for the recreate.
|
||||
"${compose_command[@]}" build || compose_failed build
|
||||
"${compose_command[@]}" build
|
||||
|
||||
# `com.docker.compose.volume` is the volume KEY, not a project-qualified name -
|
||||
# a second stack on the same host (a beta instance started with a different
|
||||
@@ -953,5 +306,4 @@ fi
|
||||
|
||||
# Already built above, so no --build here: a second build would only re-check
|
||||
# the cache.
|
||||
"${compose_command[@]}" up -d || compose_failed up
|
||||
report_when_ready || exit 1
|
||||
exec "${compose_command[@]}" up -d
|
||||
@@ -166,11 +166,10 @@ fi
|
||||
# appdata directory (which creates it): this script updates an EXISTING
|
||||
# deployment, so a missing appdata path means there is nothing here yet to
|
||||
# update, and creating one would just be this script quietly doing
|
||||
# Start-Codeman.sh's first-run job worse. No early `exit` in awk: a closed pipe
|
||||
# would end this script through pipefail, with no message.
|
||||
# Start-Codeman.sh's first-run job worse.
|
||||
appdata_path=$(
|
||||
"${compose_command[@]}" config --environment |
|
||||
awk -F= '$1 == "CODEMAN_APPDATA_PATH" && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
|
||||
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
||||
)
|
||||
if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
|
||||
printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-<unset>}" >&2
|
||||
@@ -178,22 +177,6 @@ if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start-Codeman.sh refuses to start while the password is still the example's
|
||||
# `changeme`. Checked here too, BEFORE the build and the `down` below: found
|
||||
# only at the handoff, that refusal would leave the stack this script just
|
||||
# stopped down. No early `exit` in awk, so printf never meets a closed pipe.
|
||||
codeman_password=$(
|
||||
"${compose_command[@]}" config --environment |
|
||||
awk -F= '$1 == "CODEMAN_PASSWORD" && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
|
||||
)
|
||||
if [[ "$codeman_password" == 'changeme' ]]; then
|
||||
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
|
||||
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
|
||||
printf 'set a real password in %s, then rerun this script. Nothing was stopped.\n' "$env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
unset codeman_password
|
||||
|
||||
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
|
||||
# host, so both need to work. Identical to Start-Codeman.sh's own helper.
|
||||
owner_of() {
|
||||
|
||||
@@ -17,7 +17,6 @@ FROM node:22-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
tmux \
|
||||
ripgrep \
|
||||
curl \
|
||||
@@ -126,11 +125,7 @@ RUN set -eux; \
|
||||
# ⚠️ The default is in REGISTRY order, byte-identical to what the generator emits.
|
||||
# A different order is a different RUN string, which is a different layer hash and
|
||||
# so a needless cache miss between a bare `docker build` and a scripted one.
|
||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli @github/copilot"
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
||||
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
||||
&& npm cache clean --force
|
||||
|
||||
@@ -254,21 +249,6 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
# Docker cases have a fresh, container-owned home directory. Declare the
|
||||
# optional identity here so changing it invalidates only this final layer, then
|
||||
# configure Git's system defaults. A user-level config still takes precedence.
|
||||
ARG GIT_USER_EMAIL=
|
||||
ARG GIT_USER_NAME=
|
||||
RUN set -eux; \
|
||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
git config --system user.name "${GIT_USER_NAME}"; \
|
||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||
fi
|
||||
|
||||
USER agent
|
||||
WORKDIR /home/agent
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ services:
|
||||
dockerfile: docker/server.Dockerfile
|
||||
args:
|
||||
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
|
||||
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||
GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||
PGID: ${PGID:-1000}
|
||||
PUID: ${PUID:-1000}
|
||||
image: ${CODEMAN_IMAGE}
|
||||
@@ -34,10 +32,6 @@ services:
|
||||
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
||||
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
||||
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
||||
# Passed through only so Codeman can use the same identity when it builds
|
||||
# the Docker-case agent image.
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
||||
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
||||
# defaults to empty rather than requiring a line in every .env.
|
||||
|
||||
@@ -39,7 +39,6 @@ RUN apt-get update \
|
||||
curl \
|
||||
g++ \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
make \
|
||||
openssh-client \
|
||||
procps \
|
||||
@@ -213,28 +212,13 @@ RUN set -eux; \
|
||||
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
||||
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
||||
# system directories for the root part of the start.
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
||||
ENV PATH=$PATH:/opt/codeman-cli/bin
|
||||
# CLIs installed at runtime (Settings -> CLIs, npm redirected to ~/.local by installEnv()) live on the
|
||||
# persistent home mount, so they survive a container recreate. Appended for the same reason as above.
|
||||
ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin
|
||||
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
|
||||
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
|
||||
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
|
||||
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
|
||||
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
|
||||
# already carries it for the same reason (#352). It lives in the same
|
||||
# runtime-writable prefix as the CLIs, so a session can update it in place.
|
||||
RUN npm install --global \
|
||||
@anthropic-ai/claude-code@2.1.258 \
|
||||
@google/gemini-cli@0.58.0 \
|
||||
@openai/codex@0.152.1 \
|
||||
opencode-ai@1.18.26 \
|
||||
pnpm@12.6.0 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||
@@ -302,21 +286,6 @@ EXPOSE 3000
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Declare the optional identity immediately before configuring it so a change
|
||||
# invalidates only this final layer. This is declarative setup: a persisted
|
||||
# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values.
|
||||
ARG GIT_USER_EMAIL=
|
||||
ARG GIT_USER_NAME=
|
||||
RUN set -eux; \
|
||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
git config --system user.name "${GIT_USER_NAME}"; \
|
||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||
fi
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
CMD ["node", "dist/index.js", "web"]
|
||||
@@ -8,17 +8,6 @@ see [§7 Build log](#7-build-log-what-actually-happened) for what shipped, what
|
||||
verification round found, and the two items that genuinely remain open (§2.4's footgun
|
||||
guard and the Part 3 deferrals).
|
||||
|
||||
**Update 2026-11-08 (1.50.0)**: `agentSkillEnabled` is now **on by default** (owner decision:
|
||||
"enable the codeman skill by default"), the flip §2.2 planned for after dogfooding. An absent
|
||||
key means on in both readers (server `getAgentSkillEnabled()` and the App Settings switch, both
|
||||
`!== false`); an explicit false is off. Existing installs that stored off are switched on once at
|
||||
the first 1.50.0 start (every App Settings Save up to 1.49.0 wrote `false`); turning it off
|
||||
afterwards sticks (`src/web/agent-skill-default-migration.ts`, a "Behaviour change" in the
|
||||
release notes). To keep users' repositories clean, every managed copy
|
||||
now carries a `.gitignore` of `*` (written once with `wx`, removed by uninstall only when it is
|
||||
the last file left). Turning the setting off still sweeps nothing. The sections below record the
|
||||
original default-off decision as it was made.
|
||||
|
||||
**Date**: 2026-08-08
|
||||
**Scope**: Part 1 (agent skill) and Part 2 (wait primitives) were specified and built.
|
||||
Parts 3 to 5 are captured so they are not lost, but remain deliberately deferred.
|
||||
@@ -130,7 +119,7 @@ Install paths, in order of how a user gets it:
|
||||
|
||||
Setting name: `agentSkillEnabled`. Synced (not per-device), since it changes on-disk case
|
||||
content rather than display. Default: **ON after the dogfooding phase, OFF in the first
|
||||
release** (flipped to ON in 1.50.0, see the Status update at the top). Rationale for starting OFF: Claude Code loads every skill's name and description
|
||||
release**. Rationale for starting OFF: Claude Code loads every skill's name and description
|
||||
into context on every turn, so an always-on skill has a small permanent token cost, and we
|
||||
should measure that we are buying something with it first.
|
||||
|
||||
@@ -554,7 +543,6 @@ without the wait endpoints, so the wait work goes first.
|
||||
1. ✅ `skills/` at the repo root: accepted (built that way; the install one-liner depends on it).
|
||||
2. ✅ `agentSkillEnabled` default: **OFF** for the first release, per §2.2's rationale (skills
|
||||
cost context on every turn; measure before defaulting on). Flip later if dogfooding earns it.
|
||||
**Flipped to ON in 1.50.0** (2026-11-08), with the copy made self-ignoring in git.
|
||||
3. ✅ Both: global install via `npx skills add` / `codeman skill install`, AND per-case
|
||||
auto-injection behind the (default-off) setting. Injection is add-only at session create and
|
||||
marker-guarded, so a user-authored copy is never touched.
|
||||
@@ -693,9 +681,9 @@ Built to the §2.6 file list, mirroring the statusLine mechanism throughout:
|
||||
| ----- | ----- |
|
||||
| `applyAgentSkill(casePath, enabled)` + `installAgentSkillInto` / `removeAgentSkillFrom` | `src/hooks-config.ts` |
|
||||
| `codeman skill install` / `skill uninstall` (`--global` default, `--case <name>`) | `src/cli.ts` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF; ON since 1.50.0) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js`; the one-time 1.50.0 boot migration of a stored false in `src/web/agent-skill-default-migration.ts` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js` |
|
||||
| Injection call sites (Claude mode only) | `POST /api/sessions` next to `refreshStaleCodemanHooks`; `POST /api/quick-start` after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) |
|
||||
| Tests | `test/agent-skill.test.ts` (unit, incl. the self-ignoring `.gitignore` against real git since 1.50.0), `test/quick-start.test.ts` (real server: default-off at first, default-on since 1.50.0 incl. a partial PUT and an explicit false, injection on create, shell-mode skipped), `test/agent-skill-setting-ui.test.ts` (the App Settings switch reads an absent key as on, and a device blob holding the old false follows the server), `test/agent-skill-default-migration.test.ts` (the boot migration: once per data dir, marker outside settings.json) |
|
||||
| Tests | `test/agent-skill.test.ts` (10 unit), `test/quick-start.test.ts` (real server: default-off, PUT accepts key, injection on create, shell-mode skipped) |
|
||||
|
||||
Decisions worth keeping:
|
||||
|
||||
@@ -773,12 +761,9 @@ workers. Tests: `test/name-flag-injection.test.ts`.
|
||||
Later narrowing: `--name` is not only the peer name but also the `/resume` picker
|
||||
entry and the terminal title, and a pinned title stops Claude generating its own, so
|
||||
pinning the `w1-myapp` placeholder listed every conversation of a case under the same
|
||||
name in `/resume`. Only a manual name is pinned by default (`Session.cliPinnedName`,
|
||||
name in `/resume`. Only a manual name is pinned now (`Session.cliPinnedName`,
|
||||
`nameSource === 'manual'`, carried to the builders as `cliName`); placeholder and auto
|
||||
names leave Claude to title the conversation. The opt-in synced `pinCliSessionName`
|
||||
(default OFF, re-read at every spawn and respawn) pins every tab name instead, at the
|
||||
cost of Claude's generated titles; a rename then holds only until the next turn, because
|
||||
a pane spawned with `--name` re-appends its in-memory title. A rename in Codeman appends a
|
||||
names leave Claude to title the conversation. A rename in Codeman appends a
|
||||
`custom-title` row to the conversation's transcript (`claude-session-title.ts`), the
|
||||
row `/rename` writes. Tests: `test/claude-resume-title.test.ts`,
|
||||
`test/routes/session-name-routes.test.ts`.
|
||||
@@ -11,7 +11,7 @@ One cross-session inbox for every prompt that is waiting on a human: permission
|
||||
|
||||
## Scope
|
||||
|
||||
- Claude mode only (hooks fire only for `claude`; external CLIs keep their output-stabilization heuristics and get no inbox items). This mirrors the wait-primitive `stop`/`blocked` gating. Since then, DeepSeek Harness (through its status bridge) and Codex (through dialogs read off its screen) open alert-only items; answerable items are still Claude's alone (see `src/web/approval-inbox.ts`).
|
||||
- Claude mode only (hooks fire only for `claude`; external CLIs keep their output-stabilization heuristics and get no inbox items). This mirrors the wait-primitive `stop`/`blocked` gating.
|
||||
- Permission prompts occur for sessions running `ClaudeMode` `normal` / `auto` / `allowedTools` (and the trust-folder dialog even under skip-permissions). Question and idle prompts occur in every mode including `dangerously-skip-permissions`.
|
||||
- In-memory store (plus the frontend seeding from it on load). Server restart drops items; hooks re-fire on the next prompt. No new state file in v1.
|
||||
|
||||
|
||||
@@ -71,23 +71,17 @@ We tested three browser automation frameworks against the Codeman web UI:
|
||||
|
||||
## Test File Structure
|
||||
|
||||
### Ports
|
||||
### Port Allocation
|
||||
|
||||
A test that starts a server binds an ephemeral port, never a fixed one:
|
||||
|
||||
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
|
||||
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
|
||||
any `WebServer` built under `test/` on a non-zero port.
|
||||
- A raw `http`, `net`, Fastify or `ws` server: `listen({ port: 0 })`, then
|
||||
`address().port`. The guard also fails a raw listen on a number or a `…PORT` constant.
|
||||
- The mobile suite (`test/mobile/**`) gets its server from `createTestServer()`, which
|
||||
binds an ephemeral port too; read it from `server.boundPort`.
|
||||
- The one exception is `test/codex-predictive-echo.test.ts`, which starts a separate lab
|
||||
server process on port 3222 that the guard cannot see.
|
||||
- Never port 3000: that is the live instance.
|
||||
|
||||
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
|
||||
fixed ports 3180-3182.
|
||||
| Port Range | Test File |
|
||||
|------------|-----------|
|
||||
| 3150-3153 | browser-e2e.test.ts (existing) |
|
||||
| 3154 | file-link-click.test.ts |
|
||||
| 3155 | browser-playwright.test.ts |
|
||||
| 3156 | browser-puppeteer.test.ts |
|
||||
| 3157 | browser-agent.test.ts |
|
||||
| 3158-3160 | browser-comparison.test.ts |
|
||||
| 3180-3182 | scripts/browser-comparison.mjs |
|
||||
|
||||
### File Purposes
|
||||
|
||||
@@ -112,7 +106,7 @@ const browser = await chromium.launch({
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto(BASE_URL);
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Auto-waiting selectors
|
||||
await page.click('.btn-claude');
|
||||
@@ -146,7 +140,7 @@ const browser = await puppeteer.launch({
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto(BASE_URL);
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Manual waiting often needed
|
||||
await page.click('.btn-claude');
|
||||
@@ -192,7 +186,7 @@ function agentBrowserJson<T>(cmd: string): T {
|
||||
}
|
||||
|
||||
// Usage
|
||||
agentBrowser(`open ${BASE_URL}`);
|
||||
agentBrowser('open http://localhost:3000');
|
||||
agentBrowser('click ".btn-claude"');
|
||||
const title = agentBrowserJson<{title: string}>('get title');
|
||||
|
||||
@@ -252,14 +246,11 @@ npx playwright install chromium
|
||||
### 4. Wait for Server Startup
|
||||
|
||||
```typescript
|
||||
const server = new WebServer(0, false, true); // port 0 (the OS picks one), no TLS, testMode
|
||||
const server = new WebServer(PORT);
|
||||
await server.start();
|
||||
const BASE_URL = `http://localhost:${server.boundPort}`;
|
||||
await new Promise(r => setTimeout(r, 1000)); // Allow server to stabilize
|
||||
```
|
||||
|
||||
`boundPort` holds the real port only once `start()` has resolved. The `BASE_URL` used by the
|
||||
other snippets on this page is this one.
|
||||
|
||||
### 5. Clean Up Sessions
|
||||
|
||||
Track created sessions for cleanup:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# The CLI registry
|
||||
|
||||
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP and GitHub Copilot CLI — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
|
||||
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
|
||||
|
||||
## Where it lives
|
||||
|
||||
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
|
||||
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
|
||||
|
||||
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
|
||||
- installs a missing **stock** CLI by running its shipped install command (the entry as merged with `clis.json`, so an override of a stock entry's install command is what runs here), after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
|
||||
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
|
||||
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
|
||||
|
||||
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
|
||||
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
||||
interface CliEntry {
|
||||
id: CliId; // 'codex'
|
||||
label: string; // 'Codex' — shown in menus
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
|
||||
shortBadge: string; // tab badge, e.g. 'CX'
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
@@ -46,22 +46,8 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?,
|
||||
// blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?,
|
||||
// composerCommandPrefixes?, composerCompletionSigils? }
|
||||
// (how this CLI's pane shows work, work it started in the background, a turn
|
||||
// that ended waiting for workers it will resume from, a dialog it is blocked on,
|
||||
// the rows around its idle composer that guarded input relies on, and what a
|
||||
// draft can trigger)
|
||||
// .modelDetect?: { screenLine, screenLines? }
|
||||
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
|
||||
// .launchDefaults?: { [launchParam]: settingsKey }
|
||||
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
|
||||
// codex's model, reasoning effort and network access, via src/web/launch-defaults.ts)
|
||||
// .gates: { [name]: { minVersion, failClosed } }
|
||||
// (version gates a `capabilityGate` launch condition names: claude's `--name`,
|
||||
// codex's `--no-daemon`. Measured against THAT CLI's own version, never claude's:
|
||||
// resolveSessionCliVersion, so an unknown version passes no fail-closed gate)
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
|
||||
// this CLI's pane shows work, and how it shows work it started in the background
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
@@ -70,45 +56,25 @@ interface CliEntry {
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 8, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), codex's ` <model> <effort> · ` footer (its last row, or the row above codex 0.162's indented hint row, so a two-row window), and opencode's composer agent row (`┃ Build <model> <provider>`, directly above the box's `╹` edge, eight rows because its home screen puts up to five rows of its own chrome below it). opencode's field is the model AND the provider, since only colour separates them on that row; the pattern takes the LAST such row in the window, so a composer-shaped row the agent prints higher up cannot stand in for it. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
|
||||
|
||||
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
|
||||
|
||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
||||
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
|
||||
to comment on a page the agent published, so Claude's pattern refuses any footer that
|
||||
carries it, and the idle alert goes out as usual.
|
||||
|
||||
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||
the `·` its footer joins items with. Codex pins
|
||||
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
||||
puts the row third from the bottom once the status line and the composer are counted, and
|
||||
fourth on codex 0.162+ at rest, where a `← for agents · ? for shortcuts` hint row sits under
|
||||
the status line (it disappears while a prompt is typed). So its entry declares
|
||||
`watchingLines: 4` and matches that row end to end. Both were measured against live panes
|
||||
rather than read out of a binary, which is the standard for adding a third.
|
||||
|
||||
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
|
||||
to wait for workers the CLI will resume from by itself. When background agents or an
|
||||
ultracode workflow are still running at turn end, Claude closes the turn with
|
||||
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
|
||||
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
|
||||
the words are still on screen after the workers report back and the follow-up turn ends.
|
||||
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
|
||||
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
|
||||
tests only the first row that starts in column 0, which is the newest transcript row. Claude
|
||||
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
|
||||
agent from holding its own session busy.
|
||||
puts the row third from the bottom once the status line and the composer are counted, so its
|
||||
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
|
||||
against live panes rather than read out of a binary, which is the standard for adding a
|
||||
third.
|
||||
|
||||
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||
@@ -126,92 +92,9 @@ nothing the agent can write — not even the status line above it, whose command
|
||||
running with permissions bypassed can write into its own `.claude/settings.json`. Codex's row
|
||||
shares its slot with the last row of the transcript whenever no terminal is running, so a
|
||||
message ending in that exact line is matched. What keeps that harmless is `hooks: 'none'`: no
|
||||
IDLE prompt from a codex session ever reaches the approvals inbox (its screen-read dialogs,
|
||||
below, open PERMISSION items, which a watching label never acknowledges), so a forged label
|
||||
costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a
|
||||
pattern, make sure its row is one the agent cannot write.
|
||||
|
||||
### The idle composer guarded input reads (`composerFooterLine`)
|
||||
|
||||
The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through
|
||||
`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture,
|
||||
that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the
|
||||
exact end of its text. `composerFooterLine` is the second half of that proof: every
|
||||
non-blank row below the composer (at most three) must match it, one row at a time. A CLI
|
||||
that declares none never takes guarded input; only codex declares one, built from the same
|
||||
effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a
|
||||
codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the
|
||||
foot of the screen, so a pattern must anchor on chrome only that CLI draws there.
|
||||
|
||||
Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup
|
||||
(its slash-command list, file search, skill and mention lists) ABOVE the composer and
|
||||
leaves the footer below it alone, so the footer check reads `› /stat` with the command
|
||||
list open as an idle draft, and Enter would run the highlighted `/status`.
|
||||
`composerPopupLine` matches a popup row; it is tested against the contiguous block of
|
||||
non-blank rows directly above the composer (past at most three blank rows, at most twelve
|
||||
rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists
|
||||
the characters that, first in a draft, make Enter run a command (codex: `/!`), and
|
||||
`composerCompletionSigils` the ones that, starting the word under the caret, open a popup
|
||||
(codex: `@$`); stage refuses such a message before typing, the sigils only on the last
|
||||
word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's
|
||||
values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search
|
||||
popup with results shows bare path rows no pattern can tell from prose, which is why the
|
||||
trigger refusal is there as well as the frame check.
|
||||
|
||||
### Dialogs a CLI blocks on (`blockedLine`)
|
||||
|
||||
`blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to
|
||||
report it. Codex is the reason it exists: it is the one CLI Codeman launches with its
|
||||
approvals on by default, and with `hooks: 'none'` a codex session parked on "Would you like to
|
||||
run the following command?" used to read as plainly idle, and one parked on a question as
|
||||
WORKING (the question's own key hints end in `esc to interrupt`, codex's working line). A
|
||||
pane matching it is BLOCKED, which raises exactly what a Claude `permission_prompt` hook
|
||||
raises: the red "needs you" tab alert and desktop notification, web push and webhooks, the
|
||||
phone overview's NEEDS YOU row, an Approvals Inbox item, a run-summary row and the `blocked`
|
||||
wait signal (`web/screen-blocked.ts`). `stop` stays unavailable for such a CLI: nothing on a
|
||||
screen says a turn ended.
|
||||
|
||||
The pattern runs over the last `blockedLines` (1 to 24, default 1) non-blank rows, joined
|
||||
with newlines and tested once (`isBlockedScreen()`, `session-activity.ts`), so it can demand
|
||||
several markers and anchor the dialog's footer on the END of the window (no `m` flag: `$` is
|
||||
the last row). Codex's needs a `› N.` option row and then its key-hint row (`Press enter to
|
||||
confirm or esc to cancel`, `tab to add notes | enter to submit answer | esc to interrupt`,
|
||||
`enter continue · esc quit` for the startup trust prompt, `esc back` when it follows the
|
||||
update prompt), allowing for that row to wrap onto rows of its own below about 60 columns.
|
||||
Measured on codex-cli 0.162.0 at widths 200 down to 36: the command, edit and
|
||||
network-escalation approvals, single and multi-question
|
||||
`request_user_input` dialogs (notes field open or closed), and the workspace trust prompt
|
||||
match; the idle and working views, popups the user opened (`/model`, `/permissions`,
|
||||
`/review`), the composer holding the dialogs' words and a transcript quoting them verbatim
|
||||
do not. `Implement this plan?` after a plan turn is deliberately not matched: its footer is
|
||||
the same as `/model`'s, and the turn has ended there. The fixtures are in
|
||||
`test/fixtures/codex-dialogs/`.
|
||||
|
||||
Three rules hold for any CLI that declares one:
|
||||
|
||||
- ⚠️ The text is pane-derived and the agent writes most of the pane, so the pattern must end
|
||||
on chrome only the CLI draws, at the foot of the screen, where a dialog replaces the
|
||||
composer. A match is an ALERT and nothing else: the inbox item is `answerInTerminal`, the
|
||||
answer route refuses it, push offers no Approve/Deny, and Codeman never answers such a
|
||||
dialog itself, so a forged match costs a false alert and never a keystroke.
|
||||
- The state raises on one reading (the session's settle probe, which reads the screen once
|
||||
the pane goes quiet, which is when a dialog has just been drawn), and while it is up a timer
|
||||
re-reads the screen every 2 s (`BLOCKED_RECHECK_MS`), because answering resumes the turn and
|
||||
a busy pane would not reach the settle path again until the turn ended. The read is skipped
|
||||
while the pane has drawn nothing since the last reading that saw the dialog (a dialog leaves
|
||||
only through a repaint), with a safety-net read every 30 s (`BLOCKED_QUIET_RECHECK_MS`), so a
|
||||
dialog left unanswered for hours does not cost a synchronous `tmux capture-pane` every 2 s.
|
||||
It clears only after
|
||||
two readable captures in a row without the dialog (`BLOCKED_CLEAR_READS`), so a capture
|
||||
taken mid-repaint cannot clear and re-raise it (a second alert, push and webhook for one
|
||||
dialog). An unreadable capture changes nothing in either direction. A blocked screen wins
|
||||
over the working line in the same capture.
|
||||
- Hooks win: a session whose CLI reports blocked itself (`hooksAvailableForMode()`) gets
|
||||
nothing from the screen, so declaring both can never alert twice for one dialog.
|
||||
|
||||
A `GET /api/sessions/:id/wait?until=blocked` that arrives while such a dialog is already up is
|
||||
answered at once (`immediate: true`): unlike a hook's edge, this is a level the server is
|
||||
still reading. A caller that asked only for `idle` keeps getting `idle`.
|
||||
hook event from a codex session reaches the approvals inbox, so a forged label costs a wrong
|
||||
badge and cannot silence an alert. Before giving a CLI both hook signals and a pattern, make
|
||||
sure its row is one the agent cannot write.
|
||||
|
||||
### Three capabilities that must stay independent
|
||||
|
||||
@@ -219,22 +102,6 @@ still reading. A caller that asked only for `idle` keeps getting `idle`.
|
||||
|
||||
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
||||
|
||||
### Where the agent skill lands
|
||||
|
||||
`agentSkillInjection` says a CLI's sessions get the agent skill (`skills/codeman`) and its per-session preamble when the synced `agentSkillEnabled` setting is on; `agentSkillDir` says which workspace directory the CLI scans for skills, read through `agentSkillDir()` (hooks-config.ts), so no call site branches on a CLI id. Absent with the boolean on means `.claude/skills`, which is what the boolean meant before the field existed.
|
||||
|
||||
| CLI | Skill directory |
|
||||
| --- | --- |
|
||||
| claude | `.claude/skills/codeman` |
|
||||
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
|
||||
| pi, shell | none |
|
||||
|
||||
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
|
||||
|
||||
## The newline chord
|
||||
|
||||
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||
|
||||
## Arg-template safety
|
||||
|
||||
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
||||
@@ -332,7 +199,7 @@ already have.
|
||||
|
||||
Three rules, and the middle one is why the embed matters:
|
||||
|
||||
1. **The server executes an entry's `install.command` in exactly two places, both behind a consent click that shows the literal text.** CLI management (above, `cliManagementEnabled`, the merged entry's command) and the local model install (`docs/local-models.md`: only the COMPILED stock command of a catalogue harness row, through `stockInstallCommand()`, so no `clis.json` override reaches it; no setting gates that path, the consent screen does). Both run through the one runner in `src/cli-install-runner.ts` (one install per CLI at a time, no `CODEMAN_*` in the environment, the whole process group killed on a timeout or a cancel) and both are written to the admin audit log in multi-user mode. Everywhere else the field is display text (`CliDiscovery.install.command`).
|
||||
1. **The server never executes an entry's `install.command`.** Unchanged, and still enforced by nothing executing it: the field is display text (`CliDiscovery.install.command`).
|
||||
2. **`install.sh` executes only commands embedded in itself.** Those arrive in the same file, over the same TLS fetch, in the same commit as the `curl \| bash` line that fetched the script — identical trust to the hardcoded vendor one-liners it replaces.
|
||||
3. **Nothing fetched at install time is ever executed.** There is no second code path that fetches anything after the script itself has been fetched.
|
||||
|
||||
@@ -366,24 +233,8 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
||||
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
||||
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
||||
|
||||
## MCP server sync
|
||||
|
||||
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (and GitHub Copilot CLI, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
||||
|
||||
**Tools that are not run modes, or that declare no `mcpConfig`.** `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. GitHub Copilot CLI is both: its registry entry declares `mcpConfig`, so while the run mode is enabled the sync-only row is skipped, and it applies only if the run mode is disabled. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
|
||||
|
||||
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) copilot `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
|
||||
|
||||
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
|
||||
|
||||
## See also
|
||||
|
||||
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
||||
- `docs/architecture-invariants.md` — the mechanics and the history behind the rules above.
|
||||
- `docs/deepseek-integration.md` — why DeepSeek is shaped the way it is.
|
||||
|
||||
## GitHub Copilot CLI
|
||||
|
||||
`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist. **Plan usage:** the header chip gains a `Copilot` row with one `mo` window, the calendar-month premium-request quota (`quota_snapshots.premium_interactions` from `GET api.github.com/copilot_internal/user`, an undocumented endpoint the editors use; the tooltip also shows the request counts and reset). It is polled every 10 minutes by the host, read-only, and only when the CLI is installed, a token resolves (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, then `authTokens` in `<COPILOT_HOME or ~/.copilot>/config.json`) and plan-usage display is on. Any failure leaves the chip without a Copilot row; an unlimited quota has nothing to meter and is omitted.
|
||||
|
||||
Measured on 1.0.94 inside tmux: an alternate-screen TUI with mouse tracking on, whose composer is the `❯` between two rules. While a turn runs the footer reads `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`), which is the `workDetect` working line. Keystrokes sent with `tmux send-keys` reach the composer, Shift+Enter inserts a newline, and a shell tool call completes inside tmux; github/copilot-cli#4180 (a driven PTY ignores input) and #4223 (a shell command never completes under tmux) report both failing on 1.0.70 to 1.0.74 and neither reproduces on 1.0.94. `COPILOT_HOME` and `COPILOT_ALLOW_ALL` are privileged env keys, so a non-granted multi-user owner cannot set them through `envOverrides`, and so are the other `COPILOT_*` variables (read from the 1.0.98 bundle, far more than `copilot help environment` lists) that switch approvals, run code with no approval row or send the sign-in token elsewhere. The `COPILOT_` prefix admits whatever a later release adds, so the list needs a look whenever the measured version moves.
|
||||
@@ -1,159 +0,0 @@
|
||||
# GitHub Copilot CLI sessions
|
||||
|
||||
Codeman can drive [GitHub Copilot CLI](https://github.com/github/copilot-cli) (`copilot`,
|
||||
npm `@github/copilot`) as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
|
||||
Antigravity, Pi, Grok, DeepSeek Harness and OMP. `copilot` is a run mode: its own PTY, its
|
||||
own tmux session, its own tab identity (purple, a drawn mark rather than GitHub's logo). It is
|
||||
not a location overlay like Docker or remote-SSH cases, and it is not a web tab.
|
||||
|
||||
Everything here was measured against Copilot CLI 1.0.94 and 1.0.95 inside tmux. The entry
|
||||
itself is registry data (`src/config/cli-registry/stock.ts`); see
|
||||
[`cli-registry.md`](./cli-registry.md#github-copilot-cli) for the field-by-field reference.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @github/copilot
|
||||
```
|
||||
|
||||
CLI management (Settings → Agents & CLIs) can run exactly that for you. Codeman resolves the
|
||||
binary from the server's PATH and the usual npm/nvm prefixes, then runs `copilot --version`
|
||||
and accepts it only if the output says `GitHub Copilot CLI <x.y.z>`. A bare `copilot` on PATH
|
||||
that answers anything else is ignored. Check what it resolved:
|
||||
|
||||
```bash
|
||||
curl -s localhost:3000/api/copilot/status | jq
|
||||
# { "available": true, "path": "/home/you/.local/bin", "version": "1.0.95" }
|
||||
```
|
||||
|
||||
## Authenticate
|
||||
|
||||
Codeman never logs Copilot in or stores its credentials.
|
||||
|
||||
- **Interactive.** Run `copilot` once and use `/login` (or `copilot login`), or do it inside a
|
||||
Codeman tab. The token goes to the OS keychain when one is available.
|
||||
- **Headless server.** There is no keychain on a service, so Copilot offers to keep the token
|
||||
as **plain text in `~/.copilot/config.json`** (`authTokens`). That is where it ends up when you
|
||||
accept. To keep a token out of a file, set `COPILOT_GITHUB_TOKEN` in the service environment;
|
||||
Copilot reads it first, then `GH_TOKEN`, then `GITHUB_TOKEN`.
|
||||
- **Per-session override.** `COPILOT_*` is the only prefix the env allowlist admits for
|
||||
Copilot. `GH_TOKEN` and `GITHUB_TOKEN` are deliberately **not** accepted as per-session
|
||||
overrides: the allowlist is one global list with no mode context, so admitting them would
|
||||
make them settable on every session. Use `COPILOT_GITHUB_TOKEN`.
|
||||
- **No GitHub sign-in is needed** when a session points at your own endpoint (below).
|
||||
|
||||
## What Codeman wires up
|
||||
|
||||
| Thing | Behaviour |
|
||||
| --- | --- |
|
||||
| Launch | `copilot`, in Copilot's own Manual Approval mode. |
|
||||
| Run button | `copilot --yolo` (allow every tool, path and URL), like the other CLIs' bypass switches, so an unattended session never stops on an approval. |
|
||||
| Permission clamp | `allowAll` is a `privilegedParam`. A multi-user owner without the bypass grant has it forced back to `false`; an absent config is never materialized (a bare `copilot` is already safe). `COPILOT_HOME`, `COPILOT_ALLOW_ALL`, `COPILOT_PROVIDER_*` and `COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals (`COPILOT_PLAN_THEN_AUTOPILOT`), runs code with no approval row (`COPILOT_CLI_DIST_DIR`, the package caches, `COPILOT_EDITOR`, `COPILOT_PROVIDER_API_KEY_COMMAND`, skills, hooks) or sends the sign-in token to another host (`COPILOT_API_URL`, `COPILOT_GH_HOST`, ...): the list is in `stock.ts`, read from the 1.0.98 bundle. A variable a later Copilot release adds is not covered until it is listed there. |
|
||||
| Model | `--model <id>`, sent only by an API caller that sets `copilotConfig.model` (the value must start with a letter or digit, like `resumeSessionId`). Nothing in the UI sets it: a saved custom endpoint sets `COPILOT_MODEL` instead (see below). |
|
||||
| Resume | `--resume <value>`, sent only by an API caller that sets `copilotConfig.resumeSessionId` (Past Sessions does not). The value (an id, an id prefix or a session name) must be one plain word starting with a letter or digit: `--resume`'s value is optional, so `--resume --yolo` would otherwise make `--yolo` its own flag and bypass the clamp. |
|
||||
| Continue | `--continue`, from **Past Sessions**: it resumes the most recent conversation Copilot has for that directory. Nothing sets it on a respawn. Skipped when a resume value is set. |
|
||||
| Name | A tab name **you set yourself** is passed as `--name <tab name>`, so Copilot's own session list reads like that tab. An automatic name (`w1-myapp`, or an auto-named title) is not pinned. Copilot refuses `--name` with `--resume`/`--continue`, so it is dropped there and a continued session keeps the name it had. |
|
||||
| Working / idle | The footer. A turn shows `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`); the composer is the `❯` between two rules. A tool waiting for approval replaces the composer and reads as idle. |
|
||||
| Model detection | The right-hand field of the footer's last row, at rest and mid-turn. |
|
||||
| Status probe | `GET /api/copilot/status`, plus an entry in the CLI-installed probes and `codeman doctor`. |
|
||||
| MCP | The entry declares `mcpConfig` (`~/.copilot/mcp-config.json`, dialect `copilot-json`, relocated by `COPILOT_HOME`), so [MCP server sync](./cli-registry.md) treats it as a target and a source. `copilot mcp disable` is honoured (read from `settings.json`, never written). |
|
||||
| Plan usage | The header chip's **Copilot** row: the monthly premium-request quota from `GET https://api.github.com/copilot_internal/user`, read at startup and every 10 minutes with Copilot's own sign-in token (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a sign-in kept in the OS keychain shows no row). On by default, like the Codex usage poll: it rides Plan Usage Limits (`showPlanUsageLimits`), and turning that off stops the request. Read-only, redirects refused, the token never logged or stored; any failure leaves the row out. |
|
||||
| Docker | The agent image installs `@github/copilot`. No credential directory is seeded (see below). |
|
||||
|
||||
### Your own endpoint (BYOK)
|
||||
|
||||
With custom model endpoints on, a saved endpoint is offered in the Run menu for Copilot and
|
||||
launches straight onto it (no restart; the env is applied before the process starts):
|
||||
|
||||
| Codeman field | Variable |
|
||||
| --- | --- |
|
||||
| base URL (+ `/v1`) | `COPILOT_PROVIDER_BASE_URL` |
|
||||
| key | `COPILOT_PROVIDER_API_KEY` |
|
||||
| model | `COPILOT_MODEL` |
|
||||
|
||||
`COPILOT_PROVIDER_TYPE` is not set: it defaults to `openai`, which is what a Codeman custom
|
||||
endpoint is (llama.cpp, vLLM, Ollama). All of these are privileged env keys.
|
||||
|
||||
Checked at the CLI level against a local OpenAI-compatible endpoint (1.0.95): it posted to
|
||||
`/v1/chat/completions` with the key and the model name, needed no GitHub sign-in, and
|
||||
`--resume <id> --model <m>` / `--continue --model <m>` resumed earlier sessions. Tool calls
|
||||
through a custom endpoint were not exercised. Only keys are persisted (never values).
|
||||
|
||||
## Terminal behavior
|
||||
|
||||
Copilot is an alternate-screen TUI with mouse tracking on, so it needed one non-default
|
||||
decision, the same one OpenCode needed:
|
||||
|
||||
- **Selection.** Left in, xterm reports a plain drag to the TUI instead of selecting, so
|
||||
Auto Copy and copy-on-select silently did nothing (only Shift+drag selected). The entry is
|
||||
`altScreen: 'strip-mux-and-mouse'`: the tracking DECSETs are stripped under tmux, a drag is
|
||||
a local selection, and clicks still reach Copilot through the browser's hand-encoded tap
|
||||
(`cliMouseTracking` is published as the server strips).
|
||||
- **Wheel and touch.** Stripping the DECSETs also stops xterm encoding the wheel, so
|
||||
`_shouldForwardWheelToApp` forwards it by hand as SGR reports while Copilot is tracking the
|
||||
mouse; a phone swipe goes through the same gate. Shift+wheel scrolls Codeman's local
|
||||
history. If the tracking flag is stale after a server restart, the gesture falls back to
|
||||
PageUp/PageDown rather than going dead (Copilot pages its own transcript on those keys: three
|
||||
PageUp moved a 140-line answer from lines 109-140 to 010-042).
|
||||
- **Keys.** Keystrokes from `tmux send-keys` and `POST /api/sessions/:id/input` reach the
|
||||
composer; Shift+Enter inserts a newline; shell tool calls complete inside tmux
|
||||
(github/copilot-cli#4180 and #4223 report both failing on 1.0.70 to 1.0.74; neither
|
||||
reproduces on 1.0.94).
|
||||
- **Scrollbar.** Copilot draws a `┃` gutter at the right edge of every row; the response
|
||||
reader strips it.
|
||||
|
||||
## `codeman agent` verbs
|
||||
|
||||
The three `CODEMAN_*` variables are exported into every pane, so the
|
||||
[session-to-session verbs](../README.md#codeman-agent--session-to-session-verbs-in-every-cli-mode)
|
||||
work from inside a Copilot tab and against Copilot workers:
|
||||
|
||||
- `spawn` waits for the composer. The ready mark is the footer hint `/ commands · ? help`, **not**
|
||||
`❯`: the folder-trust dialog draws `❯ 1. Yes`, so the glyph would call a worker ready while it
|
||||
is still asking whether to trust the folder. An untrusted folder therefore makes `spawn`
|
||||
warn that the composer was not seen instead of claiming ready.
|
||||
- `send --wait` resolves on `idle`. `--until stop` is refused (`400`, "no Claude Code hooks")
|
||||
like every hook-less mode; use `idle`, `exit` or a `--match` marker.
|
||||
- `read` returns the last answer. Copilot's output stream is absolute cursor moves rather
|
||||
than lines, so the route reads the pane's **visible screen** (tmux `capture-pane`) and cuts it
|
||||
with a per-CLI pane dialect (`src/web/response-viewer-transcript.ts`): `❯ <prompt> HH:MM`,
|
||||
`● <answer>` with indented continuation, and the cwd / `Session: N AIC used` row, composer,
|
||||
footer and scrollbar as chrome. Everything before the first prompt (banner, trust dialog) is
|
||||
dropped, so a session that has not been asked anything answers nothing.
|
||||
- `interrupt` sends a bare ESC; `rm` refuses itself.
|
||||
|
||||
## Docker cases
|
||||
|
||||
The agent image installs `@github/copilot`, and a docker case runs Copilot like any mode. **No
|
||||
credential directory is seeded**: without a keyring the sign-in token is plain text in
|
||||
`config.json`, and `CRED_STORES` (`src/docker-hosts.ts`) deliberately has no `.copilot` row.
|
||||
Sign in inside the container (`copilot login`). Setting `COPILOT_GITHUB_TOKEN` for the session does
|
||||
not reach it: a docker quick-start refuses `envOverrides`, and Copilot's `dockerExecEnvNames` is
|
||||
empty, so a token in the server environment is not forwarded either.
|
||||
|
||||
## Remote SSH cases
|
||||
|
||||
A remote or Docker pane runs a bare `copilot` (`defaultRemoteCommandForMode`, pinned in
|
||||
`test/location-overlay-commands.test.ts`), in Copilot's own Manual Approval mode. The
|
||||
`copilotConfig` flags (`--yolo`, `--model`, `--resume`, `--continue`) are a local-session feature
|
||||
and are not sent there, so there is nothing for the privileged-param clamp to do. A remote host
|
||||
needs its own `copilot` on its own PATH and its own sign-in. This path has unit coverage for the
|
||||
built command but was not exercised against a real remote host.
|
||||
|
||||
## What Codeman deliberately does NOT wire up
|
||||
|
||||
- **No login or token storage.** See Authenticate.
|
||||
- **No bypass inferred.** `--yolo` is sent only by the Run button's `allowAll`, never
|
||||
guessed from anything else, and is clamped for non-granted owners.
|
||||
- **No `--api-key`-style flag on the command line.** Secrets travel as environment variables
|
||||
through `tmux setenv`.
|
||||
- **No Claude-only features.** Auto-resume on a usage limit, the Approvals Inbox, Read My Mind,
|
||||
the Ralph tracker, subagent windows and the bundled agent skill are Claude-shaped.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- `agent read` does not model tool-call rows: they open with the same `●` bullet and read as
|
||||
part of the answer.
|
||||
- `agent read` sees the visible screen, so an answer longer than one screen is its tail.
|
||||
- Custom endpoints: tool calls through a BYOK endpoint were not exercised.
|
||||
- Remote SSH was not exercised against a real remote host.
|
||||
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
|
||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
|
||||
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
|
||||
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
||||
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
||||
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
||||
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
### In the browser
|
||||
|
||||
1. Click **⏰ Cron** in the bottom toolbar.
|
||||
1. Click **⏰ Cron** in the header.
|
||||
2. Click **+ New Job**.
|
||||
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
||||
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
||||
@@ -204,13 +204,12 @@ trailing carriage return:
|
||||
|
||||
| Mode | Mechanism | Use when |
|
||||
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. |
|
||||
| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. |
|
||||
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
|
||||
|
||||
> ⚠️ **Single-line only — enforced.** A job fires unattended, and the submit
|
||||
> check that re-presses an ignored Enter cannot reliably recognise a multi-line
|
||||
> paste in the composer, so a stranded prompt would go unnoticed (in paste mode
|
||||
> the newlines reach the CLI as keys). So newlines are **rejected**: the
|
||||
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
|
||||
> multi-line delivery would be silently corrupted (Ink-based TUIs treat a
|
||||
> newline as submit; typed mode fuses lines). So newlines are **rejected**: the
|
||||
> schema and the form refuse a multi-line `promptText`, and at fire time a
|
||||
> prompt file whose content is multi-line (after stripping trailing newlines)
|
||||
> fails the run with a clear `errorMessage`. Put multi-line instructions in a
|
||||
@@ -262,8 +261,8 @@ Sequence in `CronService.launch()`:
|
||||
4. **Auto-close previous session** (recurring schedules, unless
|
||||
`autoClosePreviousSession: false`): any still-open session created by this
|
||||
job's previous runs is closed via the normal session-cleanup path.
|
||||
5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS`, 50 unless
|
||||
`CODEMAN_MAX_SESSIONS` sets it). At cap → **`failed`**.
|
||||
5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap →
|
||||
**`failed`**.
|
||||
6. A `Session` is created **with `useMux: true`** (so it runs inside tmux),
|
||||
registered, listeners attached, and started via `startInteractive()`
|
||||
(`startShell()` for `shell` mode). Model/claudeMode come from global config.
|
||||
@@ -285,7 +284,7 @@ automatically after **Run Now**.
|
||||
> ⚠️ **Session-cap math if you disable auto-close.** With
|
||||
> `autoClosePreviousSession: false`, nothing ever closes the sessions a
|
||||
> recurring job creates — an interval job every 30 min creates 48 tabs/day and
|
||||
> hits the global session cap (50 by default, `CODEMAN_MAX_SESSIONS`) in ~25 hours (sooner with existing tabs), after
|
||||
> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after
|
||||
> which **every** fire of **every** job fails with "Maximum concurrent sessions
|
||||
> reached" until you delete tabs by hand. Leave auto-close on for unattended
|
||||
> recurring jobs, or clean up sessions yourself.
|
||||
@@ -377,7 +376,7 @@ boot. Sessions the jobs create persist through the normal session-recovery path.
|
||||
| Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` |
|
||||
| Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) |
|
||||
| Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) |
|
||||
| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` (env `CODEMAN_MAX_SESSIONS`) |
|
||||
| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` |
|
||||
| Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) |
|
||||
| `name` length | 1–200 | `CronJobSchema` |
|
||||
| `promptText` length | ≤ 100000 | `CronJobSchema` |
|
||||
|
||||
@@ -8,10 +8,8 @@ against a **cloud** OpenAI-compatible endpoint too (e.g. Azure AI Foundry's
|
||||
OpenAI-compatible inference endpoint, OpenRouter, a self-hosted gateway).
|
||||
Right now every Codeman session mode defaults to its native cloud backend
|
||||
with no way to redirect a session at any other endpoint from the UI — the
|
||||
closest existing precedent was DeepSeek's server-env-sourced
|
||||
`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since
|
||||
been superseded by deepseek's per-session patch recipe, see the table
|
||||
below).
|
||||
closest existing precedent is DeepSeek's server-env-sourced
|
||||
`DEEPSEEK_BASE_URL`, which isn't user-facing.
|
||||
|
||||
**Scope note**: this plan originally said "local LLM." It now covers any
|
||||
OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama,
|
||||
@@ -114,7 +112,7 @@ declared capability, never an `if (mode === 'claude')` branch.
|
||||
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
|
||||
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
|
||||
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
|
||||
| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml`, a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY`, a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`, the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` |
|
||||
| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST <baseUrl>/chat/completions` → `404`, `POST <baseUrl>/v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp |
|
||||
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
|
||||
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
|
||||
|
||||
|
||||
@@ -67,23 +67,15 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one.
|
||||
Endpoint management is admin-only in multi-user mode, same as remote/docker
|
||||
hosts — these are machine-level infra, not per-user settings.
|
||||
|
||||
**Context length is discovered too, opportunistically and safely.** Older
|
||||
servers state no context-window field in the plain `GET /v1/models`
|
||||
response at all; current llama-swap states one per model (both
|
||||
`context_length` and `context_window`) and gateways like Bifrost pass
|
||||
`context_window` through: that listed figure is the *lowest*-precedence
|
||||
source, used only when neither probe below answers, and because llama-swap
|
||||
documents it as the PER-SLOT window, a model whose figure comes from the
|
||||
listing is recorded with a slot count of 1 so the slot check never divides a
|
||||
per-slot figure a second time. The probes are only ever looked for on a model
|
||||
llama-swap's own response already reports
|
||||
**Context length is discovered too, opportunistically and safely.** The plain
|
||||
`GET /v1/models` response has no context-window field. Discovery only ever
|
||||
looks for one for a model llama-swap's own response already reports
|
||||
`status.value === "loaded"` for — never for an unloaded one, because
|
||||
llama-swap treats `?model=` as a routing hint and asking about a model that
|
||||
isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side
|
||||
effect of what should be read-only discovery. A server with no `status` field
|
||||
on any entry at all (not llama-swap) gets no launch-command/`/props`
|
||||
enrichment, only whatever its listings state, rather than guessing. A
|
||||
model's previously-learned context length survives a
|
||||
on any entry at all (not llama-swap) gets no context-length enrichment,
|
||||
rather than guessing. A model's previously-learned context length survives a
|
||||
later cycle where it wasn't the loaded one; it's dropped only once the model
|
||||
disappears from the endpoint's list entirely. Stored per model in
|
||||
`modelContextLengths` and applied automatically (see "Applying a model to a
|
||||
@@ -105,13 +97,6 @@ llama-swap's own launch command instead (`GET /running`'s `cmd` field —
|
||||
hand-written command might use), and only falls back to the `/props` probe
|
||||
when `cmd` states no recognizable flag at all.
|
||||
|
||||
**Parallel slots are read the same way.** `--parallel N` / `-np N` from `cmd` gives
|
||||
the slot count (`modelSlotCounts`); only when `cmd` has none does discovery read
|
||||
`total_slots` from `/props`, because a server started without the flag picks the
|
||||
number itself. `/props` never widens the context the slot check divides, and it is
|
||||
probed once per launch command rather than on every sweep, because llama-swap
|
||||
treats the call as use of the model and a `ttl` would otherwise never expire.
|
||||
|
||||
**File size is discovered too, when the server states one.** llama-swap
|
||||
writes a GB figure into an auto-discovered model's own `description`
|
||||
(`"Auto-discovered 16.35 GB - parameters auto-fitted by llama.cpp"`), parsed
|
||||
@@ -200,66 +185,32 @@ saved default, not just confirming it.
|
||||
|
||||
The modal promotes exactly one row to the top of the list rather than
|
||||
always showing raw discovery order, so the zero-wait choice is the one
|
||||
under your thumb. Three kinds of knowledge feed that promotion, best
|
||||
evidence first, and the first one that answers wins:
|
||||
under your thumb:
|
||||
|
||||
- **"Currently loaded"** is a model from this host's own list that
|
||||
- **"Currently loaded"** — a model from this host's own list that
|
||||
llama-swap reports `ready` right now, queried via
|
||||
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
|
||||
~800ms (`Promise.race`), on top of the route's own 5s server-side
|
||||
timeout, so an endpoint that is asleep or firewalled cannot leave the
|
||||
modal invisible for the full 5s after the Run menu has already closed.
|
||||
- **"In use by a session"** is the fallback for every endpoint that
|
||||
cannot report its own loaded model, which is every server except
|
||||
llama-swap: a bare llama.cpp, Ollama, vLLM, and a gateway such as
|
||||
Bifrost fronting a whole fleet all answer `isLlamaSwap: false`.
|
||||
`_getCustomModelInUseBySession` walks `this.sessions` for one of your own
|
||||
sessions whose `customModel.endpointId` is this host, newest
|
||||
`createdAt` wins, and promotes its model. A live session is first-hand
|
||||
evidence of a model in use on the endpoint, because Codeman put it
|
||||
there, so it earns the promotion and the tag says where the knowledge
|
||||
came from. Two fences keep it honest: it reads only `customModel`,
|
||||
never `displayModel` (display text scraped off a CLI footer, so a
|
||||
terminal that merely printed a similar string must not mark a row),
|
||||
and a session with `status: 'stopped'`, a null `pid` (its PTY exited,
|
||||
the same live-pane test keep-warm applies) or a recorded `paneExit`
|
||||
does not count, since nothing is on the endpoint any more. Sessions on other
|
||||
endpoints, and models the host no longer discovers, are skipped.
|
||||
- **"Last used"** appears only when neither of the above answers: the
|
||||
model actually launched last for this exact (harness, endpoint) pair,
|
||||
read from the per-device localStorage key
|
||||
`codeman:customModelLastUsed:<mode>:<endpointId>`. That key is per CLI
|
||||
on purpose, so it can only
|
||||
ever promote the CLI that launched there before, which is why a gateway
|
||||
box with no session running shows plain discovery order for the other
|
||||
CLIs. Written by `_runCustomModelEntryViaRestart` (claude) and
|
||||
`_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
- **"Last used"** — shown only when nothing is currently loaded: the model
|
||||
actually launched last for this exact (harness, endpoint) pair, read
|
||||
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
|
||||
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
|
||||
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
`runCustomModelEntry` entry point itself only dispatches between the
|
||||
two) only once the model is actually applied, never on the mere click:
|
||||
two) only once the model is actually applied, never on the mere click —
|
||||
declining the context-window warning means this exact model cannot work
|
||||
with this CLI at all, so promoting it next time would be actively
|
||||
wrong, not just premature.
|
||||
with this CLI at all, so promoting it next time would be actively wrong,
|
||||
not just premature.
|
||||
|
||||
No promotion reorders anything past that one promoted row. The "Default"
|
||||
Neither tag reorders anything past that one promoted row. The "Default"
|
||||
pill is a separate span, not a third value of the same slot: a promoted
|
||||
row that is also the endpoint's `defaultModelId` shows both tags (on a
|
||||
single-purpose GPU box that is the common case, and an exclusive slot
|
||||
silently dropped the Default marking for exactly that row), and a row
|
||||
with neither promotion nor default shows no tag at all.
|
||||
|
||||
When nothing is known about any row, `#customModelPickUnknown` says so
|
||||
out loud instead of leaving an alphabetical wall that reads as the app
|
||||
ignoring the question. It shows when the probe answered
|
||||
`isLlamaSwap: false`, which the route gives for every failure it
|
||||
catches, so one answer covers a server that cannot report and an
|
||||
endpoint that is down or refused alike; the line's wording therefore
|
||||
claims only that nothing was reported. A probe that never got any
|
||||
answer (`_getCustomModelRunningStatus` resolving `null`, Codeman's own
|
||||
request failed or the client-side bound won) keeps the line hidden.
|
||||
Like its sibling no-matches line it needs an
|
||||
explicit `[hidden]` rule scoped to `#customModelPickModal`, because
|
||||
`.form-hint`'s `display: block` outranks the UA `[hidden]` rule.
|
||||
|
||||
**How the launch itself applies the endpoint depends on the harness.** For
|
||||
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
|
||||
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
|
||||
@@ -328,15 +279,6 @@ hot-swap. A Claude session is relaunched with `--resume <conversation> ||
|
||||
grok are relaunched with the `--model` value that selects the injected
|
||||
provider (`custom/<modelId>` for pi and omp, `codeman-custom` for grok),
|
||||
since for those three the config file alone does not switch the model.
|
||||
**Codex is relaunched with `--model <modelId>` too**, the bare selected id
|
||||
(not a provider selector): the launch param forces the session onto the
|
||||
model its generated `config.toml` already selected, so a launch-time model
|
||||
carried over from App Settings can never override the injected selection.
|
||||
That launch arg must satisfy the codex entry's `model-plain-colon` token
|
||||
pattern (a leading letter or digit, then letters, digits and `._-:/`, per
|
||||
`stock.ts`), so an id outside that shape, e.g. LM Studio's
|
||||
`org/model@q4_k_m`, is refused with a 400 at apply time now, where a
|
||||
`config.toml` alone used to carry it.
|
||||
**Remote (SSH) and Docker sessions are refused** (400) for now: their restart
|
||||
reattaches the durable remote/in-container tmux rather than relaunching the
|
||||
agent, so the selection would report success and change nothing.
|
||||
@@ -344,16 +286,14 @@ agent, so the selection would report success and change nothing.
|
||||
**Claude gets two more env vars when known/applicable, both declared on its
|
||||
registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:**
|
||||
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length:
|
||||
the discovered figure (see the discovery section above), falling back to
|
||||
the endpoint's user-set `defaultContextLength`, whenever one is known.
|
||||
Without
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context
|
||||
length (see the discovery section above) whenever one is known. Without
|
||||
it, Claude Code assumes a large (200k) window for any unrecognized custom
|
||||
model id and never compacts, which reliably overflows a much smaller real
|
||||
local context — confirmed live: a stock ~33.7K-token system prompt against
|
||||
a 16384-token llama-swap model failed with `exceeds the available context
|
||||
size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
|
||||
for the model means the var is simply omitted, never a guess. ⚠️ **This var only affects when Claude
|
||||
size`. No entry for the model in `modelContextLengths` means the var is
|
||||
simply omitted, never a guess. ⚠️ **This var only affects when Claude
|
||||
Code compacts conversation _history_ — it cannot fix a model whose real
|
||||
context is smaller than Claude Code's own fixed per-turn overhead**
|
||||
(system prompt + tool schemas, empirically ~36.4K tokens, confirmed live
|
||||
@@ -384,10 +324,7 @@ size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
|
||||
included), user-level agents and commands, and the MCP servers configured
|
||||
in `~/.claude.json` — none of those are symlinked back, only `projects` is.
|
||||
A fine trade for "point this session at my local llama.cpp," but worth
|
||||
knowing before it surprises you mid-session. A session on its own
|
||||
`CLAUDE_CONFIG_DIR` links `projects` from that directory instead, so its
|
||||
conversation, and what the endpoint run adds to it, stay on its own account
|
||||
across an apply and a clear.
|
||||
knowing before it surprises you mid-session.
|
||||
|
||||
**That isolated directory needed one more fix to actually be usable
|
||||
non-interactively.** An otherwise-empty `CLAUDE_CONFIG_DIR` has none of a
|
||||
@@ -497,17 +434,15 @@ Both apply routes (the restart route and the one-shot `POST
|
||||
/api/quick-start`) now check for this **before** launching or restarting
|
||||
anything, gated on the CLI's registry entry declaring a `contextLengthVar`
|
||||
(currently only claude — the check is a no-op for every other CLI by
|
||||
construction, never a hardcoded mode check). If the model's context (the
|
||||
discovered figure from `modelContextLengths`, or the endpoint's
|
||||
`defaultContextLength` when discovery never learned one for that model) is
|
||||
below `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
construction, never a hardcoded mode check). If the model's discovered
|
||||
context (`modelContextLengths`, from discovery above) is below
|
||||
`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
~36.4K overhead), the response is `{requiresContextWarning: true, modelId,
|
||||
contextLength, minSafeContextTokens}` (plus `slotCount` when the model runs more than one parallel slot) instead of applying — nothing is
|
||||
restarted or created yet. A model with neither a discovered figure nor a
|
||||
`defaultContextLength` skips the check entirely (nothing to compare, so it
|
||||
fails open rather than warning on every model an endpoint hasn't reported a
|
||||
size for).
|
||||
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. ⚠️ `defaultContextLength` is the model's whole window, never a per-slot figure, so a default small enough to split under the slot count trips that slot division exactly like a discovered figure does. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
contextLength, minSafeContextTokens}` instead of applying — nothing is
|
||||
restarted or created yet. A context length that was never discovered at
|
||||
all skips the check entirely (nothing to compare, so it fails open rather
|
||||
than warning on every model an endpoint hasn't reported a size for).
|
||||
Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
|
||||
The Run-menu picker shows this as an in-app modal
|
||||
(`#customModelContextWarningModal`, matching the llama-swap conflict
|
||||
@@ -538,31 +473,13 @@ id, model and injected key NAMES are persisted, the values are re-derived
|
||||
from the endpoint store on recovery, and the pane keeps running against the
|
||||
endpoint in between because tmux retains its environment.
|
||||
|
||||
A value the session set itself under an injected key comes back when the
|
||||
selection goes, on a clear or a switch to another endpoint. That is what
|
||||
keeps a session on its own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME`, and on
|
||||
its own account through `CLAUDE_CONFIG_DIR` (the per-client-account case).
|
||||
|
||||
The launch model goes the same way: a clear relaunches on the model the session
|
||||
was created with, or with none if it was created without one. A session an
|
||||
older build launched straight onto an endpoint saved that endpoint's model in
|
||||
its config, so its clear still relaunches on that id.
|
||||
|
||||
⚠️ Across a Codeman restart only the home directory comes back, and only as
|
||||
an absolute path. The selection overwrote the tmux `setenv` that held it, so
|
||||
its record in `state.json` keeps that one path (`ownEnv`), and a non-granted
|
||||
owner loses it at recovery like any other privileged override. Any other own
|
||||
value can be a credential and stays in memory: a session that set its own
|
||||
`GEMINI_API_KEY`, picked an endpoint and then outlived a restart falls back to
|
||||
the server's key on clear. A selection saved by an older build has no such
|
||||
record, so its first clear still falls back to the server's default.
|
||||
|
||||
⚠️ That record only exists when the selection was made before the restart. An
|
||||
own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME` on a session with no selection is
|
||||
not persisted at all (a session's overrides keep only `CLAUDE_CODE_*` and
|
||||
`CLAUDE_CONFIG_DIR`), so a Codeman restart leaves nothing to record: an
|
||||
endpoint picked after it, and cleared later, falls back to the server's
|
||||
default. `CLAUDE_CONFIG_DIR` does not have this gap.
|
||||
⚠️ Clearing removes injected keys **by name**, and `CLAUDE_CONFIG_DIR` is one
|
||||
of the names claude's selection injects — so a session that ALSO had
|
||||
`CLAUDE_CONFIG_DIR` set through the generic `envOverrides` field (the
|
||||
per-client-account case) loses that override on clear too, and silently
|
||||
falls back to the server's default Claude account. If you route a session
|
||||
to a specific account this way, re-apply the override after clearing a
|
||||
custom-model selection from it.
|
||||
|
||||
**New sessions always default back to the harness's native backend.** A
|
||||
custom-endpoint selection is a per-session choice, never a sticky global
|
||||
@@ -602,12 +519,7 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
and fabricating a fake catalog entry would mean copying the _shape_ of
|
||||
OpenAI's own proprietary schema (their per-model system-prompt content
|
||||
included) for a warning that doesn't otherwise affect behavior — not
|
||||
something to build into discovery. ⚠️ The fallback-metadata warning is
|
||||
NOT about the context window going un-overridden. Codex does now get a
|
||||
real context-window override: when a figure is known the generated
|
||||
`config.toml` sets the top-level `model_context_window` to it and
|
||||
`model_auto_compact_token_limit` to 75% of it (floored), so Codex
|
||||
compacts at that point instead of trusting its fallback guess.
|
||||
something to build into discovery.
|
||||
- **Gemini** — fails with `Invalid auth method selected`, traced to an
|
||||
undocumented `GATEWAY` auth path gemini-cli selects once
|
||||
`GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation
|
||||
@@ -622,40 +534,14 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
`POST <baseUrl>/chat/completions` 404s against llama-swap while
|
||||
`POST <baseUrl>/v1/chat/completions` succeeds; the harness's own error
|
||||
template (`DeepSeek API error (HTTP ${status})`) matches the originally
|
||||
reported symptom exactly. The fix is no longer the original env redirect:
|
||||
deepseek's `customModelInjection` is a `configDir` recipe: a per-session
|
||||
`DSH_HOME` holding only a generated `cordis.patch.yml` that declares a
|
||||
`codeman-custom` `openai-completions` provider (the base URL gets the `/v1`
|
||||
suffix from the shared `renderConfigFile` step every file template goes
|
||||
through), so the installed profile keeps supplying the transport; the
|
||||
recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and
|
||||
`settings.yaml` back out of the real dsh home so installed bundles,
|
||||
transcripts and user settings all survive the redirect (missing
|
||||
directory entries are created first: a home that has never run a
|
||||
turn would otherwise get a dangling link whose every write through
|
||||
it failed with ENOENT). **Verified end-to-end against a real dsh
|
||||
install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1):
|
||||
`--dump-config` shows the three patched rows, the TUI boots on the
|
||||
chosen model, and a prompt reaches the endpoint as
|
||||
`POST /v1/chat/completions` with the right model, the bearer key and
|
||||
`max_tokens: 8192`; the reply renders and the context window shows in
|
||||
the footer. What such a session SHARES with the real home is
|
||||
exactly the linked entries: `profiles/`, `sessions/`,
|
||||
`settings.yaml`. The last is shared only until the session writes
|
||||
it: dsh saves settings with a temp file and a rename
|
||||
(`writeFileAtomic` in `@deepseek-ai/dsh-atomic-write`), which replaces
|
||||
the symlink, so the first `/settings` change inside such a session
|
||||
never reaches `~/.dsh/settings.yaml` and is deleted with the session's
|
||||
directory on clear or close. Everything else in the generated `DSH_HOME` is
|
||||
isolated: `storages/` (dsh-TUI's persisted `/model` choice and
|
||||
workspace state), `.agent-presets` and `.env`. Two consequences of
|
||||
the generated home-level file follow: the user's own home-level
|
||||
`cordis.patch.yml` does NOT apply in such a session (the generated
|
||||
file replaces it; profile-level patch files reached through the
|
||||
linked `profiles/` still compose), and the generated patch REPLACES
|
||||
the `llm-pi-ai` provider row's whole `config` for the session. Note
|
||||
too that it likewise REPLACES the `dsh-tui` row's whole `config`, so
|
||||
a profile-level `effort`/`preset` does not apply.
|
||||
reported symptom exactly. `customModelInjection`'s new `appendV1Suffix`
|
||||
(deepseek's entry only — claude/gemini must NOT get it, since claude was
|
||||
already confirmed working against the raw `baseUrl`) fixes it by writing
|
||||
`DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a
|
||||
real `dsh` binary (no install available in this environment) — the fix
|
||||
is source-confirmed and live-verified at the HTTP level, but a real
|
||||
"hello world" reply through `dsh` itself is still outstanding before
|
||||
calling this fully verified like the harnesses above.
|
||||
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
|
||||
|
||||
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
|
||||
@@ -663,45 +549,6 @@ each result. `scripts/test-local-llm-harnesses.ts` is the standalone script
|
||||
used to check a harness against a real endpoint outside the web UI
|
||||
entirely; see its own `--help` for usage.
|
||||
|
||||
## The endpoint store has one writer
|
||||
|
||||
`~/.codeman/custom-model-hosts.json` is written only through `mutateCustomModelHosts()`
|
||||
(`src/custom-model-hosts-writer.ts`): the create, update, delete and discover routes, the
|
||||
five-minute re-discovery sweep and the local model install all go through it.
|
||||
|
||||
- **One at a time.** Changes run on one queue, and the check a route makes ("this id is
|
||||
free", "this endpoint still exists") runs in the same step as its write. Before, each
|
||||
writer read the file, changed its copy and wrote it back on its own, all through one temp
|
||||
file name: a route and the sweep, or two requests sent together, could lose each other's
|
||||
change or fail outright.
|
||||
- **Atomic.** A temp file with a random name beside the target, then a rename. Mode 0600.
|
||||
- **A file that does not parse is refused, not replaced.** The writing routes answer
|
||||
`409 CONFLICT` with the fix (repair or delete the file, or restore it from a config
|
||||
backup) and leave it byte for byte. Before, such a file read as an empty list, and the next
|
||||
save replaced every stored endpoint and its API key with one record. The message never
|
||||
quotes the file, which holds keys.
|
||||
|
||||
The one direct writer left is config-backup restore, which must work on a file that does
|
||||
not parse.
|
||||
|
||||
## The endpoint of the model on this Mac
|
||||
|
||||
Installing a local model from Codeman ([local-models.md](local-models.md)) saves one endpoint
|
||||
with the reserved id `local-ollama` and the label "This Mac". It shows up in the list and in
|
||||
the Run menu like any other endpoint. Three things are different:
|
||||
|
||||
- A new endpoint cannot be given the id `local-ollama`.
|
||||
- While it is the endpoint the install manages, it cannot be edited or deleted here, and
|
||||
"Discover" answers its stored model list without asking the runtime. Its model list must
|
||||
stay the one model the install checked, and its address the port of the runtime Codeman
|
||||
supervises. Change or remove it in the local model window.
|
||||
- A session is started on it only while Codeman's own runtime is running and the model is
|
||||
still the one that was installed (its digest is compared). Otherwise the launch answers
|
||||
with one sentence.
|
||||
|
||||
An endpoint with that id that somebody made by hand, with no model installed by Codeman, is
|
||||
an ordinary endpoint: it can be edited and deleted as before.
|
||||
|
||||
## Security note
|
||||
|
||||
Every env var this feature can set that redirects a session's traffic
|
||||
|
||||
@@ -53,9 +53,7 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
|
||||
surfaces that same line as the install error. `npm install -g pnpm` (or
|
||||
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
||||
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
||||
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
|
||||
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
|
||||
button works there too.
|
||||
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
|
||||
@@ -201,18 +199,6 @@ not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
|
||||
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
||||
profile. That is also how you point dsh at a local or third-party provider.
|
||||
|
||||
Codeman does READ the route, for display only: a session header names the model
|
||||
the TUI's status line draws, and while it draws none (the status bar's model
|
||||
field switched off, or not painted yet) the model the session's route config
|
||||
pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of
|
||||
`profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying
|
||||
`config` for the `dsh-tui` row, and only when it names BOTH `provider` and
|
||||
`model`; a half-pinned route is dropped whole by the TUI and shows nothing here.
|
||||
`settings.yaml`'s `agent-default-model` is the headless default and is not read.
|
||||
The reader never writes, follows no symlink out of the dsh home, and returns the
|
||||
model id alone. The TUI can still reject a pinned route against its provider's
|
||||
model catalog at startup; the status line, when on, then shows what it chose.
|
||||
|
||||
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
||||
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
||||
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
||||
@@ -235,11 +221,9 @@ worker's first answer reads as an answer. Three things about the file shaped the
|
||||
reader (`src/deepseek-transcript.ts`):
|
||||
|
||||
- **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all
|
||||
of them, Node's `zlib` zstd decoder before 24.22 and 26.10 stops at the first:
|
||||
a real 56-line transcript came back as 1 line. The reader walks frame headers
|
||||
itself, which stays correct on a Node that decodes every frame
|
||||
(nodejs/node#65865). On a Node older than 22.15 (no zstd at all) the mode
|
||||
falls back to the pane, as before.
|
||||
of them, Node's `zlib` zstd decoder stops at the first: a real 56-line
|
||||
transcript came back as 1 line. The reader walks frame headers itself. On a Node
|
||||
older than 22.15 (no zstd at all) the mode falls back to the pane, as before.
|
||||
- **Not every `user/message` is the user.** Each turn also records a
|
||||
plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a
|
||||
prompt.
|
||||
@@ -267,8 +251,8 @@ siblings; no other external CLI mode qualifies. Two edges are worth repeating he
|
||||
reports a turn that never ran, and leaves the prompt in a pane that was not yet
|
||||
accepting input. Wait for the composer (`❯`) instead.
|
||||
- **Wait on `stop`, not on the default signal set.** That set also carries `idle`,
|
||||
which for dsh is inferred from output stabilization; a dsh TUI that repaints
|
||||
rarely reads as idle mid-turn.
|
||||
which for every external CLI is inferred from output stabilization; a dsh TUI
|
||||
that repaints rarely reads as idle mid-turn.
|
||||
|
||||
## 6. The web UI as a tab
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
# Discord bridge
|
||||
|
||||
Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network.
|
||||
|
||||
> [!WARNING]
|
||||
> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password.
|
||||
|
||||
## What it does
|
||||
|
||||
- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex, OMP, OpenCode, Gemini, Antigravity, Pi, Grok and Copilot send no turn end, so they reach Discord through an error, a blocked respawn, a stopped crash loop or their own `codeman notify`; Codex's permission and question dialogs are also read off its screen.
|
||||
- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them.
|
||||
- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session.
|
||||
- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress.
|
||||
- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end.
|
||||
- Saves files you attach in a thread into that tab's working folder and tells the CLI the path. A CLI can send a file back with `codeman notify "message" --file <path>`.
|
||||
- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)).
|
||||
|
||||
## Setup
|
||||
|
||||
Do these once, in order. Never paste the bot token into a chat or a session.
|
||||
|
||||
### 1. Create a private server
|
||||
|
||||
1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**.
|
||||
2. Invite nobody and create no invite links.
|
||||
3. Create one text channel, for example `#codeman-cli`.
|
||||
|
||||
### 2. Copy the IDs
|
||||
|
||||
1. Open **User Settings**, **Advanced** and turn on **Developer Mode**.
|
||||
2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID.
|
||||
|
||||
### 3. Create the application
|
||||
|
||||
1. Go to <https://discord.com/developers/applications> and choose **New Application**.
|
||||
2. Name it `Codeman`.
|
||||
|
||||
### 4. Configure the bot
|
||||
|
||||
On the **Bot** tab:
|
||||
|
||||
1. Choose **Reset Token** and copy it. It is shown once.
|
||||
2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here.
|
||||
3. Leave **Requires OAuth2 Code Grant** off.
|
||||
4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off.
|
||||
|
||||
### 5. Invite the bot with minimal permissions
|
||||
|
||||
On **OAuth2**, **URL Generator**:
|
||||
|
||||
1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else.
|
||||
2. Tick only these permissions (Attach Files and Embed Links are optional: without them, files cannot be sent from a CLI and links show no preview):
|
||||
|
||||
| Permission | Why |
|
||||
| ------------------------ | ------------------------------------ |
|
||||
| View Channels | See the parent channel |
|
||||
| Send Messages | Post in the parent channel |
|
||||
| Send Messages in Threads | Post in each tab's thread |
|
||||
| Create Public Threads | Make a thread per tab |
|
||||
| Read Message History | Read your replies |
|
||||
| Add Reactions | Acknowledge a reply that was typed |
|
||||
| Attach Files | Send a file from a CLI to the thread |
|
||||
| Embed Links | Show previews for links in posts |
|
||||
|
||||
3. Open the generated URL, choose your private server and authorise.
|
||||
4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot.
|
||||
|
||||
The ticked boxes are the source of truth. As a cross-check, these eight permissions add up to the permissions integer `309237763136`.
|
||||
|
||||
### 6. Optional: allow deleting threads
|
||||
|
||||
Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads.
|
||||
|
||||
### 7. Never grant
|
||||
|
||||
Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission.
|
||||
|
||||
### 8. Configure Codeman
|
||||
|
||||
1. Open **Settings**, **Notifications**, then the **Discord** group.
|
||||
2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist.
|
||||
3. Choose the options below and press **Send test**. A test message should appear in the channel.
|
||||
|
||||
The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE.
|
||||
|
||||
### 9. Phone alerts
|
||||
|
||||
In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Values | Default | Effect |
|
||||
| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab |
|
||||
| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads |
|
||||
| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. A deleted tab's thread leaves the map, so this also archives or deletes threads that `onSessionDelete` kept or archived. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman |
|
||||
| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts |
|
||||
| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too |
|
||||
| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch |
|
||||
|
||||
## Telling the CLIs
|
||||
|
||||
A CLI only posts deliberately if it knows it can. While the bridge is on:
|
||||
|
||||
- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. This reaches local tabs only: remote (ssh) and Docker launches are built outside the CLI registry, and have no `codeman` command anyway.
|
||||
- **Every other CLI** is sent nothing: there is no silent way to tell it, and typing the note as a message would cost a model turn per tab. It can still run `codeman notify` if you tell it to.
|
||||
|
||||
The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH of every session from the installer or an npm install (a git checkout run without the installer has no `codeman` command) and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those.
|
||||
|
||||
## Files
|
||||
|
||||
- **Received:** files you attach to a reply from an allowed user are saved as `<working folder>/.codeman-discord/<message id>-<name>` (mode 0600) and the one-line reply the CLI sees ends with `[attached: <paths>]`. At most 5 files of 8 MB each; names are reduced to letters, digits, `.`, `_` and `-`; only Discord's own HTTPS addresses are fetched. Anything skipped is named in the thread. The folder ignores itself (it holds a `.gitignore` of `*`), so nothing in it is committed. Remote (ssh) tabs have no local folder, so no file is saved for them.
|
||||
- **Sent:** `codeman notify "message" --file <path>` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, and a file on the attachment blocklist (`.env`, keys under `.ssh`, Codeman's own state) is refused even there, which stops an upload of something sensitive by mistake. The agent runs as your user, so this is a guard against accidents, not a sandbox. Remote tabs cannot send files.
|
||||
- A downloaded file is untrusted content: only allowed users can send one, but a CLI that opens it is exposed to anything written inside it.
|
||||
|
||||
## Security model
|
||||
|
||||
- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped.
|
||||
- A reply is reduced to one line, stripped of control characters and capped in length before it is typed.
|
||||
- Replies are rate limited per session.
|
||||
- A Discord outage or a bad token never affects sessions. The bridge backs off, and the last error shows in Settings.
|
||||
|
||||
## Rotating and revoking
|
||||
|
||||
- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Send test**.
|
||||
- **Revoke:** kick the bot from the server or delete the application, then choose **Remove token** in Settings. Deleting `~/.codeman/discord.json` alone does not reach a running bridge, which keeps the token it loaded until Codeman restarts.
|
||||
- If the token is ever pasted somewhere it should not be, reset it straight away.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Test fails with 401 | The token is wrong or was reset. Paste the current one |
|
||||
| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID |
|
||||
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist |
|
||||
| Thread is not deleted | The bot lacks Manage Threads on the parent channel |
|
||||
| Nothing arrives on the phone | Check the Discord app's notification setting for the server |
|
||||
@@ -83,8 +83,6 @@ Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.jso
|
||||
|
||||
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
||||
|
||||
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||
@@ -121,13 +119,6 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
|
||||
|
||||
## Attach to a container you already run
|
||||
|
||||
⚠️ Two different things are called "adopted". This section is about a CONTAINER
|
||||
(`DockerCase.owned === false`). A tmux SESSION a human started inside a container is
|
||||
listed by `GET /api/mux/foreign?docker=1` and wrapped by `POST /api/sessions/adopt`, for
|
||||
which the container needs a Docker case but no adoption of its own. That one is
|
||||
`Session.isAdopted`, with the connection on `Session.adopt.docker` and `session.docker`
|
||||
undefined. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
|
||||
|
||||
The tab's **Attach to an existing container** toggle points a case at a container **you**
|
||||
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
|
||||
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
|
||||
@@ -209,7 +200,7 @@ The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, an
|
||||
## Notes & limits
|
||||
|
||||
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
|
||||
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead. A `$` in that override expands inside the container, never on this host, and a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal characters, so write the value out.
|
||||
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
|
||||
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
|
||||
|
||||
Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md).
|
||||
@@ -6,31 +6,18 @@ For the Compose configuration, environment settings, storage migration, and macv
|
||||
|
||||
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
||||
|
||||
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
|
||||
|
||||
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine or Docker Desktop with the Docker Compose v2 plugin, version 2.27.2 or newer
|
||||
- A reachable Docker daemon, usable by your account without sudo (on Linux, membership of the `docker` group)
|
||||
- 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
|
||||
|
||||
On Linux, clone the repository and run the start script:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
|
||||
bash docker/Start-Codeman.sh
|
||||
```
|
||||
|
||||
The first run checks Docker, Compose and the daemon, then asks for a data folder (default `~/codeman-docker`), a port (default 3000, or the next free one) and a password (Enter generates one, shows it on your terminal and keeps it in `docker/.env`). It writes `docker/.env` from `docker/.env.example`, builds the image, starts the container and waits until Codeman answers, then prints the URL and how to sign in. `--yes` takes every default without asking, and `--setup-only` writes `docker/.env` and stops so it can be reviewed first. The full description, including the options, is the Start section of the [Docker deployment guide](../docker/README.md#start).
|
||||
|
||||
The data folder is mounted at `/home/${CODEMAN_RUNTIME_USER}` in the container, so Codeman state and CLI credentials stay on the host outside Docker-managed volumes. On every start the script determines `PUID` and `PGID` from the owner of that folder, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned data folder is rejected so the runtime account cannot become UID 0.
|
||||
|
||||
To write `docker/.env` by hand instead (Unraid and other root-only hosts, or Compose without the script), copy the template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example value `/mnt/user/appdata/codeman` is an Unraid layout.
|
||||
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/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
|
||||
@@ -42,6 +29,12 @@ On PowerShell, use the following command instead.
|
||||
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. Naming the file with `-f` disables Compose's own discovery of `docker/docker-compose.override.yml`, so add a second `-f` for it when you keep one (see `docker/README.md`, Local customisation).
|
||||
|
||||
```sh
|
||||
@@ -50,7 +43,7 @@ docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -
|
||||
|
||||
The container starts as root, corrects the ownership of a bind source the daemon had to create, and drops to `PUID:PGID` with `setpriv` before Codeman starts; the capabilities that needs are declared in `docker/docker-compose.yaml` and named by the entrypoint when a compose file written elsewhere lacks them.
|
||||
|
||||
Open the URL the script printed (`http://localhost:3000` by default) and sign in with the username and password from `docker/.env`.
|
||||
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
|
||||
|
||||
## Operations
|
||||
|
||||
|
||||
@@ -163,14 +163,9 @@ respawn, Ralph, the orchestrator, search, and admin. Each route module carries a
|
||||
If the caller is an agent running _inside_ a Codeman session, install the packaged
|
||||
agent skill instead of teaching it these calls by hand: `skills/codeman` in the repo
|
||||
(`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install
|
||||
[--case <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
|
||||
automatic per-case injection on session create, into the directory each CLI scans for
|
||||
skills, on by default since 1.50.0). The skill carries the guard, the safety rules, and verified
|
||||
wait/orchestration recipes.
|
||||
|
||||
If the caller speaks the Model Context Protocol (Claude Desktop, Cursor, Codex, any MCP
|
||||
client), point it at `codeman mcp` instead: seven tools over these same endpoints,
|
||||
configured with `CODEMAN_API_URL`. See the README section "MCP server".
|
||||
[--case <name>]`, or the synced `agentSkillEnabled` App Setting for automatic
|
||||
per-case injection on Claude session create). The skill carries the guard, the
|
||||
safety rules, and verified wait/orchestration recipes.
|
||||
|
||||
The common ones:
|
||||
|
||||
@@ -183,7 +178,7 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"workingDir":"/home/me/project","mode":"claude"}'
|
||||
|
||||
# Send a prompt (it must end with \r: Enter is sent only
|
||||
# Send a prompt (single-line only, and it must end with \r: Enter is sent only
|
||||
# when the input contains a carriage return; without it the text sits on the
|
||||
# session's prompt unsubmitted)
|
||||
curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \
|
||||
@@ -220,18 +215,17 @@ whether your integration works, and the last one is what actually bites:
|
||||
to die in the field.
|
||||
- **`wait.timeoutMs`** is the timeout after server-side clamping (600 s ceiling by
|
||||
default). Read it rather than assuming you got what you asked for.
|
||||
- **`stop` only exists for `claude` (and `deepseek`) sessions, `blocked` also for
|
||||
`codex`** (read off its screen), and on a `shell` session even `idle` fires only once
|
||||
at startup, so send-and-wait there can only time out.
|
||||
- **`stop` and `blocked` only exist for `claude` sessions**, and on a `shell` session
|
||||
even `idle` fires only once at startup, so send-and-wait there can only time out.
|
||||
See the Gotchas below.
|
||||
|
||||
⚠️ **There is no readiness signal, and skipping readiness is the failure that looks
|
||||
like success.** A session reports `idle` before its CLI has spawned, and a `claude`
|
||||
worker in a brand-new case comes up on the CLI's **trust dialog**, which has a ❯
|
||||
prompt of its own. Prompt it at that moment and the text lands in the dialog,
|
||||
whose highlighted option is `No, exit`, so the prompt's `\r` quits the CLI. Codeman
|
||||
holds the session's `idle` while the dialog is on screen, which makes waiting for
|
||||
`idle` safe but still not a readiness signal. Wait for the pid, then wait for the
|
||||
prompt of its own. Prompt it at that moment and the text lands in the dialog, the
|
||||
`\r` does not get past it, and the session's startup `idle` lands inside the wait
|
||||
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'
|
||||
@@ -294,8 +288,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
fi
|
||||
|
||||
# 3. Send the prompt AND register the wait in one call, so the answer cannot be
|
||||
# the previous turn's idle state. It must end in \r (otherwise Enter is never
|
||||
# sent and this wait times out on a turn that never started).
|
||||
# the previous turn's idle state. Single line only, ending in \r (otherwise
|
||||
# Enter is never sent and this wait times out on a turn that never started).
|
||||
W=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize the failures\r","useMux":true,
|
||||
@@ -344,42 +338,6 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
|
||||
codeman status | list | attach <path> codeman doctor
|
||||
```
|
||||
|
||||
### Opening a session from your own page
|
||||
|
||||
To send someone from your page to one session, link to the dashboard with the
|
||||
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
|
||||
dashboard selects that tab when it loads. It also removes the fragment from its
|
||||
own URL, so a later link to the same session still counts as a change.
|
||||
|
||||
Keep reusing one named window to make later links fast:
|
||||
|
||||
```js
|
||||
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
|
||||
```
|
||||
|
||||
When that window already shows the dashboard, only the fragment differs. The
|
||||
browser therefore keeps the page loaded, and the dashboard switches tabs without
|
||||
reloading it. A session the window has shown before appears at once. A session
|
||||
your page has only just created may not be listed yet, so the dashboard waits
|
||||
for its `session:created` event and selects it then. That wait lasts at most 30
|
||||
seconds: a link whose session never appears (a closed session, a typo, or in
|
||||
multi-user mode another user's session) is dropped with a "Session not found"
|
||||
notice. Clicking another tab, going Home or opening a web tab also ends the
|
||||
wait, so a session that turns up later never takes the screen from the person.
|
||||
|
||||
When your page holds the window reference (`const win = window.open(...)`),
|
||||
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
|
||||
without a reload, but adds no history entry, so Back in the dashboard window
|
||||
does not turn into a silent no-op.
|
||||
|
||||
Following a link does not count as someone looking at the session, so it
|
||||
leaves the session's idle alert in place. The alert clears when the person
|
||||
clicks the tab or types into the session. A link to a session that is popped
|
||||
out into its own window asks that window to come forward, as clicking its tab does.
|
||||
|
||||
A link to `/session/<id>` opens a page showing that session alone, and that
|
||||
page loads from scratch for every link.
|
||||
|
||||
## Seam 4: Hooks
|
||||
|
||||
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
||||
@@ -414,16 +372,14 @@ Every one of these has cost somebody real time.
|
||||
shipped bugs more than once.
|
||||
- **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled
|
||||
simple-request CSRF, so it is deliberate. Send `application/json`.
|
||||
- **Prompts must end with `\r`.** The server splits your text and Enter into two
|
||||
separate tmux writes (Ink needs them apart), but it sends the Enter **only when
|
||||
the input contains a carriage return**. Without it your text sits on the prompt
|
||||
unsubmitted, which is the single most common "the wait endpoints don't work"
|
||||
report: the wait runs its full timeout on a turn that never started. With
|
||||
`useMux`, newlines inside the string are kept: the text goes in as one paste and
|
||||
the Enter after it submits all of it, so `"echo A\necho B\r"` runs both commands
|
||||
in a shell. The check that re-presses a stranded Enter cannot reliably recognise
|
||||
a multi-line paste, so a long multi-line prompt to a just-started agent is still
|
||||
safer in a file it is told to read.
|
||||
- **Prompts are single-line and must end with `\r`.** The server splits your text
|
||||
and Enter into two separate tmux writes (Ink needs them apart), but it sends the
|
||||
Enter **only when the input contains a carriage return**. Without it your text
|
||||
sits on the prompt unsubmitted, which is the single most common "the wait
|
||||
endpoints don't work" report: the wait runs its full timeout on a turn that never
|
||||
started. Newlines inside the string are stripped rather than rejected, so
|
||||
`"echo A\necho B\r"` runs the single joined command `echo Aecho B`: send one line
|
||||
per call.
|
||||
- **`wait-output`'s `from=now` is not "printed after you asked".** tmux repaints
|
||||
the visible screen on attach, on resize, and on any TUI redraw, and a repaint
|
||||
arrives as ordinary output, so text already on screen can satisfy a fresh wait.
|
||||
@@ -441,11 +397,9 @@ Every one of these has cost somebody real time.
|
||||
a multi-word match is unreliable there. Match one short space-free token, ideally
|
||||
one you printed yourself, and keep it out of the typed line (your own keystrokes
|
||||
echo into the stream).
|
||||
- **`stop` and `blocked` never fire for `shell`, `opencode`, `gemini`, `antigravity` or
|
||||
`pi` sessions, and `stop` never for `codex`.** They come from Claude Code hooks, which
|
||||
no other mode installs, so only `idle`, `working` and `exit` exist there. The exception
|
||||
is `blocked` on `codex`, which Codeman raises from the approval, question and trust
|
||||
dialogs it reads off codex's screen. Asking for them
|
||||
- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini`,
|
||||
`antigravity` or `pi` sessions.** They come from Claude Code hooks, which no other mode
|
||||
installs, so only `idle`, `working` and `exit` exist there. Asking for them
|
||||
explicitly is a `400`; omitting `until` is safe, since the server drops them from
|
||||
the default set and echoes what it actually waited on as `wait.until`. Even in
|
||||
`claude` mode, a Docker case needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for hooks to
|
||||
|
||||
@@ -114,19 +114,15 @@ history.
|
||||
## Remote SSH cases
|
||||
|
||||
`grok` mode is routed through an interactive login shell
|
||||
(`remoteLoginShellCommand('grok')`), because sshd's remote-command PATH does not include
|
||||
(`exec "$SHELL" -i -l -c 'grok'`), because sshd's remote-command PATH does not include
|
||||
`~/.grok/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected
|
||||
rather than silently ignored; use the per-host command override instead. For auth on
|
||||
the remote host, `grok login --device-auth` exists for exactly this.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook yet.** The registry entry declares no `workDetect` prompt
|
||||
marker or working line, which is what gives Codex, OpenCode, Gemini, Pi, OMP and
|
||||
Antigravity a screen check. So Codeman calls a new grok pane idle 3 seconds after launch, and after
|
||||
that it has only generic cues: Claude's `❯` marker, which grok does not print (see the
|
||||
cron item below), and a braille spinner glyph. Working and idle are unreliable for
|
||||
grok. Grok has a hooks system, so a Codeman hook POSTing to
|
||||
- **No idle/completion hook yet.** Idle detection falls back to output-stabilization
|
||||
like the other external CLIs. Grok has a hooks system, so a Codeman hook POSTing to
|
||||
`/api/hook-event` is the highest-value follow-up.
|
||||
- **No response viewer.** Grok writes ACP JSONL sessions under
|
||||
`~/.grok/sessions/<encoded-cwd>/<session-id>/updates.jsonl`; nothing reads them yet.
|
||||
|
||||
|
After Width: | Height: | Size: 859 KiB |
|
Before Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 9.7 MiB |
|
Before Width: | Height: | Size: 7.7 MiB |
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 774 KiB |
|
Before Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 7.1 MiB |
@@ -1,442 +0,0 @@
|
||||
# A model that runs on this Mac
|
||||
|
||||
Codeman can set up a coding model that runs on the Mac the server runs on: no account and
|
||||
no API key, and the session's prompts and files go to a model on this Mac rather than a
|
||||
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
|
||||
shows what fits this Mac, installs the choice and ends on a coding agent session that
|
||||
runs on it.
|
||||
|
||||
**Experimental.** The routes and their payloads may change in any release
|
||||
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
|
||||
silicon macOS only: anywhere else the window says why nothing is offered.
|
||||
|
||||
This page is the reference for how it works. The HTTP contract is in
|
||||
[api-reference.md](api-reference.md) ("Local model"). The rules that must not be broken are
|
||||
in [architecture-invariants.md](architecture-invariants.md#local-model-install).
|
||||
|
||||
## Which model fits which Mac
|
||||
|
||||
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
|
||||
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
|
||||
the catalogue answers today, before the runtime measured anything (the budget is then an
|
||||
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
|
||||
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
|
||||
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
|
||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
|
||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
|
||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||
|
||||
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
|
||||
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
|
||||
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
|
||||
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
|
||||
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
|
||||
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
|
||||
|
||||
### How much memory a model needs
|
||||
|
||||
The need of a model is its peak: the weights, what stays held per token of context, what the
|
||||
runner holds beside both, and what it takes for a moment while a long prompt is processed. The
|
||||
runtime runs a model with one of two runners, which spend memory differently, so every row
|
||||
names its runner (`runner`) and its figures follow that runner's rule:
|
||||
|
||||
- **llama.cpp** (GGUF builds, on Metal) allocates the K/V cache for the whole context when the
|
||||
model loads and does not grow after that. The runtime is started with
|
||||
`OLLAMA_KV_CACHE_TYPE=q8_0` (the cache in 8 bits, 17/32 of the default f16; the runtime's
|
||||
own documentation calls the loss very small), `OLLAMA_FLASH_ATTENTION=1` (an 8-bit cache
|
||||
needs it), `LLAMA_ARG_CACHE_RAM=1024` (llama-server keeps copies of earlier prompts, and
|
||||
this bounds them to 1 GiB) and `LLAMA_ARG_CTX_CHECKPOINTS=8` (llama-server keeps copies of
|
||||
the state it cannot roll back, two or three per request, up to 32 by default: 5 GB for the
|
||||
27B; 8 keeps the last few requests, which is what an agent's next request restores from). A
|
||||
row's figures are computed from its model's `config.json`: the K/V cache of its attention
|
||||
layers, its recurrent or sliding-window state, live and in each of the 8 checkpoints, 1.0
|
||||
GB for the runner and the 1 GiB prompt cache. A build with a multi-token-prediction layer
|
||||
(the two `-mtp` tags and `qwen3.8:27b-q8_0`) costs more: the runtime turns prediction on,
|
||||
so llama-server keeps that layer's cache in f16 and one more copy of the recurrent state for
|
||||
rollback per drafted token (the tag's `draft_num_predict`: 2 for the 35B-A3B, 4 for the
|
||||
27B). No peak: the buffers are allocated at load. The checkpoints and the
|
||||
prediction costs were read in the llama.cpp source the runtime bundles (b12111), not
|
||||
measured.
|
||||
- **MLX** grows its cache with the tokens actually used, keeps snapshots of the recurrent
|
||||
state, and may keep up to 8 GiB of snapshots of earlier prompts, which no setting bounds. A
|
||||
Mac always takes the MLX build of a tag that has one. Below 48 GB that 8 GiB does not fit
|
||||
beside a useful model, so the catalogue lists GGUF builds there.
|
||||
|
||||
Where every figure comes from:
|
||||
|
||||
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
|
||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
|
||||
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
|
||||
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
|
||||
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
|
||||
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
|
||||
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
|
||||
| `qwen3.8:27b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 2.95: a published run without prediction (a floor) |
|
||||
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
|
||||
|
||||
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
|
||||
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
|
||||
calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
|
||||
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
|
||||
below). The measured row's figures come from single long prompts, not a session of many
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
|
||||
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
|
||||
|
||||
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
|
||||
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
||||
`test/local-model-catalog.test.ts`, which recomputes every computed figure from it and fails
|
||||
for a row it does not know. Its answer for each memory size is then pinned in
|
||||
`test/local-model-recommend.test.ts`.
|
||||
|
||||
## What an install does, in order
|
||||
|
||||
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
|
||||
server then runs one job of five steps. The window polls `GET /api/local-model` once a
|
||||
second; the job runs in the server, so the window can be closed.
|
||||
|
||||
| Step | What it does |
|
||||
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `runtime` | Downloads the pinned runtime, compares its hash, unpacks it, chooses a port once, starts it, reads the measured memory budget and judges the choice again |
|
||||
| `model` | Downloads the model through the runtime |
|
||||
| `verify` | Loads the model and checks it: one tiny chat, the memory and context the runtime reports, one tool call |
|
||||
| `agent` | Installs the coding agent with its stock install command. Left out when the agent is installed |
|
||||
| `connect` | Saves one model endpoint and switches the custom model endpoints setting on |
|
||||
|
||||
Every step can be repeated: Resume and "Try again" enter the step the job stopped in.
|
||||
|
||||
### The runtime
|
||||
|
||||
- **One pinned build.** Ollama 0.40.15 for macOS, a compiled constant
|
||||
(`OLLAMA_RUNTIME_PIN` in `src/local-model/runtime.ts`): the URL, the size (185,971,671
|
||||
bytes) and the sha256. No file, setting, environment variable or request can change which
|
||||
binary is downloaded.
|
||||
- **Download.** Streamed to `*.partial` while it is hashed. A cut stream is continued with a
|
||||
Range request, at most three times. The hash is compared before anything is unpacked, and
|
||||
a mismatch deletes the file. Unpacking is `/usr/bin/tar` into a temporary folder, then one
|
||||
rename.
|
||||
- **No administrator rights.** Everything lives in the data directory.
|
||||
- **Supervised by Codeman.** The runtime is a non-detached child of the server: started at
|
||||
boot once a model is installed and not stopped, restarted at most three times after a
|
||||
crash, sent SIGTERM at shutdown. It is not a login item: it stops with Codeman, and a
|
||||
leftover from a server that was killed is taken back at the next start (see below).
|
||||
- **Its environment is an allowlist.** `PATH`, `TMPDIR`, `LANG` and the proxy variables are
|
||||
copied from the server. `HOME` is a private folder. Exactly these are set:
|
||||
`OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`,
|
||||
`OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`,
|
||||
`OLLAMA_MAX_LOADED_MODELS=1`, and four memory settings of the llama.cpp runner:
|
||||
`OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_FLASH_ATTENTION=1`, `LLAMA_ARG_CACHE_RAM=1024` and
|
||||
`LLAMA_ARG_CTX_CHECKPOINTS=8` (see "How much memory a model needs" above). No `CODEMAN_*`
|
||||
variable and none of the user's own `OLLAMA_*` or `LLAMA_ARG_*` settings reach it.
|
||||
- **The port is chosen once**, by binding, from 11435 across 20 ports, and stored. 11434 is
|
||||
left alone: it is the default of a runtime the user may run themselves, which is never
|
||||
touched.
|
||||
- **An orphan is recognized.** The pid and the port are stored. At boot a listener on the
|
||||
port that answers `/api/version` and whose pid runs a binary under Codeman's runtime folder
|
||||
is Codeman's own leftover from a server that was killed: it is adopted or replaced. Any
|
||||
other listener is "Port 11435 is in use by another program", with "Use another port" as
|
||||
the way out.
|
||||
|
||||
### The memory budget is measured
|
||||
|
||||
Before the runtime exists the budget is an estimate from the installed memory. Right after
|
||||
it starts, the runtime prints the device total in its log line "inference compute"
|
||||
(`total="37.4 GiB"` on a 48 GB MacBook). Codeman reads that line, stores the figure and
|
||||
judges the chosen model against it before a byte of the model is downloaded. A model that no
|
||||
longer fits ends the job there with "does not fit" and the next model to try. From then on
|
||||
the window shows the measured budget and the word "estimated" is gone.
|
||||
|
||||
The need of a model is its peak (see "How much memory a model needs" above).
|
||||
|
||||
### The model download
|
||||
|
||||
`POST /api/pull` with `stream: true`. A 23 GB pull sends about 1.4 million progress lines,
|
||||
so the stream is parsed piece by piece and never kept. Cancel aborts the request, which
|
||||
stops the download. Resume pulls again and the runtime continues its partial files. An error
|
||||
line or a cut stream is retried up to five times. Once the pull reported its total, every
|
||||
size shown for the model is that total.
|
||||
|
||||
### The check
|
||||
|
||||
After the pull, in this order:
|
||||
|
||||
1. One tiny chat with `reasoning_effort: "none"`. It loads the model.
|
||||
2. `GET /api/ps` must show the whole model on the GPU (`size_vram` equal to `size`) at the
|
||||
chosen context length. For a llama.cpp build both figures are the buffers llama-server
|
||||
reports allocating (weights, K/V cache, compute), so this proves the whole load is on the
|
||||
GPU. For an MLX build it proves only the weights: the runtime books an MLX model at its
|
||||
weights (runtime issue 19417), so for those rows the catalogue's figures are all there is.
|
||||
3. One tool probe on `/v1/chat/completions` (a `tools` array, `reasoning_effort: "none"`,
|
||||
`max_tokens` 1024). It passes on a tool call whose arguments parse.
|
||||
|
||||
A failure is a state with a code and one next step, never a bare "failed": `does-not-fit`
|
||||
and `no-tool-call` offer the next model, `probe-unfinished` (the token limit ended the
|
||||
probe, so it proves nothing) and `load-failed` offer "Try again". The digest the runtime
|
||||
reports for the model is stored when the check passed.
|
||||
|
||||
### The coding agent
|
||||
|
||||
When the chosen coding agent is not installed, the job installs it with the CLI registry's
|
||||
own stock install command, through the same runner as App Settings > CLIs
|
||||
(`src/cli-install-runner.ts`). The rules:
|
||||
|
||||
- Only a CLI that is a harness row of the local model catalogue.
|
||||
- Only the **compiled** stock command. A `~/.codeman/clis.json` override cannot change what
|
||||
runs here.
|
||||
- Only after the person's consent click, on a screen that showed the literal command.
|
||||
- Admin only in multi-user mode, and written to the admin audit log (`cli_install`, with
|
||||
`via: "local-model"`).
|
||||
- One installer per CLI at a time, shared with App Settings: while one runs, the other
|
||||
answers that it is busy.
|
||||
- Afterwards the cached lookups of the CLI's binary are forgotten and the binary is looked
|
||||
for again. Still missing is the failure `harness-failed`, with the installer's last lines.
|
||||
|
||||
This step does not need the `cliManagementEnabled` setting. That setting gates the general
|
||||
CLI management surface; this path can only ever run one vetted command for one catalogue
|
||||
row.
|
||||
|
||||
### The connection
|
||||
|
||||
Two writes outside the local model folder, both undone by Remove:
|
||||
|
||||
1. One record in `custom-model-hosts.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "local-ollama",
|
||||
"label": "This Mac",
|
||||
"baseUrl": "http://127.0.0.1:11435",
|
||||
"authStyle": "bearer",
|
||||
"models": ["qwen3.6:35b-a3b"],
|
||||
"defaultModelId": "qwen3.6:35b-a3b",
|
||||
"defaultContextLength": 131072
|
||||
}
|
||||
```
|
||||
|
||||
`defaultContextLength` is the chosen context: the runtime's model listing states none, so
|
||||
this field is what reaches the coding agent as its context limit.
|
||||
|
||||
2. `customModelEndpointsEnabled: true` in `settings.json`. The value it had before is kept
|
||||
in the state file.
|
||||
|
||||
The id `local-ollama` is reserved. A new endpoint cannot take it, and a record with that id
|
||||
that this feature did not write is never overwritten: the install says so before anything is
|
||||
downloaded. While the record is the managed one it cannot be edited or deleted in App
|
||||
Settings and the five-minute discovery sweep leaves it alone, so its model list stays the
|
||||
one model the install checked.
|
||||
|
||||
Switching the setting on also starts that five-minute sweep for every other saved endpoint.
|
||||
|
||||
## Starting a session
|
||||
|
||||
"Start a session" starts the coding agent on the endpoint `local-ollama` through the
|
||||
existing custom model launch. Before a coding agent is STARTED on that endpoint the server
|
||||
checks, in this order:
|
||||
|
||||
- the runtime child Codeman started is alive (whatever else answers on the port is not
|
||||
ours);
|
||||
- the digest the runtime reports for the installed model is the one stored at install time;
|
||||
- the model asked for is the one that was installed and checked.
|
||||
|
||||
That check runs wherever a new agent process comes to be on the endpoint: a session created
|
||||
with the model, a model applied to a running session (which restarts its agent), the attach
|
||||
that starts an agent in a session that has none or whose agent exited, and the rebuild of a
|
||||
session after a reboot (that entry is skipped with "the local model is not ready" and stays
|
||||
on offer). An agent that is already running when Codeman restarts is not started by
|
||||
Codeman, so it is not asked: it keeps running on its port, see below.
|
||||
|
||||
A different digest refuses the launch: "The model on this Mac changed since it was
|
||||
installed. Remove it and install it again." The digest is also read again at boot and when
|
||||
the runtime is started.
|
||||
|
||||
### From a Claude Code session: cloud plus local
|
||||
|
||||
The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac))
|
||||
lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and
|
||||
reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and
|
||||
files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what
|
||||
is installed or why nothing can run; `spawn_worker <name> local` starts the runtime when it was
|
||||
stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same
|
||||
quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its
|
||||
answer back from a file it writes in its own folder. The skill never installs a model and never
|
||||
answers a confirmation, the runtime still answers one request at a time, and while a worker it
|
||||
opened is live, Stop and Remove are refused until it is closed.
|
||||
|
||||
## Stop, Start, Remove
|
||||
|
||||
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
|
||||
that.
|
||||
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
|
||||
only when this feature switched it on and no other endpoint is saved, and deletes the
|
||||
runtime and its private home. The downloaded model is deleted only when asked. The coding
|
||||
agent stays. While a live session uses the model, Remove answers with the session names
|
||||
instead.
|
||||
|
||||
## Where everything is
|
||||
|
||||
All under the data directory (`~/.codeman`, or the instance's own):
|
||||
|
||||
```
|
||||
local-model.json the state file (mode 0600)
|
||||
local-model/
|
||||
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
|
||||
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
|
||||
models/ the model store
|
||||
home/ the runtime's private HOME (its key pair is here)
|
||||
```
|
||||
|
||||
Nothing is written or deleted outside `local-model/` except the state file beside it and the
|
||||
two writes of the connection. The state file holds no path, so it can never point a delete
|
||||
somewhere else. The user's own `~/.ollama` is never created, read or removed.
|
||||
|
||||
`local-model.json` is not part of a config backup. `custom-model-hosts.json` and
|
||||
`settings.json` are, so restoring an older backup can take the endpoint record away while
|
||||
the model stays installed. The record is put back the next time Codeman starts (the setting
|
||||
is left as the backup had it). A restore can also bring a record back after a Remove: it
|
||||
then points at a port nothing listens on, is an ordinary endpoint again, and can be deleted
|
||||
in App Settings.
|
||||
|
||||
## The job's states
|
||||
|
||||
| State | A server restart | Cancel | What can be done |
|
||||
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
|
||||
| `idle` | stays idle | refused | install |
|
||||
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
|
||||
| `paused` | stays paused | refused | resume, install, remove |
|
||||
| `failed` | stays failed, same error | refused | resume, install, remove |
|
||||
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
|
||||
|
||||
One job at a time. A job a server restart interrupted comes back paused and never continues
|
||||
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
|
||||
the installer's process group); in the last step it is honoured before the first write, and
|
||||
once the two writes began they finish.
|
||||
|
||||
## On a Mac without developer tools
|
||||
|
||||
Coding agents call `git` and `python3`. On a Mac without Xcode or the Command Line Tools
|
||||
those are stubs that open macOS's "install the command line developer tools" window. So the
|
||||
window says one sentence wherever the person commits or finishes, and only when
|
||||
`/usr/bin/xcode-select -p` fails:
|
||||
|
||||
> Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer
|
||||
> to install them the first time an agent needs one.
|
||||
|
||||
Codeman itself only ever runs `/usr/bin/xcode-select -p` for this, bounded, and never `git`
|
||||
or `python3`.
|
||||
|
||||
## What to know before relying on it
|
||||
|
||||
- **The runtime's port has no login.** Any program on this Mac, and any web page served from
|
||||
`localhost`, can use the model server and change its models. That is why the endpoint
|
||||
record lists one model, why the sweep leaves it alone, and why the digest is compared
|
||||
before a session starts.
|
||||
- **A Codeman restart stops the runtime.** A session that is mid-answer on the local model
|
||||
loses that answer. The runtime is started again with the server, and the first request
|
||||
loads the model again (seconds).
|
||||
- **An open session keeps calling the runtime's port.** The coding agent was given the
|
||||
address when it started. While the runtime is stopped, or for the seconds of a Codeman
|
||||
restart, its requests go to a port nothing of ours holds, and any program on this Mac could
|
||||
bind that port and answer them. That is why Stop and "Use another port" are refused while
|
||||
a session uses the model: close those sessions first.
|
||||
- **The runtime does not enforce the context length.** A prompt longer than the context was
|
||||
accepted in testing. Do not rely on the runtime to stop one.
|
||||
- **Most memory figures are calculated, not measured.** One model was run on one Mac; the
|
||||
others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
||||
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
|
||||
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
||||
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
||||
unloads it.
|
||||
|
||||
## Keeping the catalogue current
|
||||
|
||||
The catalogue (`src/config/local-model-catalog.ts`) and the runtime pin are compiled into
|
||||
Codeman on purpose: no file or download decides what is recommended or run. What the
|
||||
registry serves can still move away from them: a tag is pushed again or removed, the build
|
||||
a Mac downloads changes size, Ollama ships a new version. The workflow
|
||||
`.github/workflows/local-model-catalog.yml` looks for that every Monday at 06:00 UTC. It
|
||||
runs `scripts/check-local-model-catalog.mjs`, which also runs locally:
|
||||
`npm run check:local-model-catalog` prints the report and exits 0 (clean), 1 (drift) or 2
|
||||
(the check itself failed).
|
||||
|
||||
For every model row it checks:
|
||||
|
||||
- **The tag still exists** in the registry.
|
||||
- **The size of what a Mac downloads** is within 0.1% of the row's `bytes`. A tag can be a
|
||||
manifest list with one build per runner, and Ollama on Apple silicon takes the `mlx` build
|
||||
when there is one, else `llamacpp`, else `ggml`. So the check asks for the tag the way
|
||||
Ollama asks, picks the build a Mac picks and sizes it the way a pull counts it (every layer
|
||||
plus the config). The plain manifest other tools get for the same tag is the `llamacpp`
|
||||
build, which is not what a Mac downloads.
|
||||
- **The runner of that build**, once a row names its `runner`: a Mac still pulls an `mlx`
|
||||
build for an `mlx` row and a GGUF build (`llamacpp`, or a manifest that names no runner)
|
||||
for a `llamacpp` row. A row's memory figures follow its runner, so a tag that gains or
|
||||
loses an mlx build is drift even at about the same size.
|
||||
- **The build a Mac pulls runs on the pinned runtime**: its config declares no newer
|
||||
Ollama (`requires`) than `OLLAMA_RUNTIME_PIN`.
|
||||
- **The pinned digest**, once a row carries one: a Mac would still pull that manifest. The
|
||||
field is `digest` and its value is what `/api/tags` reports after the pull. The catalogue
|
||||
schema does not have it yet: `LocalModelEntrySchema` must gain an optional `digest` under
|
||||
exactly that name, or the check never compares it. Until then the report lists today's
|
||||
digests, rows with measured figures first, because without a digest a rebuild of about the
|
||||
same size goes unnoticed (a switch between the mlx and the gguf build does not: their sizes
|
||||
differ by 2% or more). Pinning digests is a decision, not a quick fix: today the catalogue
|
||||
holds no digest on purpose, and `test/local-model-catalog.test.ts` ("holds no download
|
||||
data") refuses a `sha256` anywhere in it, so that rule changes with it.
|
||||
|
||||
For the runtime pin it checks whether a newer Ollama release exists, and whether the pinned
|
||||
`ollama-darwin.tgz` is still on GitHub with the pinned size and sha256.
|
||||
|
||||
It reads manifests and configs only, never a model layer. It does not check the memory
|
||||
figures, the speeds or whether a coding agent works with a model: no manifest says anything
|
||||
about those, and they change only when somebody measures on a Mac.
|
||||
|
||||
**The issue.** Drift opens one issue, "Local model catalogue: weekly check", or, while that
|
||||
issue is open, replaces its description with the new report and adds a comment. A clean week
|
||||
comments on the open issue and does nothing when there is none. A check that failed (the
|
||||
network, a rate limit, an answer that did not parse) fails the run and leaves the issue
|
||||
alone; what it found is in the run's summary. The schedule runs the copy of the workflow on
|
||||
`master`, and so does `gh workflow run local-model-catalog.yml`.
|
||||
|
||||
**What a maintainer does with it**, in a pull request each time:
|
||||
|
||||
- **Size drift or a changed digest**: correct the row. When the build itself changed (another
|
||||
runner or quantization), its memory figures and speed no longer describe it: mark the row
|
||||
`estimated` until somebody measures it again.
|
||||
- **A tag that is gone, or a build that needs a newer runtime**: replace or drop the row, or
|
||||
move the runtime pin first.
|
||||
- **A newer Ollama**: read its release notes, then move `OLLAMA_RUNTIME_PIN` (version, URL,
|
||||
size and sha256, checked against the release's `sha256sum.txt`) and run an install on a
|
||||
Mac before merging.
|
||||
- **A pinned archive that changed**: a security question first. Every install refuses it at
|
||||
the hash compare, so nobody gets it, but find out why before moving the pin.
|
||||
|
||||
Close the issue once the fix is merged. A catalogue or pin change reaches users only in a
|
||||
release: an installed Codeman keeps the catalogue it was built with until it is updated.
|
||||
|
||||
## Source
|
||||
|
||||
| What | Where |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Hardware probe, catalogue, recommendation | `src/local-model/hardware.ts`, `src/config/local-model-catalog.ts`, `src/local-model/recommend.ts` |
|
||||
| Runtime pin, download, environment, supervisor | `src/local-model/runtime.ts` |
|
||||
| Runtime HTTP client, pull parser, checks | `src/local-model/ollama-client.ts` |
|
||||
| Developer tools check | `src/local-model/developer-tools.ts` |
|
||||
| Job (state table, reducer, view, service) | `src/local-model/install-job.ts` |
|
||||
| State file | `src/local-model/state-store.ts` |
|
||||
| Connection and its undoing | `src/local-model/connect.ts` |
|
||||
| Paths | `src/local-model/paths.ts` |
|
||||
| Routes | `src/web/routes/local-model-routes.ts` |
|
||||
| Shared CLI install runner | `src/cli-install-runner.ts` |
|
||||
| Serialized writer of the endpoint store | `src/custom-model-hosts-writer.ts` |
|
||||
| Weekly catalogue check | `scripts/check-local-model-catalog.mjs`, `.github/workflows/local-model-catalog.yml` |
|
||||
@@ -1,70 +0,0 @@
|
||||
# Media assets: videos and GIFs
|
||||
|
||||
Codeman produces a lot of video: README GIFs, website loops, release-note clips, promo videos and their drafts, raw screen captures. This page says where each kind lives and which of them belong in git.
|
||||
|
||||
## The rule
|
||||
|
||||
**Only media that a public page embeds is pushed to GitHub. Everything else stays on the maintainer's machine, in the local media library.**
|
||||
|
||||
| Kind | Where it lives | On GitHub? |
|
||||
| --- | --- | --- |
|
||||
| GIFs the README, the wiki or a published article embeds | `docs/images/` on `master` | yes |
|
||||
| Release-note and media-kit clips | the orphan `design-assets` branch | yes, but never on `master` |
|
||||
| getcodeman.com videos and GIFs | `pr/website/assets/` (local, deployed to Cloudflare Pages) | no |
|
||||
| Masters, promo videos, drafts, shorts, feature clips, raw captures | `pr/media/` (the local media library) | no |
|
||||
| Remotion render inputs for the promo videos | `scripts/remotion/public/` on the unpushed promo branch | no |
|
||||
|
||||
`.gitignore` enforces it: `*.mp4`, `*.webm`, `*.mov`, `*.m4v`, `*.mkv`, `*.avi` and `*.gif` are ignored everywhere except `docs/images/*.gif`, plus `scripts/remotion/public/`, whose render inputs the promo branch tracks. A video that really must be committed needs `git add -f`, which makes the exception visible in review.
|
||||
|
||||
A GIF that leaves `docs/images/` stays in git history, so removing it does not shrink a clone. The rule keeps new weight out and the tree honest about what is actually used.
|
||||
|
||||
## Tracked GIFs (`docs/images/`)
|
||||
|
||||
| File | Embedded in | Shows |
|
||||
| --- | --- | --- |
|
||||
| `tiles-crt-stats-1600-20261010.gif` | README hero (en + zh-CN) | six live agents in the tile grid, powering on and off with the CRT animation, header stats on |
|
||||
| `tile-grid-20261009.gif` | README, Tile Grid section | the Tiles button opening six live sessions side by side, and closing them |
|
||||
| `zerolag-demo-20260728.gif` | README, wiki `Input-And-Voice`, `packages/xterm-zerolag-input/README.md`, CHANGELOG, getcodeman.com docs | instant local echo next to slow server echo, on two phones |
|
||||
| `codeman-tab-states-20261010.gif` | README, Tab Alerts section | annotated tab states: working, blocked on a question, idle |
|
||||
| `codeman-skill-crt-20261010.gif` | README, agent skill section; wiki `Codeman-Skill` (getcodeman.com docs show the MP4 cut) | one prompt to Claude Code spawning and driving two worker sessions through the skill |
|
||||
| `codeman-skill-grid-20261015.gif` | README, agent skill section (Step 2); wiki `Codeman-Skill` (getcodeman.com docs show the MP4 cut) | one prompt to a Claude Code lead, five Claude Code workers powering on as tiles in the grid |
|
||||
| `codeman-skill-tabs-20261015.gif` | README, agent skill section (Step 3); wiki `Codeman-Skill` (getcodeman.com docs show the MP4 cut) | one prompt to a Claude Code lead, eight worker tabs powering on with lineage lines, then a click into a live worker |
|
||||
| `subagent-demo-20260724.gif` | the "mission control" article on dev.to, Hashnode and Medium (hotlinked from `master`) | subagent windows fanning out |
|
||||
|
||||
Before removing one, search for hotlinks: `README*.md`, `docs/wiki/`, `packages/*/README.md`, `CHANGELOG.md`, the site (`pr/website/`), and published articles, which link `raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/<file>` and break silently.
|
||||
|
||||
On 2026-10-13, seven GIFs that no page embedded left `docs/images/` and moved into the library: `codeman-demo.gif`, `codeman-skill-20261010.gif`, `subagent-demo-new.gif`, `subagent-demo-opt.gif`, `tab-alerts-glow-20260815.gif`, `tiles-crt-20261010.gif`, `tiles-crt-stats-20261010.gif`.
|
||||
|
||||
### Adding or replacing a README GIF
|
||||
|
||||
1. Keep the master (the lossless MP4 or the full-size GIF) in `pr/media/clips/<feature>/`, or in the project folder it came from.
|
||||
2. Commit only the size the README shows, named `<feature>-<YYYYMMDD>.gif`, and keep it lean: the current ones are 0.8 to 10 MB.
|
||||
3. A replaced GIF leaves `docs/images/` in the same commit, unless something outside the repo hotlinks it (see above).
|
||||
|
||||
## Website media (getcodeman.com)
|
||||
|
||||
The site is built and deployed from `pr/website/` (gitignored) with `pr/deploy-site.sh`. Its `/docs` pages are generated from `docs/wiki/`, and a wiki GIF that has an MP4 cut of the same run on the site is shown there as that video (the README and the GitHub wiki keep the GIF, since GitHub cannot autoplay inline video). Its videos and GIFs sit in `pr/website/assets/` because that directory is what gets uploaded: they are web cuts (1280 to 1600 px wide, high CRF, posters next to them) derived from masters in the library. `pr/site-notes.md` records how each one was cut and from which master. A few superseded files stay deployed on purpose, so links that point at them keep resolving.
|
||||
|
||||
## Release notes and the media kit (`design-assets` branch)
|
||||
|
||||
GitHub releases and the Announcements discussions embed clips from the orphan `design-assets` branch (`release-1.40/`, `release-1.41/`, `media-kit-2026-10/`). They are referenced by a commit-pinned raw URL, so the branch can change later without breaking old notes, and none of it ever touches `master`.
|
||||
|
||||
## The local media library (`pr/media/`)
|
||||
|
||||
`pr/` is gitignored, so the library is never pushed. It holds every master, every finished promo cut and every superseded draft in one tree:
|
||||
|
||||
```
|
||||
pr/media/
|
||||
README.md curated guide: what each collection is, which cut is current, where it is published
|
||||
INVENTORY.md generated list of every file (size, length, resolution, fps, date, published-or-not)
|
||||
inventory.mjs regenerates INVENTORY.md: node pr/media/inventory.mjs
|
||||
videos/<production>/ finished promo videos; superseded renders in drafts/
|
||||
shorts/<episode>/ per-feature cuts of a promo; older sets in drafts/
|
||||
clips/<feature>/ feature loops and GIF masters (README / site / release clips start here)
|
||||
crt-gifs/ CRT tile-animation loops
|
||||
files-one-click-20261011/ the Files panel loop project (own README)
|
||||
screenshots-hq/ high-res stills and their capture masters
|
||||
raw-captures/ unedited screen recordings, safe to prune
|
||||
```
|
||||
|
||||
Two working directories keep files the library only archives, because tools read them from fixed paths: `pr/website/assets/` (the deploy upload) and the promo worktree's `scripts/remotion/public/` (Remotion's `staticFile()` inputs). Renders still land in `scripts/remotion/out/` and are filed into `pr/media/videos/<production>/` once checked. A finished render never goes to `tmp/`.
|
||||
@@ -105,7 +105,7 @@ Keep the existing single-user branch untouched. Add a parallel multi-user branch
|
||||
1. **Credential check**: Basic header parsed into `username:password`, verified against the user store (scrypt + `timingSafeEqual`). Disabled users fail closed.
|
||||
2. **Cookie sessions**: same `codeman_session` cookie and `StaleExpirationMap`, but `AuthSessionRecord` gains `username` and `role`. All existing TTL/sliding/eviction logic reused. Eviction cap becomes per-user aware (evict oldest _of that user_ first) so one user cannot flush everyone's sessions by logging in 100 times.
|
||||
3. **Request identity**: decorate `req.authUser = { username, role }` (Fastify decorateRequest). In single-user mode `req.authUser` is `{ username: 'admin', role: 'admin' }` when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path.
|
||||
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so one flaky user behind a NAT cannot lock out the rest. As built, the per-username bucket is checked after the verify: it answers 429 to failed guesses once full and never denies a correct password, so it does not cap verified guesses. What caps them is a per-(username, IP) bucket checked before the verify (10 per pair), beside a per-IP pre-gate of 50 (#684).
|
||||
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest.
|
||||
5. **`mustChangePassword` gate**: when set, every API request except `GET /api/me`, `POST /api/me/password`, and static assets returns 403 with `errorCode: 'PASSWORD_CHANGE_REQUIRED'`; the frontend intercepts that code and shows the change-password modal.
|
||||
6. **Password change vs Basic-auth caching**: browsers cache Basic credentials. After a password change we revoke all of that user's cookie sessions; the next request falls to Basic with stale creds, gets 401, and the browser re-prompts. Acceptable for v1; a proper login form is Phase 6 (see 15).
|
||||
7. **Unchanged**: hook-secret loopback bypass (hooks authenticate the _instance_, not a user; the event maps to a session which has an owner), host guard, Origin/CSRF guard, security headers.
|
||||
@@ -158,11 +158,11 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
|
||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
|
||||
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only |
|
||||
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
|
||||
|
||||
## 7. SSE Event Filtering
|
||||
@@ -228,7 +228,7 @@ These operate directly on `users.json` via `user-store.ts` (no server needed), h
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||
| Instance isolation | `users.json` and the audit log via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ shared nor seeded.
|
||||
## Remote SSH cases
|
||||
|
||||
`omp` mode is routed through an interactive login shell
|
||||
(`remoteLoginShellCommand('omp')`), because sshd's remote-command PATH does not
|
||||
(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not
|
||||
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
|
||||
rejected rather than silently ignored; use the per-host command override instead.
|
||||
|
||||
@@ -163,13 +163,9 @@ remote user's home, so resolving locally would pin a stranger's id. See
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook.** Idle detection reads the screen instead: the
|
||||
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
|
||||
the idle check, and the status bar's spinner plus elapsed time (` ⠼ 14s > ⬢ …`)
|
||||
or the `⎋ Working…` row as the working line, measured on omp 18.8.6 and 18.0.11.
|
||||
Without it an omp session that had started a turn never left `busy`. If omp ever
|
||||
ships a hooks system, a Codeman hook POSTing to `/api/hook-event` would still be
|
||||
the highest-value follow-up.
|
||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
||||
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
|
||||
POSTing to `/api/hook-event` would be the highest-value follow-up.
|
||||
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
|
||||
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
|
||||
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||
|
||||
@@ -920,8 +920,6 @@ const mode = cmd.includes('opencode') ? 'opencode' : 'claude';
|
||||
|
||||
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
|
||||
|
||||
> **Update 2026-10-09 (working/idle shipped, from measured data):** the registry entry now declares `capabilities.workDetect` for opencode, measured on a live opencode 1.3.0 pane (pane captures every 250-300 ms through real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream). Every composer row starts with a `┃` bar, which arms the shared screen-probed idle check; a running turn puts an 8-cell knight-rider spinner (`⬝■■■■■■⬝ esc interrupt`) at the head of the footer row, redrawn about every 40 ms, and `[⬝■]{8}` is the working line. The label is not the anchor: tmux ships `esc` and `interrupt` as separate words joined by cursor moves, and below about 45 columns the footer wraps it. At rest the TUI is silent (no cursor or timer redraws), and a pending permission prompt replaces the composer and stops the spinner, so it reads as idle. Before this, a turn that ran a tool latched the session `busy` for good (the tool row's braille spinner tripped the generic spinner detector, and nothing ever armed the idle check). Token parsing and the ANSI content filter remain deferred.
|
||||
|
||||
### Goal
|
||||
Detect OpenCode's state from terminal output (idle, working, ready).
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignme
|
||||
session.sendInput(task.prompt)
|
||||
│
|
||||
▼
|
||||
writeViaMux() → tmux paste-buffer -p (prompt as one bracketed paste) + Enter
|
||||
writeViaMux() → tmux send-keys -l "prompt..." + Enter
|
||||
│
|
||||
▼
|
||||
Claude CLI receives prompt, executes, outputs results
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
|
||||
Tracking issue: [#206 "Plans to support pi.dev?"](https://github.com/Ark0N/Codeman/issues/206)
|
||||
|
||||
**SUPERSEDED in part (PR #771, 2026-11):** what this record says about pi's default screen and
|
||||
the strips no longer holds. Fullscreen became pi's default in 1.0.0, and pi now takes the mouse
|
||||
strip (`altScreen: 'strip-mux-and-mouse'`). Each affected passage carries a SUPERSEDED note;
|
||||
current behaviour is in `docs/pi-integration.md` and the "Pi is the same shape" paragraph of
|
||||
`docs/architecture-invariants.md`.
|
||||
|
||||
Status: **IMPLEMENTED 2026-08-13** (see `docs/pi-integration.md` for the user-facing
|
||||
guide). Everything below is the design record; the open questions were resolved
|
||||
empirically against pi 0.84.1 and the answers are recorded inline as **RESULT**
|
||||
@@ -35,7 +29,7 @@ against the upstream docs in `packages/coding-agent/docs/`.
|
||||
| Config dir | `~/.pi/agent` (override: `PI_CODING_AGENT_DIR`). Holds `auth.json`, `trust.json`, `settings.json`, `models.json` (user-defined providers), `models-store.json` (cached catalogs), `keybindings.json`, `extensions/`, `skills/`, `prompts/`, `themes/`, `AGENTS.md`, `SYSTEM.md`, and the package trees `npm/` + `git/` |
|
||||
| Sessions | `~/.pi/agent/sessions/--<cwd with / replaced by ->--/<timestamp>_<uuid>.jsonl`, tree-structured (`id`/`parentId`), format v3. Overrides: `PI_CODING_AGENT_SESSION_DIR`, `--session-dir` |
|
||||
| Credentials | `~/.pi/agent/auth.json` (OAuth subscriptions + API keys, auto-refresh), plus ~34 provider env vars with **no common prefix**. 0.84.1 adds `pi auth check` (auth preflight with optional credential output) |
|
||||
| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0, and `tuiMode: regular` keeps the main screen |
|
||||
| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode |
|
||||
| Providers | 15+ (Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, xAI, OpenRouter, Copilot, Baseten since 0.84.0, ...). OAuth subscription login via `/login` for six: ChatGPT Plus/Pro, Claude Pro/Max, GitHub Copilot, xAI, OpenRouter, Radius |
|
||||
| Permission model | **No permission prompts at all.** No built-in sandbox, no MCP (none planned), no sub-agents, no plan mode, no to-dos, no background bash. Tools run with the user's own permissions |
|
||||
| Trust model | "Project trust" gates **loading** of project-local `.pi/` config/extensions/skills and **installing missing project packages**, not tool execution. Triggered only when the cwd (or an ancestor) contains `.pi/settings.json`, `.pi/extensions\|skills\|prompts\|themes`, `.pi/SYSTEM.md`/`.pi/APPEND_SYSTEM.md`, or `.agents/skills`; a bare `.pi/` directory does NOT prompt. Global `defaultProjectTrust`: `ask` (default) / `always` / `never` |
|
||||
@@ -94,12 +88,6 @@ for pi, i.e. pi appears in none of them): the replay-side strip in `session-rout
|
||||
live-stream twin in `session.ts`, and the frontend `_sessionUsesServerMouseStrip()` in
|
||||
`terminal-ui.js` (usages `:3432`, `:3697`).
|
||||
|
||||
**SUPERSEDED (PR #771, 2026-11):** pi still stays out of `isAltScreenStripMode()`, but it is no
|
||||
longer outside every strip: it takes the mouse strip (`isMuxMouseStripMode()`), which removes the
|
||||
alt-screen toggles and the mouse enables and keeps `3J`. Fullscreen is its default since 1.0.0, the
|
||||
enables are recorded as `cliMouseTracking`, and the wheel is forwarded from that flag, so it survives
|
||||
a tab switch or reload. A `/settings` flip of `tuiMode` is followed in both directions.
|
||||
|
||||
### 2.3 tmux required, no direct-PTY fallback, no per-mode configurator
|
||||
|
||||
Same rule as the other external CLIs: `pi` mode throws if tmux is unavailable. Add a fourth block to
|
||||
@@ -352,7 +340,6 @@ because the result lands in a `bash -c "..."` string):
|
||||
- `--tui-mode` (released in 0.84.0): never passed by Codeman. The main-screen default is the
|
||||
friendly case for the browser terminal, and fullscreen remains the user's own runtime choice via
|
||||
`/settings` (§2.2 is designed for that). `--use-theme` (still unreleased) likewise.
|
||||
**SUPERSEDED (PR #771, 2026-11):** still never passed, but fullscreen is the default since pi 1.0.0.
|
||||
- `--name <name>` (`-n`): nice for `/resume` readability, but names contain spaces and would be the
|
||||
first user-controlled value needing real shell quoting in `buildSpawnCommand`. Defer.
|
||||
- `--no-session`: ephemeral mode fights respawn/resume. Defer.
|
||||
@@ -684,7 +671,7 @@ follow-up surfaces in from the start (login-shell routing in Phase 1, availabili
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `pi` resolves to an unrelated binary | `pi --version` + semver-shape check in the resolver (§2.6); path and version shown in `/api/pi/status` |
|
||||
| Pi's TUI repaints in a way the browser terminal handles badly | Test scrollback and repaint early (step 3 of §7); pi's default is main-screen with terminal-owned scrollback, which is the friendly case |
|
||||
| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0 and pi now takes the mouse strip, see §2.2 |
|
||||
| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly |
|
||||
| The buffer local-echo overlay fights pi's live composer | §2.10: explicit E2E gate (§7 step 4) with the one-line `'off'` fallback; predictive echo for pi is a tracked follow-up, not a v1 blocker |
|
||||
| Pi moves fast (pre-1.0; 9 releases in the 7 weeks before 0.84.1) | Keep the flag surface small; every flag validated and droppable; nothing pinned in the Dockerfile beyond the `--no-cache` rebuild cadence. Live example of the hazard: `--tui-mode` went from main-only docs to released between the two drafts of this plan |
|
||||
| Docker image grows | Pi is an npm package; the layer is modest next to the ~190MB `agy` binary |
|
||||
|
||||
@@ -88,15 +88,9 @@ bypass, and project trust is a decision the person at the terminal makes.
|
||||
- **`--api-key`.** Never. It would put a provider secret on the spawn command
|
||||
line, visible in `ps`, tmux server state and logs. `PI_*` overrides go through
|
||||
socket-scoped `tmux setenv` for exactly this reason.
|
||||
- **`--tui-mode`.** Which TUI you get stays your choice, in pi's `tuiMode` setting.
|
||||
Fullscreen is the default since pi 1.0.0: the wheel and clicks reach pi, and because
|
||||
Codeman strips pi's mouse enables, a drag selects text in the browser rather than
|
||||
using pi's own selection or scrollbar drag. With **Wheel Scrolls Local History** on,
|
||||
the wheel does nothing in fullscreen pi: the setting pins it to a local buffer pi
|
||||
leaves empty, and pi has no paging fallback. `tuiMode: regular`, the default below
|
||||
1.0.0, is inline with terminal-owned scrollback and emits no mouse enables at all. A
|
||||
pi from 0.84.0 set to fullscreen enables mouse tracking the same way the 1.0.0
|
||||
default does, and Codeman treats it the same.
|
||||
- **`--tui-mode`.** Pi's default main-screen TUI is the friendly case for a
|
||||
browser terminal. The fullscreen mode (0.84.0) stays your own runtime choice via
|
||||
`/settings`.
|
||||
- **`--name`, `--no-session`, `-p`/`--print`, `--mode json`, `--mode rpc`,
|
||||
`--tools`/`--exclude-tools`, `-e`/`--extension`, `--skill`,
|
||||
`--system-prompt`.** Tracked as follow-ups in the plan doc.
|
||||
@@ -221,7 +215,7 @@ a Docker case only sees that container's own history.
|
||||
## Remote SSH cases
|
||||
|
||||
`pi` mode is routed through an interactive login shell
|
||||
(`remoteLoginShellCommand('pi')`), because sshd's remote-command PATH does not
|
||||
(`exec "$SHELL" -i -l -c 'pi'`), because sshd's remote-command PATH does not
|
||||
include npm's global bin on most hosts. Per-session config and `envOverrides` do
|
||||
not cross ssh and are rejected rather than silently ignored; use the per-host
|
||||
command override instead.
|
||||
@@ -229,13 +223,9 @@ command override instead.
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
|
||||
idle detection reads the screen: the registry entry's `workDetect` names pi's
|
||||
composer rule (`─`) as the glyph that arms the idle check and the spinner pi embeds
|
||||
in that rule while a turn runs (`── ⠏ Working ───`) as the working line, measured
|
||||
on pi 1.1.0. Without it a pi session that had started a turn never left `busy`,
|
||||
since pi never draws Claude's `❯`. Pi 0.84.0 shipped an `agent_settled` extension
|
||||
event that is a genuine idle signal; a Codeman pi extension using it is still the
|
||||
highest-value follow-up.
|
||||
idle detection falls back to output-stabilization like the other external CLIs.
|
||||
Pi 0.84.0 shipped an `agent_settled` extension event that is a genuine idle
|
||||
signal; a Codeman pi extension using it is the highest-value follow-up.
|
||||
- **No response viewer.** Pi writes JSONL v3 session files under
|
||||
`~/.pi/agent/sessions/`; nothing reads them yet.
|
||||
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Releasing Codeman (the COM procedure)
|
||||
|
||||
> Moved from the root `CLAUDE.md`, which keeps a pointer here. What SemVer covers is defined in `docs/versioning-policy.md`. The root file's Session Safety rules (branch check, explicit paths, never `git add -A`) apply to every step.
|
||||
|
||||
When user says "COM":
|
||||
|
||||
1. **Determine bump type**: `COM` = patch (default), `COM minor` = minor, `COM major` = major
|
||||
2. **Create a changeset file** (no interactive prompts). Write a `.md` file in `.changeset/` with a random filename:
|
||||
|
||||
```bash
|
||||
cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET'
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
|
||||
CHANGESET
|
||||
```
|
||||
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
|
||||
**Write it in the shape the release page is built from.** `CHANGELOG.md` keeps changesets' format, and the `Release` workflow rebuilds the GitHub release from that section (`scripts/generate-release-notes.mjs`, which also titles it `Codeman <x.y.z>`). The in-app update toast reads the same bold lead-ins:
|
||||
|
||||
```markdown
|
||||
### Thanks
|
||||
- @someone for what they did (#123).
|
||||
|
||||
**What the change does for the user (#123).** Two or three plain sentences.
|
||||
|
||||
**Fixes.**
|
||||
- **What works now (#124).** What was wrong. Behaviour change: what a user has to do differently.
|
||||
|
||||
**Fixes applied while landing.** One sentence each.
|
||||
|
||||
**Docs and tests.** One sentence.
|
||||
```
|
||||
|
||||
`### Thanks` stays first and opens the page. A bold lead-in that ends in punctuation becomes a heading under What's new, so make it a short headline. `**Fixes.**` and its list become a Fixes section, and the last two labels share an Also in this release section (the labels are `SECTION_LABELS` in `scripts/lib/whats-new.mjs`). `Behaviour change:` and `Breaking:` are put in bold wherever they appear, so use those words for anything a user has to act on. Keep each change short: how it works belongs in the docs, and a long paragraph is a wall of text even under a heading. Text the script does not recognise stays on the page as plain paragraphs. After step 3, `node scripts/generate-release-notes.mjs` prints the page. If the workflow warns that it kept the changesets notes, fix the changelog and run `gh release edit codeman@<x.y.z> --notes-file <file>`.
|
||||
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast)
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
|
||||
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
|
||||
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
|
||||
|
||||
A site change that links the Mac app (`Codeman.dmg`, `/mac`) deploys only after step 7 showed the `macOS app` job green and the [DMG check](#the-mac-app-codemandmg) answered 200: the DMG reaches the release well after the npm publish, and the download link answers 404 until then.
|
||||
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release, the MCP registry publish and, when Codeman itself was published, the `macOS app` job that attaches `Codeman.dmg`). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them.
|
||||
|
||||
9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
## The Mac app (Codeman.dmg)
|
||||
|
||||
Every Codeman release carries the native macOS app (`packages/desktop`) as `Codeman.dmg` plus `Codeman.dmg.sha256`. The name never changes, so https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always serves the newest app, while each release keeps its own copy at `https://github.com/Ark0N/Codeman/releases/download/codeman@<x.y.z>/Codeman.dmg` (link that one in release notes). The app keeps its own version (`packages/desktop/Cargo.toml`), so the DMG of Codeman 1.50.0 holds app 0.1.0.
|
||||
|
||||
**How it gets there.** When npm took a Codeman release (not one that moved only xterm-zerolag-input), the `Release` run's `macOS app` job calls `.github/workflows/desktop-dmg.yml` with the tag `codeman@<x.y.z>`. It has three jobs, so no job that holds a secret runs code from a crate: `build` runs `cargo test --locked` and `bundle-macos.sh --universal` on the release commit with a read-only token and no secret; `sign` imports the Developer ID certificate into a throwaway keychain and runs `packages/desktop/scripts/sign-macos.sh`, which signs the app with the hardened runtime, has Apple notarize it, staples the ticket, builds the DMG around it and signs, notarizes and staples that too; `attach` uploads both files to the release (`--clobber`) and checks that the download links serve those bytes. The release job's `Keep Latest on the Codeman release` step puts the Latest badge back on the newest `codeman@` release after every publish, since the stable link follows that badge.
|
||||
|
||||
**The secrets** are repository secrets, passed to the job by name (never `secrets: inherit`, which would hand it `NPM_TOKEN`): `MACOS_CERT_P12` (the Developer ID Application certificate WITH its private key, exported from Keychain Access as a .p12, then `base64 -i <file>.p12`), `MACOS_CERT_PASSWORD` (the export password), `APPLE_ID`, `APPLE_APP_PASSWORD` (an app-specific password for that Apple ID, made at account.apple.com) and `APPLE_TEAM_ID`. A Developer ID certificate lasts five years; its successor replaces the first two secrets and nothing else.
|
||||
|
||||
**Check it before anything links to it.** The DMG lands after a universal build and two notary submissions, so well after the npm publish, and until then the stable link answers 404:
|
||||
|
||||
```bash
|
||||
gh release view codeman@<x.y.z> --json assets -q '.assets[].name' # Codeman.dmg and Codeman.dmg.sha256
|
||||
curl -sIL -o /dev/null -w '%{http_code}\n' https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg # 200
|
||||
```
|
||||
|
||||
**Dry run.** `gh workflow run desktop-dmg.yml` builds, signs and notarizes master and attaches nothing; `gh workflow run desktop-dmg.yml --ref <branch>` does the same for a branch that carries the workflow (GitHub starts a manual run only for a workflow file that is also on master). The signed DMG is the run's `codeman-dmg` artifact (`gh run download <run-id> -n codeman-dmg`). For a first-launch test, download it on a Mac through the browser from the run page instead, so the copy is quarantined like one from a release.
|
||||
|
||||
**Rebuild a release's DMG.** `gh workflow run desktop-dmg.yml -f tag=codeman@<x.y.z>`, started from master (the only ref allowed to attach), builds that tag and replaces its `Codeman.dmg`. It works for any release that carries `packages/desktop/scripts/sign-macos.sh`. The tag must be a Codeman release tag (`codeman@x.y.z`, or the `aicodeman@x.y.z` a failed rename leaves) whose commit is on master's history: `build` and `sign` both refuse anything else before they run its scripts, since `sign` runs them with the signing key unlocked. A failed job is also retried in place with `gh run rerun <run-id> --failed`.
|
||||
|
||||
**If notarization fails.** The `Sign and notarize` job's log names the submission and prints Apple's log for it (`xcrun notarytool log <id>` with the same login shows it again).
|
||||
|
||||
- `HTTP status code: 401` (invalid credentials) or `403` (a required agreement is missing or has expired) at the first submission: a new Apple ID password revokes every app-specific password, so make a new one and update `APPLE_APP_PASSWORD`, or accept the new agreement at developer.apple.com, then `gh run rerun <run-id> --failed`.
|
||||
- `0 valid Developer ID Application identities` in the `Signing keychain` step: the .p12 lacks its private key or holds another certificate type. A team mismatch there means `APPLE_TEAM_ID` names another team than the certificate.
|
||||
- `In Progress` at the 45-minute limit, or a network error: Apple's service was slow. Rerun the failed job.
|
||||
- `Invalid`: Apple's log names the file and the reason (a binary without the hardened runtime or a secure timestamp, an entitlement it refuses). That is a code fix and a new release. Until then keep the stable link on the last app that passed, `gh release edit codeman@<previous> --latest`, and hold any site change that links the new one.
|
||||
|
||||
**By hand on a Mac**, only when the workflow cannot be fixed in time: in a clean checkout of the tag, `env -u CODEMAN_DESKTOP_INSTALLER_URL -u CODEMAN_DESKTOP_INSTALL_BRANCH packages/desktop/scripts/bundle-macos.sh --universal`, then `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app <out-dir>` (a profile saved with `xcrun notarytool store-credentials`), then `gh release upload codeman@<x.y.z> <out-dir>/Codeman.dmg <out-dir>/Codeman.dmg.sha256 --clobber`. Never upload a build made with either of those two variables set: it installs Codeman from the test branch it names.
|
||||
|
||||
**Toolchain and runner.** `desktop-dmg.yml` and the pull-request check `desktop-ci.yml` (`cargo fmt --check`, clippy with `-D warnings` and `cargo test --locked` on macOS, for changes under `packages/desktop`) both pin `RUST_TOOLCHAIN` and run on `macos-26`. Move them together.
|
||||
|
||||
## Security notices
|
||||
|
||||
A security notice is a sticky warning card about something outside Codeman that its users should act on (the first: OpenCode's Console breach, October 2026). On a browser that gets the release's update toast it appears inside that toast instead, so nobody sees it twice. Notices live in `src/web/public/security-notices.json`, which is maintained BY HAND; never put one in `whats-new.json`, which `npm run version-packages` rewrites. A notice reaches users only with a release, so add it before the COM that should carry it.
|
||||
|
||||
**Adding one**: append an entry to `notices`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "opencode-console-breach-2026-10",
|
||||
"severity": "warning",
|
||||
"cli": "opencode",
|
||||
"published": "2026-10-17",
|
||||
"expires": "2026-12-31",
|
||||
"title": "OpenCode Console breach: check your API keys",
|
||||
"message": "A few short plain sentences, about 50 words at most: what happened and what to do.",
|
||||
"link": { "label": "Read OpenCode's notice", "url": "https://..." },
|
||||
"i18n": { "zh-CN": { "title": "...", "message": "...", "label": "..." } }
|
||||
}
|
||||
```
|
||||
|
||||
- `id` is stable and unique, and never reused: a browser that closed a notice remembers its id (`codeman-security-notices-seen` in localStorage), so a reworded notice under the same id is NOT shown again to anyone who closed it. Give a materially new warning a new id.
|
||||
- `cli` (optional) is a registry CLI id. The notice is then shown only on a server where that CLI is installed and enabled; without `cli` it is shown to everyone.
|
||||
- `published` and `expires` are inclusive `YYYY-MM-DD` days in the viewer's local time. Set `published` a day before the release ships, so the notice is already live in every timezone when it lands (a browser still on the previous day would otherwise take the plain update toast and see the notice later as a separate card). `expires` is what keeps an old notice from alarming a browser that first opens Codeman months later, so always set one.
|
||||
- Keep `message` to about 50 words and do not repeat the card's chrome (the "Security notice" eyebrow and the title are already on it): on a phone the card shares the screen with the update toast's text, and a longer one has to be scrolled to reach its buttons. State only what the affected vendor has said.
|
||||
- `title` (optional) is the card's bold line; keep it short. `link.url` must be https. `i18n` is optional; a missing string falls back to the English one.
|
||||
- Run `npx prettier --write src/web/public/security-notices.json` and `npm test -- test/security-notices.test.ts`, which validates the file (unique ids, real dates, https links, every `cli` a stock registry id, a zh-CN title wherever there is an English one).
|
||||
|
||||
**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. Until then a fresh install gets it too, because a new browser has no seen list, so give an `expires` no later than the warning stays useful. An empty list (`{ "notices": [] }`) is a valid file.
|
||||
@@ -78,7 +78,7 @@ input for the session, and came back from localStorage on each reload.
|
||||
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
|
||||
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
|
||||
go out in seq order, so the PTY sees one contiguous stream. A paste over
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (one paste plus its own Enter, never
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
|
||||
split), is refused with a toast and never queued.
|
||||
- The WebSocket answers an oversized sequenced frame with
|
||||
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
|
||||
|
||||
@@ -91,18 +91,12 @@ builds the command that launches (or **reattaches** to) the remote session:
|
||||
|
||||
```
|
||||
ssh -o BatchMode=yes -t <connection-args> user@host \
|
||||
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> /bin/sh -c <pane-command> \; \
|
||||
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
|
||||
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
|
||||
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
|
||||
set -t codeman-ssh-<id8> window-size latest \; \
|
||||
bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
|
||||
bind-key -T prefix C-q send-prefix \; \
|
||||
set -t codeman-ssh-<id8> remain-on-exit failed'
|
||||
set -t codeman-ssh-<id8> window-size latest'
|
||||
```
|
||||
|
||||
`<pane-command>` is `cd <remotePath> && exec <cli>`, shell-quoted as one token. With a
|
||||
`commands.*` override that token stands alone, with no `/bin/sh -c` in front of it.
|
||||
|
||||
Key points:
|
||||
|
||||
- **`new-session -A -s codeman-ssh-<id8>`** = attach-if-exists-else-create, so a
|
||||
@@ -114,14 +108,7 @@ Key points:
|
||||
- **`-L codeman-remote`** = a DEDICATED socket for sessions launched by remote
|
||||
Codemans, NOT the canonical `-L codeman` socket the remote host's own Codeman
|
||||
uses. Options are set per-session (`set -t`), never `-g`, so a shared remote
|
||||
tmux server's other sessions are untouched (#145 hardening). The prefix
|
||||
*table* is server-global, so the create chain reduces it to `C-q send-prefix`
|
||||
(bind `send-prefix`, then `unbind-key -a -T prefix`, then bind it again). The
|
||||
leading bind keeps an empty prefix table from aborting the chain:
|
||||
`unbind-key -a` fails with "table prefix doesn't exist" and tmux drops every
|
||||
later command, including `remain-on-exit failed`. Without that reduction, Ctrl+Q then `)` moves
|
||||
the inner client onto a sibling session. A non-owned attach does not do this:
|
||||
it joins the remote Codeman's own socket. Note the
|
||||
tmux server's other sessions are untouched (#145 hardening). Note the
|
||||
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
|
||||
socket** — they join sessions the remote's own Codeman manages, while owned
|
||||
durable launches live on `-L codeman-remote`.
|
||||
@@ -136,45 +123,6 @@ Key points:
|
||||
**login shell**, not the agent.
|
||||
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||
- **That argument reaches the far end verbatim** (#760): the launch, an adopted
|
||||
session's ssh attach included, goes to the local `bash -c` in single quotes, so
|
||||
`$HOME`, `$PATH` and `$SHELL` are the remote user's. In double quotes the local `sh` expanded them first, which sent
|
||||
this host's home and PATH to the remote. ⚠️ Every `$` in a `commands.*` override
|
||||
now expands on the far end too, and a bare `$VAR` in an `extraSshOptions` value
|
||||
reaches `ssh` as those literal characters at launch, as it always did in the link
|
||||
and quick-start tmux probes. A Docker launch is verbatim as well, so a bare `$VAR`
|
||||
in `extraCreateArgs`/`extraExecArgs` reaches `docker` the same way. Write those
|
||||
values out.
|
||||
- **tmux picks the shell by argument count** (measured on 3.4): one argument runs
|
||||
through the remote `default-shell`, two or more are exec'd directly. Codeman's own
|
||||
pane command is POSIX, so it goes to `/bin/sh -c` as separate arguments, and a fish
|
||||
login shell (or a `default-shell` set in the remote `~/.tmux.conf`) never parses it:
|
||||
fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*`
|
||||
override stays one argument on purpose, since it is written for the user's own shell
|
||||
(`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`).
|
||||
- **Login shells**: POSIX shells and fish work, and csh and tcsh do within the limits
|
||||
below. nushell and elvish cannot run the PATH bootstrap at all, so no override
|
||||
helps. A `remotePath` holding an apostrophe does not start under fish.
|
||||
- **The login flags are picked on the far end**, the only place the shell is known:
|
||||
the pane command passes `-i -l` unless `${SHELL##*/}` is `csh` or `tcsh`, which get
|
||||
neither. tcsh honors `-l` only as its one flag and exits on `-i -l -c`, `-l -c` and
|
||||
`-i -l` (measured on 6.24), so a tcsh session reads `.tcshrc` and not `.login`. The
|
||||
text is one brace group, so `cd <path> && { …; }` starts nothing when the `cd` fails.
|
||||
- **The login shell still parses the ssh line itself**, as it did in 1.48.0, so tcsh
|
||||
has two more limits. It has no `export`, so the PATH bootstrap only prints an error
|
||||
there, and `tmux` and the CLI have to be on a PATH that `.tcshrc` sets. And the tmux
|
||||
prerequisite probe's `command -v tmux` needs a `command` executable: macOS and the
|
||||
BSDs ship one, Debian and Ubuntu do not, so linking a tcsh host there is refused
|
||||
with `could not verify tmux`.
|
||||
- **File reads never leave a quote for the login shell** (`remoteLoginShellLine()` in
|
||||
`remote-files.ts`). Previews, downloads, folder grids and Edit history are multi-line
|
||||
POSIX scripts holding a path someone else may have named. fish reads `\'` inside
|
||||
single quotes as a quote, so the shell-quoted text flips in and out of quotes, and
|
||||
csh and tcsh end a command at a newline even inside quotes, so both ran part of a path
|
||||
as a command (measured). The script therefore crosses the login shell as one word of
|
||||
`printf` text with those bytes in octal, and only `sh` ever reads it. Plain csh caps
|
||||
a word at about 8 KB and answers `Word too long` for a larger read; tcsh has no such
|
||||
cap.
|
||||
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
||||
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
|
||||
preserving historical token order.
|
||||
@@ -218,12 +166,6 @@ when the tab closes.
|
||||
session. `remoteSessionName` holds its existing tmux name. Closing the tab
|
||||
**detaches**, never kills.
|
||||
|
||||
⚠️ This covers `codeman-*` sessions on the `codeman-remote` socket, the ones another
|
||||
Codeman runs. A tmux session a HUMAN started on the host is a separate path:
|
||||
`GET /api/mux/foreign?remote=1` finds it and `POST /api/sessions/adopt` wraps it, with
|
||||
the connection on `Session.adopt.remote` and `session.remote` undefined. Closing that tab
|
||||
detaches too. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
|
||||
|
||||
### Discovery
|
||||
|
||||
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
|
||||
@@ -309,71 +251,6 @@ unreachable host answers "unknown", which also means do not revive. The answer
|
||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||
|
||||
## Terminal history from the remote tmux
|
||||
|
||||
The local pane runs the ssh attach, so it only sees the remote tmux client's
|
||||
full-screen repaints and keeps a fraction of the scrollback (none after a
|
||||
reattach). A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a
|
||||
remote session therefore reads the scrollback rows from the REMOTE pane in one
|
||||
ssh round trip (`src/remote-pane-history.ts`) and splices them above the local
|
||||
visible frame and caret. Bounded (5 s for the round trip, at most 1.5 s queued
|
||||
for a slot in the shared ssh limiter) and fail-soft: any failure keeps the
|
||||
local capture, and a transport failure (ssh exit 255, a timeout, output that is
|
||||
not the script's) also skips that host for 30 s; a failure of the remote
|
||||
command itself (a remote tmux session that is gone) affects that session only.
|
||||
How much history exists is the remote tmux's own `history-limit`, which the
|
||||
launch command does not set (tmux's default is 2000 lines).
|
||||
|
||||
The same round trip also reads the remote SCREEN, in the same tmux command list
|
||||
as the history, because two seams would otherwise drop rows at the join:
|
||||
|
||||
- **Rows that scroll during the round trip.** The local frame is captured only
|
||||
once the round trip is back, so rows that scrolled off the screen in between
|
||||
are in neither the remote history nor the local frame (measured before this
|
||||
read existed: 13 lines lost with a 150 ms round trip and fast output).
|
||||
- **A shared remote session with a taller client.** Under `window-size latest`
|
||||
a remote session attached elsewhere is sized by its latest client; when that
|
||||
client is taller than this pane, the rows above this pane's frame are only
|
||||
on the remote screen (measured before: 20 rows).
|
||||
|
||||
Both are rows of the remote screen above where the local frame begins, so the
|
||||
load keeps them between the history and the frame (`joinScrollbackAndFrame`,
|
||||
`findFrameInScreen` in `src/tmux-manager.ts`). The frame is placed by matching
|
||||
its top rows against the screen as text (escape sequences and trailing blanks
|
||||
ignored, since the two tmuxes need not encode attributes alike); the screen's
|
||||
trailing blank rows and its last content row, which output in flight can still
|
||||
change, are left out of the match, the overlap must be at least three rows
|
||||
with two of them non-blank, and the highest match wins. A placement too low
|
||||
would REPEAT the rows between it and the true one, so a shorter overlap (a lone
|
||||
`$` row that happens to match after a spinner row changed) is not trusted.
|
||||
When the frame cannot be placed (more output scrolled than the screen held, or
|
||||
nothing matches), the join is history over frame, as without the screen read.
|
||||
That includes an agent TUI mid-turn (Claude Code, codex, opencode): it keeps a
|
||||
live region (spinner, composer, footer) pinned to the bottom and inserts new
|
||||
rows above it, so the frame is not a shifted copy of the screen and is not
|
||||
placed. A plain shell places, unless the frame's top row is the second half
|
||||
of a wrapped line (the screen holds that line as one joined row, so nothing
|
||||
matches and the join is history over frame). What is left: when the screen
|
||||
repeats a whole block of rows, the frame can be placed at the higher copy,
|
||||
which leaves out the rows between the two copies.
|
||||
|
||||
The history, the history size and the screen are ONE tmux command list
|
||||
(`capture-pane … \; display-message … \; capture-pane …`), not separate `tmux`
|
||||
runs. tmux runs a client's command list back to back without reading pane
|
||||
output in between, so the screen begins exactly where the history ends. As
|
||||
separate runs the shell kept writing between them and the rows that scrolled in
|
||||
those milliseconds were in neither capture (measured with output streaming: 1-2
|
||||
rows per load on a LAN host, every read on a local busy pane). This rests on how
|
||||
tmux's command queue behaves, not on a documented guarantee. A random marker row
|
||||
(`display-message`) separates the history from the size and the screen, since
|
||||
tmux output cannot carry the NUL markers around it. tmux before 3.2 prints no
|
||||
newline after `capture-pane -p` output, so when the history ends inside a
|
||||
wrapped line the marker is glued onto that row; the parser accepts the marker
|
||||
at the end of a row and re-terminates the history. The remote `tail -c` bound
|
||||
applies to that whole output, history first, and is the configured byte cap
|
||||
plus 1 MiB for the marker, size and screen, so a cut lands in the history; the
|
||||
route trims what it returns to the cap itself.
|
||||
|
||||
## File access over SSH
|
||||
|
||||
A remote case's `workingDir` is an absolute path on the **remote** host
|
||||
@@ -393,11 +270,7 @@ and it follows the same rule as the launch path: every ssh command line comes fr
|
||||
| `GET /api/sessions/:id/file-thumbnail` | `400` for remote files |
|
||||
| `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there |
|
||||
| `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` |
|
||||
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
|
||||
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
|
||||
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
|
||||
| `POST /api/sessions/:id/fix-plan/read` | Reads `@fix_plan.md` on the **remote** host (one probe of the file and the workspace, the same confinement, a 1 MB cap); missing or resolving outside the workspace → `NOT_FOUND`, not a regular file, over the cap or an ssh failure → `OPERATION_FAILED`; never a local fallback |
|
||||
| `POST /api/sessions/:id/fix-plan/write`, `POST /api/sessions/:id/ralph-prompt/write` | `400` (`INVALID_INPUT`) before any disk is touched: remote writes are out of scope, and a local write would land in a same-named local folder |
|
||||
|
||||
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
|
||||
directory (a remote `/tmp` scratchpad capture, a screenshot elsewhere in the home dir):
|
||||
@@ -467,9 +340,8 @@ still has it.
|
||||
|
||||
**Not available over ssh (by choice, not by accident):** editing a file (writes would
|
||||
need SFTP; `docs/file-viewer-edit-plan.md` §6), office-document previews and
|
||||
generated thumbnails (both need the bytes on the server's disk: no remote file is ever
|
||||
spilled onto the server, the Edit history store's diffs being the one exception), the
|
||||
file-tree/picker listings, and `tail-file`. Those routes
|
||||
generated thumbnails (both need the bytes on the server's disk — no remote file is ever
|
||||
spilled onto the server), the file-tree/picker listings, and `tail-file`. Those routes
|
||||
are still local-only, so with an `sshfs` mount in place they read the mounted copy —
|
||||
the two views can only disagree when that mount is stale. Docker cases are unaffected:
|
||||
their workspace is bind-mounted at the same absolute path, so local `fs` reads real bytes.
|
||||
@@ -480,14 +352,6 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo
|
||||
`fs`, and a local session never opens an ssh connection — including for attachment
|
||||
records, which are keyed to the session that registered them.
|
||||
|
||||
**Local watchers skip a remote session.** The Ralph tracker's `@fix_plan.md` auto-load
|
||||
(and the `fs.watch` behind it) never runs for a remote session, because the watcher reads
|
||||
the LOCAL disk, where that path is missing or a different tree: `setupSessionListeners()`
|
||||
(`server.ts`) calls `setRemoteWorkingDir()` instead, which only records the path. The fix
|
||||
plan stays authoritative exactly as for a local session, so the tracker's output-based
|
||||
completion guessers stay off there too; the file reaches the tracker only through
|
||||
`fix-plan/read`, over ssh. The image watcher skips remote sessions the same way.
|
||||
|
||||
## Wake-on-LAN from user input
|
||||
|
||||
A durable remote session survives an SSH drop (COD-104/108), but nothing brought the
|
||||
|
||||
@@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal
|
||||
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
|
||||
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
|
||||
- 40ms SGR coalescing: never send per-event writes to the server.
|
||||
- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`.
|
||||
- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified.
|
||||
- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three.
|
||||
- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`).
|
||||
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
|
||||
|
||||
## Testing (per repo rules)
|
||||
|
||||
@@ -105,9 +105,7 @@ The warning points at three ways to secure the instance:
|
||||
2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve`).
|
||||
3. `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`
|
||||
— explicitly accept the risk (downgrades the warning to a one‑line note). This
|
||||
flag is **only** an acknowledgement; it does not change reachability. It covers
|
||||
the bind and nothing else: it does **not** let the managed Cloudflare tunnel
|
||||
start without a password, which has its own opt‑in (§2, the tunnel caveat).
|
||||
flag is **only** an acknowledgement; it does not change reachability.
|
||||
|
||||
`CODEMAN_API_URL` (used by hooks/child processes) is always derived as a loopback
|
||||
address (`0.0.0.0`/`localhost`/`::1` → `127.0.0.1`) so in‑process hooks reach the
|
||||
@@ -151,7 +149,7 @@ by presence in a server‑side map, so they cannot be forged offline.
|
||||
|
||||
### Rate limiting / lockout recovery
|
||||
|
||||
Failed logins are tracked **per IP**: 10 failures (`AUTH_FAILURE_MAX`) → `429`, with a 15‑minute decay. Multi-user mode replaces that with three buckets, all with the same decay: a wider per-IP **pre-gate** of 50 failures (`AUTH_IP_FAILURE_MAX`), checked before the scrypt verify, which bounds scrypt cost from one source and tolerates many users behind one egress IP; a **per-(username, IP)** gate of 10 failures (`AUTH_FAILURE_MAX`), also checked before the verify, which is what caps verified guesses against one account from one source; and a **per-username** bucket, checked after the verify, that answers `429` to failed guesses from the 10th on, from any IP (the count is incremented before the check). The last two exist in multi-user mode only. So the wider per-IP cap does not widen guessing against any one account from one IP, and other IPs cannot lock an account out.
|
||||
Failed auth is tracked **per IP**: 10 failures → `429`, with a 15‑minute decay.
|
||||
The QR path has its own separate limiter.
|
||||
|
||||
The lockout check sits **after** the cookie/credential checks (step 4, not first).
|
||||
@@ -160,22 +158,6 @@ immediately** even while an attacker is hammering the same IP — important beca
|
||||
all traffic through a tunnel shares one source IP (loopback). Wrong credentials
|
||||
are still counted and still hit the `429` at the threshold, so brute‑force
|
||||
protection is unchanged.
|
||||
The two multi-user pre-verify gates are the exception: once the per-IP pre-gate
|
||||
or an account's (username, IP) gate trips, even a correct password from that IP
|
||||
gets `429` until the decay (for the pair gate, only for that account; the same
|
||||
account from another IP still logs in), which is why the per-IP cap is the
|
||||
widest. The per-username bucket stays after the verify on purpose: checked
|
||||
first, a username-wide lockout let throwaway IPs keep a known account (admin
|
||||
included) locked out even with the right password.
|
||||
Both pre-verify gates count a request the moment it passes them, before its
|
||||
verify starts, so a burst of concurrent attempts cannot all slip through on one
|
||||
stale count; a correct password then clears its slot with the rest, and a
|
||||
failure is not counted a second time. A verify still in flight therefore holds a
|
||||
slot, even one carrying the right password, but identical credentials from the
|
||||
same IP that arrive during it share that verify, its slot and its outcome, and
|
||||
skip both gates: they add no guess and no scrypt work. So a browser replaying its
|
||||
cached login on many parallel requests after a restart (cookie sessions live in
|
||||
memory) is not refused by the slots its own password filled.
|
||||
|
||||
---
|
||||
|
||||
@@ -211,15 +193,8 @@ requests as local:
|
||||
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
|
||||
up — it gates everything except the (secret‑gated) hook exemption and is the
|
||||
documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
|
||||
without a login (`CODEMAN_PASSWORD` or multi‑user mode) unless
|
||||
`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel`
|
||||
acknowledges the exposure, or the request carries `acknowledgeUnauthTunnel: true`
|
||||
(what the browser sends after its confirm). The network acknowledgement
|
||||
(`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`) does **not** count: the installer writes
|
||||
it for every LAN install without a password, so it once turned a click on the
|
||||
welcome screen's tunnel link into a public, login‑free URL with no confirm. With
|
||||
no login the browser asks before every enable, whatever the server would accept,
|
||||
reading `loginConfigured` from `GET /api/tunnel/status`. Prefer `tailscale serve` (below), which authenticates
|
||||
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
|
||||
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
|
||||
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
||||
|
||||
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
|
||||
@@ -342,25 +317,6 @@ escapes the session working directory (`..`, absolute paths, and symlinks that
|
||||
resolve outside). The realpath‑before‑check ordering closes the validation‑time
|
||||
TOCTOU window.
|
||||
|
||||
A local session has one more root while its agent works in a git worktree of the
|
||||
same repository OUTSIDE its folder (published as `outsideWorktree`). An absolute path inside
|
||||
that worktree is confined to it by `validateLocalSessionFilePath()`, and only while the
|
||||
worktree folder still `realpath`s to the path recorded for it. The edit route
|
||||
(`PUT /api/sessions/:id/file-content`) uses the same check, so a file in that worktree can be
|
||||
written as well as read. Codeman records such a
|
||||
worktree only after the session's own repository vouches for it, and never `/`, the
|
||||
home folder or above, a folder holding the working directory, or a folder at, inside or
|
||||
around a Docker case workspace (`src/live-worktree.ts`). It also refuses a folder that is
|
||||
itself a sensitive folder such as `.ssh`, and `validateLocalSessionFilePath()` never serves a
|
||||
file from the worktree that `isSensitivePath()` blocklists. In multi-user mode a regular
|
||||
owner's session also needs the owner's folder rule (their own space or the working directory);
|
||||
an admin's does not. It follows nothing while attachment workspace confinement is on, and
|
||||
nothing at, inside or around a blocked attachment tree. Codeman judges these rules when the pane
|
||||
changes directory, and the routes judge confinement and the blocked trees again on every request,
|
||||
so turning confinement on or blocking the folder stops serving it at once. The multi-user rule is
|
||||
judged only when the pane changes directory. On every request the routes also check that the
|
||||
folder still resolves to its recorded path. A relative path is still confined to the working directory alone.
|
||||
|
||||
| Route | Cap | Notes |
|
||||
|-------|-----|-------|
|
||||
| `file-content` | 10 MB | text preview |
|
||||
@@ -398,9 +354,8 @@ is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download c
|
||||
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
||||
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
||||
is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in
|
||||
`src/attachment-registry.ts`: images, pdf/docx/pptx/xlsx, audio/video, md/txt and
|
||||
other text), not realpath containment.
|
||||
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
|
||||
realpath containment.
|
||||
|
||||
Two registration paths, with **different trust**:
|
||||
|
||||
@@ -418,23 +373,6 @@ Two registration paths, with **different trust**:
|
||||
file outside the session's own workspace. Cross‑workspace attach must go through
|
||||
the explicit POST path above.
|
||||
|
||||
**Folder grid grants** (`POST /api/sessions/:id/folders`, `src/folder-listing.ts`)
|
||||
follow the explicit-POST trust level: a deliberate, Origin‑guarded request, allowed
|
||||
cross‑workspace subject to the same guard (blocklist on the folder AND on every
|
||||
listed file, `confineToWorkspace` when set, hidden entries and unsupported types
|
||||
left out). The grant is an id for a resolved folder; files are served by
|
||||
`folderId` + a bare file name only (no `/`, `..` or NUL, supported type), and each
|
||||
request re‑runs `resolveServableAttachmentPath` on the joined path, so a file that
|
||||
was swapped for a symlink to a blocked target after listing is still refused.
|
||||
The file a symlink resolves to is held to the attachment gate as well (supported
|
||||
extension, regular file), so a supported-looking name cannot serve an arbitrary
|
||||
file, and a FIFO or device node is never opened.
|
||||
In multi‑user mode a regular user is held to the filesystem path picker's rule
|
||||
(`folderAllowedForUser`): the folder, and again each served file after symlink
|
||||
resolution, must sit inside their own `userSpacePath` or the session's
|
||||
`workingDir`, else 403. User spaces live inside `homedir()`, so without it one user
|
||||
could list and read another's space.
|
||||
|
||||
### SSE log‑tail route — intentional extra read roots
|
||||
|
||||
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log
|
||||
@@ -502,15 +440,6 @@ transient mount blip at launch poisons tmux's long‑lived cwd and crashes
|
||||
never executes. Large hand‑formatted/generated assets (`app.js`, the gesture
|
||||
bundle, vendored libs) are `.prettierignore`d for the style pass, but the NUL +
|
||||
syntax checks still cover them.
|
||||
- **Installer downloads without a package manager:** on the no-admin route
|
||||
(`docs/wiki/Installation.md`) `install.sh` fetches and runs software no system
|
||||
package manager vetted, each checked before it is unpacked or run. The Node.js
|
||||
tarball is checked against the `SHASUMS256.txt` fetched from nodejs.org over the
|
||||
same TLS connection, which proves integrity, not independent provenance: the
|
||||
signature on that file is not checked. The tmux build is checked against a
|
||||
SHA-256 pinned per platform in the installer. `install.sh cloudflared` takes the
|
||||
latest cloudflared release into `~/.local/bin` unverified, as the system routes
|
||||
take the same release, because Cloudflare publishes no checksum file.
|
||||
|
||||
---
|
||||
|
||||
@@ -586,7 +515,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
- **It strictly improves network posture.** It removes the single shared `CODEMAN_PASSWORD` and gives each person a revocable credential; a non‑loopback bind and the tunnel‑enable guard are satisfied by "multi‑user with ≥1 enabled user" without a shared password.
|
||||
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
|
||||
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
|
||||
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, other CLIs' bypass flags, and Codex's **Allow network access** (`networkAccess`, forced off for a user without the grant, since it lets a command in Codex's sandbox call Codeman's API and start a session outside that sandbox). Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
||||
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
||||
- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. Docker cases are a second route to the same sign-in: with `CODEMAN_AGENT_IMAGE_INSTALL_GH`/`_AZ` on, a non-admin's Docker case with credential seeding on (the default) receives a copy of the server account's `gh`/`az` sign-in, exactly as it receives the Claude and Codex credentials.
|
||||
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
|
||||
|
||||
@@ -599,31 +528,10 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **The lost‑frame recovery page is the third unauthenticated 200, and the only one decided by request headers alone.** The proxy's runtime shim masks `/webview/<cap>/` off the page's own URL so a single‑page app routes on the path it expects; a navigation the page then starts itself (`location.reload()`, a root‑absolute `location.href`) lands on Codeman's root with no capability anywhere, no cookie (opaque origin) and a Referer naming the masked page. `serveLostWebviewFrame()` in `middleware/auth.ts` recognises it by shape (`GET`/`HEAD`, `Sec-Fetch-Dest: iframe` or `frame`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` or absent) and answers, BEFORE the credential checks and without counting an auth failure, with a static page whose only content is a `postMessage` of the lost path to the parent tab (`default-src 'none'` plus the hash of that one script, `no-store`, `referrer: no-referrer`, no reflected input). It is fenced to paths that are NOT registered routes and never `/api/`, `/ws/` or `/q/`, with one carve‑out: `/` itself, because the landing page masks to exactly `/` and its reload otherwise rendered Codeman's app shell inside the web tab. `/` is admitted only when the request carries neither the `codeman_session` cookie nor an `Authorization` header: nothing in Codeman frames its own root and a sandboxed frame has neither, while a framed `/` that does carry credentials still gets the shell. On a passwordless install no auth hook runs, so the index route applies the same test itself (`isLostWebviewRootFrame`). ⚠️ Known property, accepted rather than mitigated: those headers are trivially set by a non‑browser client, so an unauthenticated caller can distinguish a registered route (401) from a non‑route (200) and enumerate the route table; the routes are public in `docs/api-reference.md`, so nothing is learned. Pinned by `test/webview-auth-exemption.test.ts` (password) and `test/webview-lost-root-frame.test.ts` (passwordless).
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Rendered HTML files (`/html-view/<capability>/`) are the other capability-gated route that skips cookie auth.** A clicked `.html` file renders through it so an agent's report can be viewed from any device. The exemption is narrower than the proxy's: `GET`/`HEAD` only (plus the page's CORS preflight, which `registerSecurityHeaders` answers itself with `Access-Control-Allow-Methods: GET, HEAD` and never hands to a route), the capability in the path only (no `Referer` form), and it is a separate store, so a web-tab capability never opens a page and vice versa. The capability is minted by an authenticated `POST /api/sessions/:id/html-view` (session ownership via `findSessionOrFail`, the attachment guard for out-of-workspace files), held in memory with a rolling TTL and revoked with the web-tab ones on logout, admin logout and user deletion. Every response carries `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page is opaque-origin even when opened as a top-level tab and cannot read Codeman's document, cookies or API. **Network access is open, by the owner's decision (2026-10-13):** that `sandbox` directive is the whole policy (it replaces Codeman's own `default-src 'self'` CSP and carries no fetch directive), so a rendered page behaves like a file opened from disk in a browser. What a page CAN do: load scripts, styles, fonts, images and media from any host (an agent's dashboard pulling Chart.js from a CDN), make fetch, XHR, WebSocket, beacon and form requests to any host, and read the files of its own folder through its capability. What it CANNOT do: act as Codeman's origin, read Codeman's cookies or document, use Codeman's API (a write arrives with `Origin: null` and the Origin guard refuses it; a read's response stays unreadable, because Codeman answers CORS only for its own localhost origins, and with a password set the request carries no session cookie either), or read any folder other than its own. The accepted consequence is that a page can send what it can read, its own folder, to any host, which is why the folder rules below are the boundary that matters. `Referrer-Policy: no-referrer` keeps the capability URL out of the `Referer` the page's third-party requests carry. Pinned by the exact-header test in `test/routes/html-view-routes.test.ts`. It may read its own directory tree (non-dot, asset-allowlisted, realpath-confined, blocklist applied; the dot and extension rules hold for the symlink-resolved target as well as the requested name), so minting is refused for an HTML file in a hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a directory above one (`/home`, `/Users`). Pinned by `test/routes/html-view-routes.test.ts` and the html-view block in `test/webview-auth-exemption.test.ts`.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
|
||||
|
||||
---
|
||||
|
||||
## 10c. Webhook notifications (outbound channel)
|
||||
|
||||
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
|
||||
|
||||
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||
- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA.
|
||||
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||
|
||||
## 10d. The model on this Mac (local model install)
|
||||
|
||||
Opt-in by a click, macOS only, admin-only in multi-user mode on every verb. The server downloads one pinned runtime build, runs it as its own child, pulls one model into it, installs the coding agent when it is missing, and saves one endpoint record. Source: `src/local-model/`, routes in `src/web/routes/local-model-routes.ts`. Guide: [`local-models.md`](local-models.md).
|
||||
|
||||
- **Supply chain: a compiled pin.** The runtime's URL, size and sha256 are a constant in `src/local-model/runtime.ts` that no file, setting or request can change; the archive is hashed while it streams and compared BEFORE anything is unpacked, a mismatch deletes it, and only `/usr/bin/tar` unpacks it into a folder under the data dir. The model comes from the runtime's own registry under the name the catalogue pins; the digest the runtime reports after the pull is stored, and a model whose digest changed under that name is refused at the next start.
|
||||
- **A loopback port without a login.** The runtime listens on `127.0.0.1:<port>` (chosen once from 11435) with no authentication, like every model server of its kind: any program of any account on the Mac, and any web page served from `localhost`, can use it and change its models. What limits that: the endpoint record lists ONE model and the discovery sweep leaves it alone, the digest is compared before an agent is started on it (every start path, see the guide), and a listener on the port that is not our own child (judged by the stored pid's command line, never by the port alone) is reported as `port-in-use` and never signalled. An open session keeps calling the port while the runtime is stopped, which is why Stop and a port change are refused while one uses the model.
|
||||
- **A second install path for a coding agent, without `cliManagementEnabled`.** The job runs the COMPILED stock install command of a catalogue harness row (never a `clis.json` override, never a custom entry) through the same runner as CLI management, only after the consent screen showed the literal command, and writes the admin audit entry (`cli_install`, `via: 'local-model'`). The installer runs as the server's user with the same reach as every other spawn.
|
||||
- **The runtime's own files stay inside `~/.codeman/local-model/`**: its private HOME (so no `~/.ollama` is created), its model store and its unpacked folder; its environment is an allowlist (no `CODEMAN_*`). Remove deletes only inside that folder, refuses a folder that is a symbolic link, and ends the runtime (ours by its command line) before any file goes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
@@ -632,8 +540,7 @@ Opt-in by a click, macOS only, admin-only in multi-user mode on every verb. The
|
||||
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
|
||||
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
|
||||
| `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` |
|
||||
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning). Does not cover the tunnel |
|
||||
| `--allow-unauthenticated-tunnel` / `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Let the managed Cloudflare tunnel start without a login (the browser still asks first). Never written by the installer |
|
||||
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||
|
||||
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
||||
> reconnects after a drop, delivers input exactly once, has clickable paths
|
||||
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
||||
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
||||
> closes anything (Close Session has no default key).
|
||||
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
@@ -92,15 +84,7 @@ view needs a wide viewport). So:
|
||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||
instance with no overlay is exactly how Codeman behaved before the local-
|
||||
echo overlay existed for touch devices — normal, not degraded, for a
|
||||
keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js,
|
||||
which has gained three pieces of the primary pane: hollow-buffer wheel
|
||||
paging (#555) and the desktop click report for a CLI with
|
||||
`cliMouseTracking` on, both through the primary pane's gates aimed at the
|
||||
tile, and its own keyCode-229 soft-keyboard controller
|
||||
(terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's
|
||||
edit-based diff, so an Android autocorrect is not sent twice). The 1180px
|
||||
width gate is all that keeps a phone out, and a wide Android tablet clears
|
||||
it. See that file's fileoverview.)
|
||||
keyboard-and-mouse user.
|
||||
|
||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||
parity is a scoped v2 (extract the shared logic already once you have two
|
||||
|
||||
@@ -192,7 +192,7 @@ start/stop wrapper to manage; tailscaled owns the lifecycle).
|
||||
| First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. |
|
||||
| `tailscale up` needs auth | Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. |
|
||||
| Custom `CODEMAN_PORT` | Serve target uses the actual port; `install.sh tailscale` re-reads it from the service file. |
|
||||
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedTunnelAcknowledged` and the login check in the `PUT /api/settings` tunnel guard). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. |
|
||||
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedNetworkAcknowledged`). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. |
|
||||
| Uninstall | Best effort: if `serve status --json` shows 443 proxying to our port, run the targeted `tailscale serve --https=443 off` (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. |
|
||||
| User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). |
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ This is exactly how `command-palette` already behaves: it is a full registry ent
|
||||
|
||||
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
|
||||
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
|
||||
⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged.
|
||||
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
|
||||
|
||||
### 2.6 Helpers that already exist and should be reused
|
||||
@@ -246,7 +245,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive
|
||||
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
|
||||
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
|
||||
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
|
||||
| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
||||
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
||||
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
|
||||
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
|
||||
|
||||
@@ -282,7 +281,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30
|
||||
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
|
||||
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
|
||||
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
|
||||
6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16).
|
||||
6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select.
|
||||
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
|
||||
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ Groups are always in this order, and a session is in exactly one of them:
|
||||
|
||||
| Group | Glyph | Means | Comes from |
|
||||
| --- | --- | --- | --- |
|
||||
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed; for Codex, a dialog read off its screen) |
|
||||
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed) |
|
||||
| | `✋` | Waiting for your next instruction, or errored | `idle_prompt`, or an errored session (equally something only a human clears) |
|
||||
| **WORKING** | `✻` animating | A turn is running | The same working classification the web dashboard uses |
|
||||
| **IDLE** | `○` | Live, but sitting there | |
|
||||
@@ -136,9 +136,9 @@ instead of failing silently; `Enter` and `1-9` keep working.
|
||||
|
||||
### `p` sends exactly one line
|
||||
|
||||
The composer is a single line by design, ending in a carriage return, because the
|
||||
server's submit check cannot reliably recognise a multi-line paste: a prompt it cannot
|
||||
see on the composer line is not re-submitted when the agent ignored the first Enter. Pasted newlines become spaces rather than being rejected, so
|
||||
The composer is a single line by design, ending in a carriage return: that is the
|
||||
input contract every Codeman path follows, because multi-line text breaks the
|
||||
agent's own composer. Pasted newlines become spaces rather than being rejected, so
|
||||
a paste cannot silently run a different command than the one you read.
|
||||
|
||||
## Answering approvals
|
||||
@@ -158,10 +158,6 @@ the TUI says `that dialog is no longer on screen` rather than pressing a key int
|
||||
live composer. The answer is scoped to the options the server parsed off the actual
|
||||
frame, never to a guess.
|
||||
|
||||
Only a Claude dialog can be answered this way. A Codex or DeepSeek Harness item is an
|
||||
alert: the server refuses the answer and the TUI shows its message saying to answer in
|
||||
the terminal, so attach with `Enter` and answer it in the pane.
|
||||
|
||||
An idle prompt (`✋`) is not a dialog: there is nothing to approve, so `p` is the
|
||||
reply path and the footer says `p reply` instead of `p prompt`.
|
||||
|
||||
@@ -178,11 +174,8 @@ bar first named the wrong letter (tmux binds lowercase `d` to `detach-client` an
|
||||
capital `D` to `choose-client`), and once corrected it still failed for anyone who
|
||||
kept Ctrl held, because that sends `Ctrl+D`, which tmux leaves unbound. So the TUI
|
||||
claims `F1` in tmux's prefix-less key table for the length of the attach and gives
|
||||
it back afterwards. The held-Ctrl chord (`Ctrl+B Ctrl+D`) works too, as the TUI
|
||||
claims it for the attach as well. Plain `Ctrl+B d` does not: on Codeman's socket
|
||||
the prefix table holds nothing but `send-prefix`, because the browser's terminals
|
||||
are tmux clients on the same server and must not be able to `switch-client` or
|
||||
`detach-client` themselves (`src/tmux-prefix-table.ts`).
|
||||
it back afterwards. The chord still works; it is simply not what you are told to
|
||||
press.
|
||||
|
||||
You do not have to remember any of it. For as long as the attach lasts the pane
|
||||
wears a bar across the top:
|
||||
@@ -193,10 +186,7 @@ wears a bar across the top:
|
||||
|
||||
That is the **session strip**: the other sessions stay visible from inside a pane,
|
||||
numbered exactly as the dashboard numbers them, with the one you are in inverted.
|
||||
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. Those keys
|
||||
run `switch-client` only for this terminal's tmux client, so a browser tab on the
|
||||
same socket cannot use them to jump sessions, Esc followed by a digit included (that
|
||||
tab's pane still receives the Esc and the digit). With
|
||||
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. With
|
||||
more sessions than fit, the strip shows a window around the current one and marks
|
||||
each cut end with `…`; the way-out hint is measured first and always keeps its space.
|
||||
|
||||
|
||||
@@ -46,8 +46,6 @@ Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a con
|
||||
|
||||
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
|
||||
|
||||
Since Claude Code v2.1.251 the payload also carries a `prompt_cache` object for the main conversation: `warm`, `ttl` (`5m` or `1h`), `expires_at` (epoch seconds), `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at` and `last_miss_cause`. `StatusTelemetrySchema` lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer); `parsePromptCache()` normalizes only the subset Codeman stores. The footer appends `cache:until HH:MM` while warm or `cache:cold` otherwise, and Claude re-runs the statusline when `expires_at` passes, so the cold flip arrives without a turn. The Session Options readout adds the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (the first warm report whose `recache_tokens_if_cold` is null after one that carried it; `expected_rebuilds` rises only at the next request, so it is not read; never raised as an alert). The last report is restored on a Codeman restart and the readout is time-aware, so a value whose expiry has passed shows as cold rather than stale-warm.
|
||||
|
||||
### Alternatives considered & rejected
|
||||
|
||||
| Source | Why not |
|
||||
|
||||
@@ -30,13 +30,8 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
||||
3. **Documented environment variables** that configure deployment:
|
||||
`CODEMAN_PASSWORD`, `CODEMAN_USERNAME`, `CODEMAN_HOST`, `CODEMAN_PORT`,
|
||||
`CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`,
|
||||
`CODEMAN_TMUX_SOCKET`, `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`,
|
||||
`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`, and the `--host` / `--port` /
|
||||
`--https` / `--allow-unauthenticated-network` /
|
||||
`--allow-unauthenticated-tunnel` CLI flags. Removing or changing the meaning
|
||||
of one of these is breaking. The two acknowledgements are listed from 1.46.0,
|
||||
the release that split them: the network one covers the non-loopback bind
|
||||
only, and an unauthenticated tunnel needs the tunnel one.
|
||||
`CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags.
|
||||
Removing or changing the meaning of one of these is breaking.
|
||||
4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the
|
||||
uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and
|
||||
conventional HTTP status codes. Endpoint paths, the response envelope, error
|
||||
@@ -45,10 +40,6 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
||||
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
||||
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
||||
is kept working for the bundled UI.
|
||||
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
|
||||
`#session=<id>` fragment selects that session if this client can see it. The
|
||||
fragment name and that meaning are stable; see
|
||||
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
|
||||
|
||||
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
||||
|
||||
@@ -62,49 +53,8 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
|
||||
programmatically is not supported (there is no stable library entry point).
|
||||
3. **Experimental / opt-in features**, regardless of the app's version:
|
||||
Gesture Control (beta), Agent Teams
|
||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge
|
||||
(`window.CodemanHost.openWindow` / `closeWindow` / `focusWindow`), and
|
||||
anything labeled experimental in the UI or docs. These may change or be
|
||||
removed at any time.
|
||||
|
||||
### Experimental agent coordination
|
||||
|
||||
`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and
|
||||
the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage`
|
||||
and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since
|
||||
every `/api` route is also served under `/api/v1`), are labeled experimental, so
|
||||
item 3 above applies to them: their commands, flags, endpoint paths, payloads and
|
||||
behavior may change or be removed in a MINOR or PATCH release. They are also
|
||||
opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced
|
||||
`agentCoordinationEnabled` setting (default OFF) until it is turned on, so
|
||||
automation cannot come to depend on them without choosing to. Pin a compatible
|
||||
Codeman version when automating them. Existing authentication, ownership and
|
||||
shared response-envelope rules still apply. The session behavior underneath them
|
||||
(input revision tracking in the session's write paths, the SubmitVerifier
|
||||
re-press going through the shared write boundary) runs for every session and is
|
||||
covered like any other stable behavior. Other agent commands and API routes keep
|
||||
their existing guarantees. Promotion to stable requires an explicit documented
|
||||
decision.
|
||||
|
||||
### Experimental local model routes
|
||||
|
||||
`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/cancel`, `/resume`
|
||||
and `/runtime`, and `DELETE /api/v1/local-model` (and their `/api` aliases) are
|
||||
labeled experimental, so item 3 above applies to them: their paths, their payloads
|
||||
(the hardware facts, the verdicts and reasons, the candidate fields, the `install`
|
||||
object with its states, steps, error codes and actions, the 409 conflict shape and
|
||||
every English sentence) and their behavior may change or be removed in a MINOR or
|
||||
PATCH release. They look at the Mac the server runs on, say which local model fits,
|
||||
and install, start, stop and remove one ([local-models.md](local-models.md)).
|
||||
|
||||
They are not behind a setting. Reading changes nothing; an install starts only on an
|
||||
explicit request that carries the size the person confirmed. All of them are admin
|
||||
only in multi-user mode. What an install leaves behind is ordinary, stable state: one
|
||||
saved model endpoint with the id `local-ollama` and the `customModelEndpointsEnabled`
|
||||
setting switched on, both of which the stable custom model endpoint routes read like
|
||||
any other endpoint. On-disk state (`local-model.json`, the `local-model/` folder) is
|
||||
internal, as for every other file in the data directory. Promotion to stable requires
|
||||
an explicit documented decision.
|
||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
|
||||
in the UI or docs. These may change or be removed at any time.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Agent CLIs
|
||||
|
||||
Codeman drives eleven run modes: ten agent CLIs plus a plain shell. This page covers picking
|
||||
Codeman drives ten run modes: nine agent CLIs plus a plain shell. This page covers picking
|
||||
one, setting it up, and the differences that actually change how you work.
|
||||
|
||||
## The eleven modes
|
||||
## The ten modes
|
||||
|
||||
| Mode | CLI | Get it |
|
||||
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
|
||||
@@ -16,7 +16,6 @@ one, setting it up, and the differences that actually change how you work.
|
||||
| **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) |
|
||||
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
|
||||
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
|
||||
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
|
||||
| **Terminal / Shell** | your `$SHELL` | Already installed. |
|
||||
|
||||
Any combination works, including all of them. The run mode is chosen per session from the
|
||||
@@ -51,10 +50,10 @@ If a CLI is installed but a Run button for it never appears:
|
||||
precisely to avoid this; a hand-written plist or unit will not.
|
||||
3. Restart the server after installing a new CLI.
|
||||
|
||||
`pi`, `grok`, `omp`, `copilot` and `dsh` are additionally identity-probed rather than trusted by name:
|
||||
`pi`, `grok`, `omp` and `dsh` are additionally identity-probed rather than trusted by name:
|
||||
`pi` and `omp` are generic enough that something else on your PATH may answer to them,
|
||||
`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
|
||||
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`) that reports
|
||||
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`) that reports
|
||||
the path and version that actually resolved, so a misresolution is visible rather than
|
||||
presenting as "the mode just does not work".
|
||||
|
||||
@@ -70,16 +69,16 @@ output. The other CLIs expose no equivalent.
|
||||
| Respawn cycling and unattended runs | Yes | Yes |
|
||||
| Cron jobs | Yes | Yes |
|
||||
| Docker cases, remote SSH cases | Yes | Yes |
|
||||
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
|
||||
| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
|
||||
| Auto-resume when a usage limit resets | Yes | No |
|
||||
| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no |
|
||||
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
|
||||
| Plan usage chip | Yes | No |
|
||||
| Approvals Inbox | Yes | DeepSeek yes; others no |
|
||||
| Read My Mind | Yes | No |
|
||||
| Ralph loop and its task tracker | Yes | No |
|
||||
| Subagent and team windows | Yes | No |
|
||||
| Model, effort, advisor, and ultracode controls | Yes | No |
|
||||
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly |
|
||||
| The bundled agent skill | Yes | Every CLI except Pi and Copilot, in `.agents/skills/` |
|
||||
| Model, effort, and ultracode controls | Yes | No |
|
||||
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
|
||||
| The bundled agent skill | Yes | No |
|
||||
|
||||
Everything that makes a session a session works everywhere. What is Claude-only is mostly
|
||||
the machinery that needs to know *what* the agent is doing rather than *that* it is doing
|
||||
@@ -97,9 +96,6 @@ The defaults you will care about, all under **App Settings**:
|
||||
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
|
||||
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
|
||||
environment variable, because that would hard-lock it and block in-session switching.
|
||||
- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points,
|
||||
via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft
|
||||
default: `/advisor` switches it or turns it off inside the session.
|
||||
- **Startup permission mode** (Agents & CLIs section). The default is
|
||||
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
|
||||
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
|
||||
@@ -122,65 +118,24 @@ Renders its own TUI, so Codeman treats readiness as output stabilization rather
|
||||
watching for a prompt marker. Requires tmux, with no direct-PTY fallback, because its
|
||||
environment is injected through socket-scoped `tmux setenv` rather than the command line.
|
||||
|
||||
Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at
|
||||
the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working
|
||||
session from an idle one. A pending permission prompt shows as idle, since it is waiting on
|
||||
you. Before 1.40.0 an OpenCode session that had run a tool showed as working for good.
|
||||
|
||||
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
|
||||
|
||||
### Codex
|
||||
|
||||
App Settings has synced **Default Codex model** and **Default Codex reasoning effort**
|
||||
controls. Enter a model ID supported by your Codex provider; available reasoning levels
|
||||
depend on the model and CLI version. Empty defaults use Codex's own configuration.
|
||||
The defaults apply to local Codex sessions started from the Run menu, from Resume, and through
|
||||
`POST /api/sessions` or `/api/quick-start`; scheduled (cron) jobs do not use them.
|
||||
Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence.
|
||||
Custom model endpoints, Docker containers and remote host command overrides keep their own settings.
|
||||
Changing a default affects new sessions and does not edit Codex configuration files.
|
||||
Two behaviours that are deliberate and worth knowing:
|
||||
|
||||
**Allow network access** reaches the same local launches. It opens the network only where
|
||||
Codex runs the folder in its workspace-write sandbox and does not change Codex's sandbox
|
||||
mode: a folder Codex runs read-only keeps the network closed, and Codex asks per command.
|
||||
With it on, commands in the sandbox can call Codeman's API, which can start a session
|
||||
outside the sandbox with no approval prompt, even when a password is set. See
|
||||
[The Codeman Skill](Codeman-Skill#when-the-agent-is-codex) and
|
||||
[Security](Security#what-codeman-does-not-protect-against).
|
||||
|
||||
Behaviours that are deliberate and worth knowing:
|
||||
|
||||
- **Each local session runs its own app server.** Codex 0.162 and later otherwise hands
|
||||
every client's commands to one shared background server, which runs them with the
|
||||
environment of whichever session started it, and a server left by another Codex version
|
||||
stops every launch on a dialog. So Codeman starts a local Codex 0.162 or later with
|
||||
`--no-daemon`. The trade-off, going by Codex's own help text: `codex agents` browses the
|
||||
sessions on that shared server and `codex remote-control` manages it, so do not expect
|
||||
either to see a session started this way. Remote and Docker launches do not get the
|
||||
flag, and neither does a local launch whose Codex version Codeman could not read.
|
||||
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
|
||||
a `/` opens a live-filtering picker, arrows edit server-side state. Buffering keystrokes
|
||||
until Enter starved it, so Codex paints each keystroke at the predicted cell while the
|
||||
bytes on the wire stay byte-identical to what you typed.
|
||||
- **Wheel forwarding follows mouse tracking.** Codex with mouse tracking on gets the wheel
|
||||
forwarded like fullscreen Claude; otherwise it pages its own transcript when local history
|
||||
is empty, or scrolls locally.
|
||||
- **The wheel is not forwarded** into its transcript. Codex ignores the mouse reports
|
||||
Codeman would send, so forwarding produced a dead wheel. Scrolling in a Codex session is
|
||||
local scrollback.
|
||||
- **Work detection is Codex's own.** Codex declares its `›` composer glyph and its
|
||||
`esc to interrupt` working line, so it gets the same screen-checked idle detection Claude
|
||||
does; before 1.26.1 every Codex session reported idle for its whole life. Codex
|
||||
conversations also appear in Past Sessions and can be resumed, and on phones the keyboard
|
||||
bar grows `⇧←` / `⇧→` for Codex's queued-message editing and prompt stack.
|
||||
- **A dialog waiting for you turns the tab red.** Codex is launched with its approvals on
|
||||
unless you tick the bypass option, and it has no hooks to say it stopped, so Codeman reads
|
||||
its dialogs off the screen: a command, edit or network approval, a question it asks you
|
||||
(Plan mode), and the "Trust this folder?" prompt when it starts in a new folder. Each one
|
||||
raises the same red "needs you" alert a Claude permission prompt does, with the desktop
|
||||
notification, web push, webhook, the phone overview's NEEDS YOU row and an Approvals Inbox
|
||||
card, and the alert clears a few seconds after you answer. Answer in the terminal: Codeman
|
||||
never answers a Codex dialog for you, so the inbox card and the push carry no Approve or
|
||||
Deny buttons. While such a dialog is up the session shows as idle (a question used to show
|
||||
as working), and agents can wait on it with the `blocked` signal. "Implement this plan?"
|
||||
at the end of a plan is not counted, since the turn has ended there.
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -190,28 +145,12 @@ needs `GOOGLE_CLOUD_PROJECT`, `GOOGLE_APPLICATION_CREDENTIALS`, and
|
||||
`GOOGLE_GENAI_USE_VERTEXAI`. That is the loosest allowlist entry in Codeman and it affects
|
||||
only the CLI you spawned yourself.
|
||||
|
||||
Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line
|
||||
(`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool
|
||||
confirmation that waits for you shows as idle. Before 1.40.0 a Gemini session showed as
|
||||
working for good after its first turn.
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of its state
|
||||
in `~/.gemini/antigravity-cli/`, so the credential handling that applies to Gemini applies
|
||||
to it as well.
|
||||
|
||||
- **Work detection reads its spinner row.** While a turn runs, `agy` animates an 8-dot braille
|
||||
frame and a label one row above the composer (`⣯ Generating...`; `Working...` and
|
||||
`Loading...` too), and its footer reads `esc to cancel`. Codeman matches that row, which is
|
||||
what puts the activity indicator on the tab. The frames are not the braille set Claude and
|
||||
Gemini use, which is why the tab used to look idle through a whole turn.
|
||||
- **At rest the footer hint is the idle mark.** The composer's `>` is no good for that, since
|
||||
the submitted prompt is echoed as `> <text>` as well. `? for shortcuts` is drawn only at
|
||||
rest, and the turn's last repaint carries it. A shell call that waits for approval replaces
|
||||
the composer and the footer hint is not drawn there, so the tab keeps reading as working
|
||||
until you answer.
|
||||
|
||||
### Pi
|
||||
|
||||
Pi needs the opposite instincts from every other CLI here.
|
||||
@@ -227,10 +166,6 @@ Pi needs the opposite instincts from every other CLI here.
|
||||
`HF_TOKEN`, and so on) share no common prefix, and the environment allowlist is global
|
||||
rather than per mode, so admitting them for Pi would widen the allowlist for every mode at
|
||||
once. They stay out.
|
||||
- **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it
|
||||
puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads
|
||||
that to tell working from idle. Before 1.40.0 a Pi session that had started a turn showed
|
||||
as working for good.
|
||||
|
||||
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
|
||||
|
||||
@@ -284,74 +219,10 @@ documented default approval mode is `yolo`, so an OMP pane auto-approves tool us
|
||||
flag from Codeman; change that in OMP's own config, not here.
|
||||
|
||||
OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
|
||||
same conversation with `--continue`. Codeman tells working from idle by reading OMP's status
|
||||
bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.40.0
|
||||
an OMP session that had started a turn showed as working for good.
|
||||
same conversation with `--continue`.
|
||||
|
||||
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
||||
|
||||
### GitHub Copilot CLI
|
||||
|
||||
GitHub's `copilot`, installed with `npm install -g @github/copilot` (CLI management can run
|
||||
that for you). Codeman recognises it only when `--version` answers `GitHub Copilot CLI`, and
|
||||
`GET /api/copilot/status` reports the path and version that resolved.
|
||||
|
||||
**Quota in the plan usage chip.** While Copilot CLI is installed and signed in, the chip gains
|
||||
a **Copilot** row with the calendar-month premium-request quota (requests used and the reset in
|
||||
the tooltip). This is **on by default**, like the Codex usage poll: every 10 minutes the server
|
||||
asks `api.github.com` for the quota with Copilot's own sign-in token (read-only, redirects
|
||||
refused, the token never logged or stored). The token is read from `COPILOT_GITHUB_TOKEN`,
|
||||
`GH_TOKEN` or `GITHUB_TOKEN`, or from the plain-text `~/.copilot/config.json`, so a sign-in kept
|
||||
in the OS keychain (a macOS or desktop Linux keyring) shows no row. Any failure just leaves the row out. Turn off
|
||||
**Plan Usage Limits** in App Settings to stop the request.
|
||||
|
||||
**Signing in.** Codeman does not log it in: run `copilot` once and use `/login`, or sign in
|
||||
inside a Codeman tab. Copilot keeps the token in the OS keychain when there is one; a headless
|
||||
server has none, so it offers to save it as **plain text in `~/.copilot/config.json`**, which is
|
||||
what a service ends up with. To keep a token out of a file, give the service
|
||||
`COPILOT_GITHUB_TOKEN` (Copilot reads it first, then `GH_TOKEN`, `GITHUB_TOKEN`). The token is
|
||||
never copied into a [Docker case](Docker-Cases): sign in inside the container.
|
||||
|
||||
**Run button and permissions.** Run starts `copilot --yolo` (allow every tool, path and URL),
|
||||
the same bypass switch the other agent CLIs get, so an unattended session never stops on an
|
||||
approval. A multi-user user without the bypass grant gets a plain `copilot`, which is
|
||||
Copilot's own Manual Approval mode. Model, permission mode and the rest are also changeable
|
||||
inside its TUI.
|
||||
|
||||
**Options and resume.** `--model` is sent only by an API caller that sets `copilotConfig.model`; the Run menu has no Copilot model picker (a saved custom endpoint sets `COPILOT_MODEL` instead). A session from
|
||||
Past Sessions continues with `--continue`, which resumes the most recent Copilot conversation
|
||||
in that directory. A tab name you set yourself is passed as `--name`, so Copilot's own session
|
||||
list reads like that tab (an automatic `w1-myapp` name is not pinned); Copilot refuses `--name`
|
||||
together with `--resume`/`--continue`, so a continued session keeps the name it had.
|
||||
|
||||
**Your own endpoint.** With [custom model endpoints](Custom-Model-Endpoints) on, a saved
|
||||
endpoint appears in the Run menu for Copilot. It is launched with Copilot's BYOK variables
|
||||
(`COPILOT_PROVIDER_BASE_URL` with the `/v1` an OpenAI route needs, `COPILOT_PROVIDER_API_KEY`,
|
||||
`COPILOT_MODEL`), needs no GitHub sign-in, and applies at launch with no restart.
|
||||
|
||||
**Working and idle.** A turn shows `◉ Working` in the footer, which is what Codeman reads;
|
||||
a tool waiting for your approval replaces the composer and reads as idle. The model name is
|
||||
read from the footer too. Copilot has no hooks, so the `stop` and `blocked` wait signals are
|
||||
not available: wait on `idle`, `exit` or an output marker.
|
||||
|
||||
**Selecting and scrolling.** Copilot turns mouse tracking on itself. Codeman strips that, so a
|
||||
plain drag selects text (and Auto Copy works) and clicks still reach Copilot. The wheel, and a
|
||||
swipe on a phone, scroll Copilot's own transcript; Shift+wheel scrolls Codeman's local history.
|
||||
|
||||
**MCP servers.** [MCP server sync](Settings-Reference) includes Copilot's
|
||||
`~/.copilot/mcp-config.json` (relocated by `COPILOT_HOME`), as a target and a source.
|
||||
|
||||
**From another agent.** The `codeman agent` verbs work from inside a Copilot tab and against
|
||||
Copilot workers: `spawn` waits for the composer (not the folder-trust dialog), `send --wait`
|
||||
resolves on `idle`, and `read` returns the last answer from the pane's visible screen. Tool-call
|
||||
rows are not modelled and read as part of the answer, and `--until stop` is refused as for
|
||||
every hook-less mode.
|
||||
|
||||
**Not available.** Auto-resume on a usage limit, the Approvals Inbox, Read My Mind and the
|
||||
Ralph tracker, as for the other non-Claude CLIs.
|
||||
|
||||
Guide: [`docs/copilot-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/copilot-integration.md).
|
||||
|
||||
### Terminal / Shell
|
||||
|
||||
A plain shell in a tmux session. No agent, no hooks, no idle detection.
|
||||
@@ -378,7 +249,6 @@ respawns. Which variables are accepted depends on the mode:
|
||||
| Grok | `GROK_*`, `XAI_*` |
|
||||
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
||||
| OMP | `OMP_*` |
|
||||
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
|
||||
|
||||
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
|
||||
is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
|
||||
@@ -394,10 +264,9 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
|
||||
|
||||
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
|
||||
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
|
||||
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot** when you prefer that agent or that
|
||||
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP** when you prefer that agent or that
|
||||
model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
|
||||
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
|
||||
see [Codex](#codex) above).
|
||||
hook-driven features.
|
||||
- **DeepSeek Harness** if you want DeepSeek's models with real status signals. It is the one
|
||||
non-Claude mode that reports idle, working and blocked to Codeman itself.
|
||||
- **Pi** if you want a fast, unsandboxed agent and you understand what project trust does.
|
||||
|
||||
@@ -32,7 +32,7 @@ The shape of it:
|
||||
- The loop ends when the agent signals completion, when the iteration cap is reached, or
|
||||
when you stop it.
|
||||
|
||||
Start it from **Session Options → Ralph / Todo**.
|
||||
Start it from **Session Options → Ralph / Todo**, or from the wizard on the welcome screen.
|
||||
|
||||
| Setting | What it does |
|
||||
| ---------------------- | ------------------------------------------------------------------------ |
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
# The Codeman Skill
|
||||
|
||||
The codeman skill lets the agent inside a Codeman session drive Codeman itself. You ask in
|
||||
plain English, and the agent starts worker sessions in any installed CLI, hands each one its
|
||||
task, waits for them, reads their answers and cleans up after itself. No curl, no endpoint
|
||||
names, no session ids in your prompt.
|
||||
|
||||
It is one skill for every agent CLI, in the `SKILL.md` format that Claude Code, Codex,
|
||||
Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. It lives in the repo at
|
||||
[`skills/codeman`](https://github.com/Ark0N/Codeman/tree/master/skills/codeman).
|
||||
|
||||
## See it work
|
||||
|
||||
Three real runs on a live Codeman: real sessions, real agents, one typed prompt each.
|
||||
Nothing on screen is mocked up; the long waits are sped up and labelled as time-lapse.
|
||||
|
||||
### Five workers in the tile grid
|
||||
|
||||
The lead Claude Code session gets `use codeman to start 5 claude workers, one per TODO item`,
|
||||
and five Claude Code workers power on, one per item, with all six agents working at once.
|
||||
|
||||

|
||||
|
||||
Sessions an agent starts do not join the grid by themselves. In this recording each worker
|
||||
was put on the grid as it started, the way `Ctrl`+click or dragging its tab does it: see
|
||||
[Tile Grid](Tile-Grid).
|
||||
|
||||
### Eight workers, eight tabs
|
||||
|
||||
The same prompt with eight workers, in the normal view. Every worker gets its own tab, and a
|
||||
lineage line runs from the lead to each one, so you can see who spawned whom. Clicking a
|
||||
worker's tab shows it at work.
|
||||
|
||||

|
||||
|
||||
### Two CLIs from one prompt
|
||||
|
||||
Workers do not have to run the same CLI as the lead. Here the prompt is
|
||||
`use codeman to have a dsh worker on qwen build search and a claude one build checkout`: a
|
||||
DeepSeek Harness worker on a local qwen model and a Claude Code worker power on side by side.
|
||||
|
||||

|
||||
|
||||
## Install it
|
||||
|
||||
Usually you do not have to: since 1.50.0 Codeman puts the skill into every new session's
|
||||
folder by default (the Web UI row below), where that session's CLI looks for skills. The copy
|
||||
ignores itself in git, so your repository never lists it as a new file.
|
||||
|
||||
Otherwise pick one route. The skill only acts inside a Codeman session, so a global install is safe.
|
||||
|
||||
| How | Command | Scope |
|
||||
| ------------------ | ------------------------------------------------------------------------------- | ----- |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. The way to give it to pi. |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. |
|
||||
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
|
||||
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. On by default since 1.50.0. |
|
||||
|
||||
Use the plugin **or** `codeman skill install`, not both: a Claude Code with both lists the
|
||||
skill twice (`codeman` and `codeman:codeman`). The Web UI copy counts too, and it is on by
|
||||
default: with the plugin installed, Claude Code sessions in Codeman list the skill twice.
|
||||
Both work. Turn **Codeman Agent Skill** off if the duplicate bothers you (other CLIs then
|
||||
lose their copy too), or use the Web UI copy instead of the plugin.
|
||||
|
||||
The web UI route skips pi, because a project skill makes pi ask to trust the folder at
|
||||
startup. A pi or plain shell session uses the [`codeman agent`
|
||||
commands](Driving-Codeman-From-An-Agent#the-codeman-agent-commands) instead, which are the
|
||||
same verbs as shell commands.
|
||||
|
||||
### Remove it
|
||||
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every
|
||||
Codeman-managed copy in that case, whichever CLI's directory it sits in). It never touches a
|
||||
`skills/codeman` you wrote yourself.
|
||||
|
||||
To stop the Web UI copy, turn **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent
|
||||
Skill** off. It is on by default since 1.50.0: a settings file without the setting means on,
|
||||
and only switching it off turns it off. Existing installs that stored off are switched on
|
||||
once at the first 1.50.0 start (every App Settings save on an earlier version stored the old
|
||||
default, off, whether or not you touched this switch); turning it off afterwards sticks.
|
||||
|
||||
Turning the web UI setting off **does not remove copies it already injected**, because
|
||||
a sweep at session start would pull the skill out from under other live sessions sharing
|
||||
that directory. Remove them per case with `codeman skill uninstall --case <name>`, which
|
||||
also removes the copy's `.gitignore`.
|
||||
|
||||
## Then just ask
|
||||
|
||||
These prompts work as written:
|
||||
|
||||
| You say | What happens |
|
||||
| ----------------------------------------------------------------------------------------- | ------------ |
|
||||
| "What sessions are running right now?" | Lists them with name, mode and status. Read-only. |
|
||||
| "Use codeman to start 5 claude workers, one per TODO item." | One worker per item, each handed its task, answers gathered as they finish. |
|
||||
| "Start a shell worker on the `myapp` case, run the test suite, tell me if it passes." | Spawns, waits on a completion marker, reads the exit code, cleans up. |
|
||||
| "Spin up 3 workers for lint, typecheck and tests, run them in parallel, report failures." | One session per task, all started first, then gathered as each finishes. |
|
||||
| "Have a claude worker summarize `src/session.ts`, then close it." | Spawns, waits until the worker is ready, sends and waits, reads the answer, deletes the session. |
|
||||
| "Have a DeepSeek Harness worker build search and a claude worker build checkout." | A mixed fleet: one worker per CLI, started in one go. |
|
||||
| "Use the local model to summarize every file in `docs/`, then check its summaries." | A worker on the model Codeman installed on this Mac does the bulk; the lead reads its answer back and reviews it. Without a local model, it says why. |
|
||||
| "Watch session w4 and tell me if it gets stuck on a permission prompt." | Blocks on the `blocked` signal and brings the question to **you**. |
|
||||
|
||||
Sessions the agent creates are deleted when it is done, so you can watch the tabs appear and
|
||||
disappear in the dashboard while it works.
|
||||
|
||||
### A run, start to finish
|
||||
|
||||
> **You:** spin up 3 shell workers, run lint / typecheck / the frontend syntax check in
|
||||
> parallel, and tell me which failed.
|
||||
|
||||
```text
|
||||
lint -> 9f2d8e5f dispatched
|
||||
typecheck -> aff9c691 dispatched 3 tabs appear in the dashboard
|
||||
syntax -> be9f1f15 dispatched
|
||||
|
||||
lint DONE_lint_17909 rc=0
|
||||
typecheck DONE_typecheck_3409 rc=0 gathered as each one finishes
|
||||
syntax DONE_syntax_18501 rc=0
|
||||
|
||||
deleted 9f2d8e5f, aff9c691, be9f1f15 tabs disappear
|
||||
```
|
||||
|
||||
The `DONE_<task>_<random>` strings are the
|
||||
[split-marker trick](Driving-Codeman-From-An-Agent#the-split-marker-trick): a shell session
|
||||
has no "turn finished" signal, so the skill waits for a marker in the output, typed in two
|
||||
halves so that only the command's real output ever contains the joined string.
|
||||
|
||||
## Which workers it can drive
|
||||
|
||||
Any run mode installed on the machine can be a worker. Claude Code and DeepSeek Harness are
|
||||
the two with real completion signals (Claude's from its hooks, DeepSeek's from the harness),
|
||||
so the skill can wait for "turn finished" and read the answer as clean text. Every other CLI
|
||||
and plain shell sessions are synchronized on an output marker instead, which works the same
|
||||
from your side. See [Agent CLIs](Agent-CLIs) for the run modes.
|
||||
|
||||
**The model on this Mac is a worker too.** When Codeman has installed a local model
|
||||
([A model that runs on this Mac](Custom-Model-Endpoints#a-model-that-runs-on-this-mac)), a
|
||||
Claude Code lead can hand work to it: Claude in the cloud plans and reviews, the local model
|
||||
takes summaries, first drafts and files that should stay on the machine. It answers one
|
||||
request at a time and is slower than a cloud model, and the skill never installs one for you.
|
||||
|
||||
With Claude Code workers, where the installed Claude Code supports it, the lead can also
|
||||
message a worker directly instead of typing into its terminal.
|
||||
|
||||
## What it will and will not do
|
||||
|
||||
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses every live
|
||||
API operation and never guesses an API URL, so a global install cannot drive a server it
|
||||
is not part of. The one exception is reading saved Codex conversations with
|
||||
`codeman history read` (see [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#reading-saved-codex-conversations)),
|
||||
a local, read-only command that needs no server or session.
|
||||
- **Unprompted, it may only** start sessions, prompt them, and delete the ones **it created
|
||||
in that conversation, by exact id**, behind a guard that refuses to delete the agent's own
|
||||
session.
|
||||
- **It will not** answer another session's permission prompt on your behalf. It brings the
|
||||
question to you instead.
|
||||
- **Only when you ask, naming the target:** deleting a case (which erases a real directory
|
||||
of your code), bulk kills, respawn, Ralph, cron, the orchestrator, and settings changes.
|
||||
|
||||
## When the agent is Codex
|
||||
|
||||
Two things to know:
|
||||
|
||||
- Codex's default sandbox blocks network access, even to this machine, so a Codex session
|
||||
needs **Allow network access** (App Settings, Codex) before it can start and drive
|
||||
others. The setting is app-wide: it applies to every local Codex session started after
|
||||
you switch it on, not only the ones you mean to orchestrate. It also has a cost: commands
|
||||
in Codex's sandbox can then call Codeman's API, which can start a session outside the
|
||||
sandbox with no approval prompt, even when a password is set, so the sandbox no longer
|
||||
contains a prompt-injected command. See
|
||||
[Security](Security#what-codeman-does-not-protect-against). It opens the network only
|
||||
where Codex runs the folder in its workspace-write sandbox, which is what Codex does for
|
||||
a project you trusted. It does not change Codex's sandbox mode: a folder Codex runs
|
||||
read-only (a new case that is not a git repository, for one) keeps the network closed,
|
||||
and Codex asks you to approve each call to Codeman. Trust the project in Codex, or use `/permissions` in
|
||||
Codex for that session, to get workspace-write there. **Bypass approvals and sandbox**
|
||||
works everywhere, but removes the sandbox and the approval prompts.
|
||||
- Codex 0.162 runs its commands in a shared background process, whose Codeman variables can
|
||||
belong to the session that started it. Codeman starts a local Codex 0.162 or later with
|
||||
`--no-daemon`, so each session runs its own. A remote or Docker session does not get the
|
||||
flag, and neither does a local one whose Codex version Codeman could not read; there,
|
||||
and with an older Codex, the skill notices and says so instead of acting.
|
||||
|
||||
OpenCode and DeepSeek Harness need nothing extra: in their default setup neither keeps its
|
||||
shell commands off the network, so the skill works there as soon as it is injected.
|
||||
|
||||
## What is in the skill
|
||||
|
||||
| File | Contents |
|
||||
| ---- | -------- |
|
||||
| [`SKILL.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/SKILL.md) | Safety rules, the fast path (start N workers, task them, collect the answers) and the verb index. Always loaded. |
|
||||
| [`reference/verbs.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/verbs.md) | Every verb in detail: readiness, send-and-wait, markers, interrupts, cleanup. Loaded on demand. |
|
||||
| [`reference/recipes.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/recipes.md) | Worked flows: claude, DeepSeek Harness and shell workers, fan-out, watching a blocked worker, messaging. Loaded on demand. |
|
||||
| [`reference/endpoints.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/endpoints.md) | Endpoint tables, error codes, the signals each mode emits, capacity limits. Loaded on demand. |
|
||||
| [`reference/messaging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/messaging.md) | Talking to claude workers directly through Claude Code's cross-session messaging. Loaded on demand. |
|
||||
| [`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. Loaded on demand. |
|
||||
| [`reference/inspection-and-staging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in (App Settings, Experimental agent coordination): bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. Loaded on demand. |
|
||||
|
||||
Only `SKILL.md` sits in the agent's context; the references load when a job needs them, so a
|
||||
quick run stays quick.
|
||||
|
||||
## Read next
|
||||
|
||||
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent`
|
||||
commands, the MCP server, and the raw HTTP rules the skill is built on.
|
||||
- [Tile Grid](Tile-Grid) - watching several workers side by side.
|
||||
- [The Dashboard](The-Dashboard) - tabs, lineage lines and status dots.
|
||||
- [Watching Agents Work](Watching-Agents-Work) - the subagents inside each session.
|
||||
@@ -1,257 +0,0 @@
|
||||
# Config Backups
|
||||
|
||||
Codeman keeps timestamped copies of the files that hold what you have customized, so a bad
|
||||
write, a hand edit or a botched restart can be undone. It is on by default, needs no setup,
|
||||
and everything about it (where the copies go, how many are kept, how long) is a setting.
|
||||
|
||||
## What is backed up
|
||||
|
||||
A fixed list of files from the data directory (`~/.codeman/`, or the directory of the
|
||||
[instance](Core-Concepts#instances) you run):
|
||||
|
||||
| File | What it holds |
|
||||
| ---- | ------------- |
|
||||
| `settings.json` | Your preferences that sync across devices, including model and CLI settings. |
|
||||
| `clis.json` | CLI enable/disable and custom CLI entries. |
|
||||
| `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** |
|
||||
| `webhook.json` | Webhook notification target. |
|
||||
| `discord.json` | Discord bridge settings. **Holds a bot token.** |
|
||||
| `intents.json` | Read My Mind per-case profiles. |
|
||||
| `linked-cases.json` | Registered cases. |
|
||||
| `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. |
|
||||
| `webviews.json` | Saved dashboard URLs. |
|
||||
| `push-keys.json` | Web-push keys (regenerating them drops every device's subscription). |
|
||||
| `users.json` | Multi-user accounts. **Holds password hashes.** |
|
||||
| `.env` | Credentials the TUI client reads. **Holds a password.** |
|
||||
|
||||
Only files that exist are included. This is an allowlist: nothing else in the data
|
||||
directory is ever copied.
|
||||
|
||||
**Not backed up:** `state.json` (sessions, and also cron jobs and respawn presets: it is rewritten
|
||||
with every session event, so it would bury the real changes), logs, `mux-sessions.json`, the hook
|
||||
secret, Docker seeds and caches. Back `state.json` up with your normal file backups if you rely on
|
||||
cron jobs.
|
||||
|
||||
## When a backup is made
|
||||
|
||||
- **At startup**, so the state the server found is always on record.
|
||||
- **Whenever the content changes.** The files are read and hashed every five minutes; a new
|
||||
backup is written only if the set differs from the latest backup. An idle server writes
|
||||
nothing, and the history is one entry per real change rather than one per timer tick.
|
||||
A field a file rewrites on its own is not a change, and the comparison looks through it (the
|
||||
snapshot still stores the file exactly as written, and a restore puts those fields back as they
|
||||
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
|
||||
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
|
||||
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
|
||||
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
|
||||
`lastOpenedAt` (opening a web tab). Without that, every
|
||||
tick looked like an edit and 20 copies covered only about 100 minutes. So Compare shows
|
||||
`intents.json` as unchanged when only prompts differ, yet a restore drops the prompts captured
|
||||
since.
|
||||
- **On demand**, with **Back up now** (always writes one, even if nothing changed).
|
||||
- **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced (unless
|
||||
nothing changed since the newest backup).
|
||||
|
||||
## Where they go and what they are called
|
||||
|
||||
The default folder is `<data dir>/backups/config` (`~/.codeman/backups/config`). You can point
|
||||
it anywhere with an absolute path (a leading `~` is expanded). A path that is not absolute, or
|
||||
that is the data directory itself, falls back to the default.
|
||||
|
||||
Each backup is one folder named for the moment it was made, in the server's local time:
|
||||
|
||||
```
|
||||
~/.codeman/backups/config/
|
||||
cfg-20261011-120305/
|
||||
manifest.json id, createdAt (epoch ms), reason, appVersion, hash, origin,
|
||||
keepCount and keepDays in force, SHA-256 + size per file
|
||||
settings.json the original file names, unchanged
|
||||
clis.json
|
||||
custom-model-hosts.json
|
||||
...
|
||||
cfg-20261011-123012/
|
||||
cfg-20261011-123012-2/ a second backup in the same second gets -2, -3, ...
|
||||
```
|
||||
|
||||
| Part | Meaning |
|
||||
| ---- | ------- |
|
||||
| `cfg-YYYYMMDD-HHMMSS` | Date and time (server local time) the backup was made. A collision in the same second gets `-2`, `-3`, … |
|
||||
| `manifest.json` | The record of the backup: `reason`, `appVersion`, the change-check `hash`, the `origin` id, the retention in force (`keepCount`, `keepDays`), and a SHA-256 and size for every file. |
|
||||
| `reason` | `startup`, `auto` (a change was detected), `manual` (Back up now) or `pre-restore`. It is in the manifest and the Settings list, not in the folder name. |
|
||||
| `.tmp-…` | A backup being written. It is renamed into place when complete and is never listed. |
|
||||
|
||||
A folder counts as a backup only if its name matches that pattern **and** it has a valid
|
||||
`manifest.json`. That is what makes it safe to point the folder at a directory that also holds
|
||||
other things.
|
||||
|
||||
## Retention
|
||||
|
||||
Two limits, applied after every backup:
|
||||
|
||||
| Setting | Range | Default | Meaning |
|
||||
| ------- | ----- | ------- | ------- |
|
||||
| **Keep this many backups** (`configBackupKeepCount`) | 2 – 500 | 20 | Keep the newest N; older ones are removed. (1 is not offered: every change would delete the state before it.) |
|
||||
| **Delete backups older than (days)** (`configBackupKeepDays`) | 0 – 3650 | 30 | Also remove a backup once it has been replaced for longer than this. `0` means no age limit. |
|
||||
|
||||
Both apply together. Four guarantees:
|
||||
|
||||
- **The newest backup is never removed**, whatever the numbers say, so there is always one to
|
||||
go back to.
|
||||
- **Only folders Codeman made are ever removed**, never anything else that happens to live in a
|
||||
custom folder.
|
||||
- **Age counts from when a backup was replaced**, not from when it was made. Backups are made on
|
||||
change, so a config that stays the same for months has one backup whose own date keeps
|
||||
ageing; it is the copy you need the day something goes wrong, so it stays until a newer one
|
||||
has existed for longer than the limit.
|
||||
- Because backups are made on change, 20 backups reach back across 20 *changes*, however long
|
||||
that took, not 20 timer ticks.
|
||||
|
||||
## Settings → System → Config backups
|
||||
|
||||
| Control | Setting | Default |
|
||||
| ------- | ------- | ------- |
|
||||
| Back up my configuration | `configBackupEnabled` | On (absent means on) |
|
||||
| Backup folder | `configBackupDir` | empty = `<data dir>/backups/config` |
|
||||
| Keep this many backups | `configBackupKeepCount` | 20 |
|
||||
| Delete backups older than (days) | `configBackupKeepDays` | 30 |
|
||||
| **Back up now** | | |
|
||||
| The list, with **Restore**, **Compare with current** and **Changes in this backup** per entry | | |
|
||||
| **Compare** … with … **Show changes** | | |
|
||||
|
||||
The settings are saved server-side and read fresh, so a change applies on the next pass. The
|
||||
list and **Back up now** use the **saved** folder and retention: apply your edits first.
|
||||
|
||||
## Restoring
|
||||
|
||||
From the list, press **Restore** on a backup and confirm. Codeman then:
|
||||
|
||||
1. **Verifies** every file in the backup against the SHA-256 in its manifest. If any file is
|
||||
missing or does not match, nothing is changed and you get an error. It also refuses a backup
|
||||
folder or file that is a symlink or that group or others can read or write (Codeman never
|
||||
makes one like that, and writing it back as `0600` would turn it into trusted config). The
|
||||
permission test is skipped when the backup folder's filesystem does not keep file modes
|
||||
(FAT/exFAT drives, SMB shares, WSL's `/mnt/d` without the `metadata` option report the same
|
||||
mode for every file, so it would refuse every backup there). The symlink test is not. Compare
|
||||
applies the same checks to the backups it reads.
|
||||
2. **Takes a `pre-restore` backup** of the files about to be replaced, so a restore can itself
|
||||
be undone. When nothing changed since the newest backup (the same comparison an automatic
|
||||
backup makes), no copy is made and the response names that backup instead (`safetySnapshot`).
|
||||
3. **Replaces each file atomically** (written beside it, then renamed) with mode `0600`.
|
||||
|
||||
Only the tracked names are ever written, whatever a manifest lists.
|
||||
|
||||
If a file cannot be written (a full disk, a folder where the file should be), the restore still
|
||||
writes the others and then reports an error that names the files it restored, the ones it could
|
||||
not write, and the backup that holds the files as they were before. The restored files are in
|
||||
effect as after a full restore (Settings re-reads `settings.json` into the form), so fix the cause
|
||||
and restore again, or restore that backup to go back.
|
||||
|
||||
Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many
|
||||
days) at their current values, so a restore from before a folder change does not point the list
|
||||
at the old folder and hide the `pre-restore` copy it just made. The exception is the two
|
||||
retention values: where the live `settings.json` lacks one (a reset file), the restore keeps the
|
||||
backup's own, key by key, so the next pass does not fall back to 20 copies and 30 days. The
|
||||
`pre-restore` copy records the retention in force too.
|
||||
|
||||
Settings are re-read on the next request, so reload the page. `.env` and `push-keys.json` are
|
||||
read once per process: **restart Codeman** for those to take effect (the response lists which).
|
||||
`clis.json` is reloaded by the restore itself and `users.json` is re-read within a second. Restoring `settings.json` also does not re-run the switches
|
||||
that saving in Settings applies at once (file watchers and the like), so those apply after a
|
||||
restart, and `settings.json` is listed with the restart-required files. A restore does not touch
|
||||
running sessions.
|
||||
|
||||
When the backup folder is shared (a synced folder used by two machines, or prod and a beta),
|
||||
each snapshot records a random id that the instance made once and keeps in its data directory
|
||||
(`config-backup-id`; not the hostname, which a Docker container changes on every recreate). Every
|
||||
instance only snapshots, lists, prunes, compares and restores its own. Deleting that file makes
|
||||
the instance a new owner, and its earlier snapshots stop being listed (they stay on disk). The
|
||||
same happens after a reinstall of the data directory. To see them again, copy the `origin` value
|
||||
from any of their `manifest.json` files into `<data dir>/config-backup-id`.
|
||||
|
||||
You can also restore by hand: the files in a backup folder are the originals, so copying one back
|
||||
over the file in the data directory is equivalent (stop Codeman first if you are replacing
|
||||
`clis.json` or `users.json`).
|
||||
|
||||
## Comparing backups
|
||||
|
||||
Before you restore, or to find out what changed and when, use **Compare** in the list:
|
||||
|
||||
- **Compare with current** on a backup shows what differs between that backup and the config
|
||||
running now. A restore puts back the files the backup holds; files created since are left as
|
||||
they are, and the restore response names them (`untouched`).
|
||||
- **Changes in this backup** shows what differs between the backup before it and this one.
|
||||
- The **Compare … with … Show changes** pickers compare any two backups, or a backup with the
|
||||
current config.
|
||||
|
||||
Each file that differs is listed with its changed keys (`theme.mode`, `tags[1]`, a variable name
|
||||
in `.env`) as before → after, plus files that appeared or vanished between the two states. Files
|
||||
that are the same are listed on one line. A file that is not JSON shows only that it differs, and
|
||||
a file with more than 200 changes is cut short, so use the files themselves for a full diff. The
|
||||
self-rewritten fields listed under "When a backup is made" are not counted as changes.
|
||||
|
||||
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret), `discord.json` (the
|
||||
bot token) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
|
||||
credential or key is shown as "changed" without its value. URL values lose any `user:password@`,
|
||||
query and fragment.
|
||||
|
||||
## Security
|
||||
|
||||
- Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`,
|
||||
`push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default
|
||||
location exposes nothing the data directory does not already.
|
||||
- **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a
|
||||
network share copies those credentials there, with whatever permissions that location has.
|
||||
Choose a private location, or turn the feature off if you cannot.
|
||||
- **Retention survives a bad `settings.json`.** If it does not parse, a pass still takes its
|
||||
snapshot but deletes nothing. If a retention setting is missing (a reset file), the pass
|
||||
prunes with the retention the newest backup that recorded one holds, not the 20 copies and 30
|
||||
days defaults, and Settings shows those values. Saving sends a retention value only if it was
|
||||
saved before or you edited it there, so saving other settings (or re-entering a custom folder
|
||||
after a reset) does not write the displayed 20 and 30 over it; the first pass in the re-entered
|
||||
folder finds the retention its own backups recorded. While `settings.json` does not
|
||||
parse, a pass reads it as empty, so its snapshot goes to the default folder even if a custom
|
||||
folder is set or backups are switched off; nothing is widened (same modes, inside the data
|
||||
directory).
|
||||
- **Anyone who can write to a custom backup folder can plant a backup that Restore applies.**
|
||||
Restore refuses symlinks and files others can write, but a file of your own user's making
|
||||
passes, so keep the folder private.
|
||||
- In [multi-user mode](Multi-User-Mode) all of it is admin only: the list, Back up now,
|
||||
Restore and Compare, and each restore is written to the admin audit log
|
||||
(`config_backup_restore`).
|
||||
- Listing returns file names and sizes, never contents. A comparison returns changed values but
|
||||
never a credential's (see [Comparing backups](#comparing-backups)).
|
||||
|
||||
## HTTP API
|
||||
|
||||
```bash
|
||||
API=http://localhost:3000
|
||||
curl -s "$API/api/config-backups" | jq # settings, folder, list
|
||||
curl -s -X POST "$API/api/config-backups" | jq # back up now
|
||||
curl -s -X POST "$API/api/config-backups/cfg-20261011-120305/restore" | jq
|
||||
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305" | jq # that backup vs the running config
|
||||
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305&to=cfg-20261012-090000" | jq
|
||||
```
|
||||
|
||||
See [HTTP API](HTTP-API) and the endpoint reference in `docs/api-reference.md`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The list is empty.** None of the tracked files existed yet, or backups are turned off. Press
|
||||
**Back up now**.
|
||||
- **"Backups are not available here".** You are not an admin in multi-user mode.
|
||||
- **No new backup after I changed a setting.** Changes are picked up within five minutes; **Back up
|
||||
now** takes one immediately.
|
||||
- **A restore said a file does not match its checksum.** The backup was altered or damaged on
|
||||
disk. Nothing was changed; restore an earlier one.
|
||||
- **The backups folder is growing.** Lower **Keep this many backups**. Each backup is a few
|
||||
kilobytes, but `custom-model-hosts.json` and `intents.json` can be larger.
|
||||
- **I want my own off-machine copy.** Point the folder at a location your backup tool already
|
||||
covers, mind the Security note above, and keep **Keep this many backups** high enough that
|
||||
your tool sees each one.
|
||||
|
||||
## Read next
|
||||
|
||||
- [Settings Reference](Settings-Reference) - where the controls live.
|
||||
- [Core Concepts](Core-Concepts) - what is on disk.
|
||||
- [Security](Security) - what is protected and how.
|
||||
- [Troubleshooting](Troubleshooting) - "my settings were reset".
|
||||
@@ -42,31 +42,17 @@ npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax
|
||||
npm run check:browser-excludes
|
||||
npm test # the gate, exactly what CI runs
|
||||
npm test -- test/<file>.test.ts # one file
|
||||
npm test -- test/<file>.test.ts # one file, the normal way
|
||||
npm run test:ci # the full CI sweep
|
||||
```
|
||||
|
||||
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
||||
machine-dependent) and blocks a push that would fail them. It skips itself when you push
|
||||
something other than the checked-out HEAD, or when the tree has uncommitted changes the
|
||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||
`pre-push` hook of your own is never overwritten.
|
||||
|
||||
`npm test` runs the same suite CI runs, so a green run locally means a green run there. A bare
|
||||
`npx vitest run` resolves the same isolated config (a root `vitest.config.ts`), so it never
|
||||
touches your real `~/.codeman`; it did before that file existed. `npm test` also
|
||||
leaves out three suites that cannot pass on an arbitrary machine, each with its own command:
|
||||
`npm run test:browser` (Playwright, Chromium and a live server), `npm run test:mobile` (the
|
||||
same plus environment-specific screenshot baselines) and `npm run test:perf` (wall-clock
|
||||
benchmarks for an otherwise idle machine). Expect those to fail where the machine cannot
|
||||
provide what they need; that means "not runnable here", not a regression.
|
||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
||||
|
||||
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
|
||||
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
|
||||
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
|
||||
or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
|
||||
read `server.boundPort`. Never 3000.
|
||||
tests cannot touch real sessions. If you add a test that binds a port, pick a unique one at
|
||||
3150 or above, and never 3000.
|
||||
|
||||
## Finding your way around
|
||||
|
||||
@@ -122,7 +108,7 @@ Conventions for wiki pages:
|
||||
- Images are referenced from the main repository over raw URLs rather than being copied into
|
||||
the wiki.
|
||||
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
|
||||
- Label Claude-only behaviour every time it appears. Ten of the eleven run modes are not
|
||||
- Label Claude-only behaviour every time it appears. Nine of the ten run modes are not
|
||||
Claude.
|
||||
|
||||
## Conduct
|
||||
|
||||
@@ -13,53 +13,19 @@ Codeman-side configuration:
|
||||
|
||||
- Which CLI the Run button should default to.
|
||||
- Per-case toggles (Agent Teams, 1M Opus context).
|
||||
- A **Default session group** (Case settings…): new sessions of the case land in that tab group of the
|
||||
vertical rail unless you start them from a group's own _New session_ or they follow the session that
|
||||
spawned them. _Ungrouped_ turns it off. It follows you across devices, never moves existing tabs, and a
|
||||
deleted group simply falls back to Ungrouped.
|
||||
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
|
||||
|
||||
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
|
||||
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
|
||||
Three ways to get one, all under **+** next to the case picker:
|
||||
|
||||
| How | Result |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or, with **Clone into a custom folder**, into `<parent folder you choose>/<name>`, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
| How | Result |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
Linked cases keep living where they are. Deleting a case in Codeman removes the
|
||||
registration, and for a linked case that is all it removes.
|
||||
|
||||
**Renaming a case.** In the case manager (Settings, or the case picker's manage view) the pencil
|
||||
button renames a case and its folder. A case under `~/codeman-cases` _is_ its folder name, so it
|
||||
always moves. A linked case renames its name in the list only, unless you tick **Also rename the
|
||||
folder on disk** in the rename dialog: then its real folder is renamed too, next to where it is.
|
||||
Remote and Docker cases cannot be renamed. Renaming does the following for you:
|
||||
|
||||
- **Sessions are never moved from under you.** If live sessions have the folder open, the rename is
|
||||
refused and lists them; you can then choose to stop them. That closes them the way closing their tabs
|
||||
does (nothing is restarted, and the last one to close removes the files attached to prompts in the
|
||||
case's `.codeman-uploads`). While a rename runs, Codeman holds the folder so no session can start
|
||||
inside it half-moved. A session that is just being started there stops the rename, which says so;
|
||||
trying again a moment later lists that session with the others.
|
||||
- **A folder another program holds open is reported, not forced.** On Windows, SMB shares and some FUSE
|
||||
mounts a directory with an open handle cannot be renamed; Codeman retries briefly, then says the folder
|
||||
is in use and to close whatever has it (a terminal, an editor, a file browser, a sync client).
|
||||
- **Claude Code's history follows.** It keys conversations by the folder's path, so the rename moves
|
||||
`<claude config>/projects/<old path>` to the new path and `--resume` still finds them. When the new
|
||||
path already has history (a case deleted earlier under that name leaves its history behind), the
|
||||
conversations are merged into it without overwriting anything. Other CLIs'
|
||||
history, Docker mounts and `git worktree` links that point at the old path are not updated. The
|
||||
Resume list uses the renamed folder too, and the case's default session group follows the new name.
|
||||
- **Not migrated.** Scheduled (cron) jobs keep the old `workingDir` and fail at their next run until you
|
||||
edit them; Claude history of sessions started in a subfolder (including `.claude/worktrees/<name>`)
|
||||
stays under the old path. Claude Code's own per-project settings in `~/.claude.json` (local-scope MCP
|
||||
servers, allowed tools, the folder's trust) are keyed by the old path too, so the renamed case starts
|
||||
without them: Codeman does not write another CLI's configuration here. In multi-user mode only an
|
||||
admin's rename updates the shared case order and last used case.
|
||||
- **The case order, the last used case and this browser's per-case options follow the new name.**
|
||||
|
||||
**Clone Repo never asks for credentials.** It uses whatever the server's own git already has:
|
||||
an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include
|
||||
helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`;
|
||||
@@ -79,8 +45,7 @@ case. Each has a stable id, and that id is what the API, the wait primitives, an
|
||||
event use.
|
||||
|
||||
Several sessions can share one case. That is the normal way to parallelize: three workers
|
||||
in the same repo, three tabs, one case. They then also share one folder; when the workers
|
||||
edit and commit in parallel, give each its own checkout with [Git Worktrees](Git-Worktrees).
|
||||
in the same repo, three tabs, one case.
|
||||
|
||||
A session carries state the case does not:
|
||||
|
||||
@@ -92,7 +57,7 @@ A session carries state the case does not:
|
||||
## Run mode
|
||||
|
||||
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
|
||||
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, or `shell`. It is chosen at start and does not change afterwards; to
|
||||
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, or `shell`. It is chosen at start and does not change afterwards; to
|
||||
switch, start another session.
|
||||
|
||||
Claude is the reference mode. Nine of the ten are not Claude, and a number of Codeman
|
||||
@@ -104,14 +69,14 @@ Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one pla
|
||||
|
||||
Where a case runs is **separate from** which CLI it runs. There are three locations:
|
||||
|
||||
| Location | What happens |
|
||||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Local** | The default. tmux and the CLI run on the Codeman host. |
|
||||
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
|
||||
| Location | What happens |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Local** | The default. tmux and the CLI run on the Codeman host. |
|
||||
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
|
||||
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
|
||||
|
||||
This matters because it is a common source of confusion: Docker is **not** an eleventh run
|
||||
mode. All eleven run modes work in all three locations. A case is docker-backed or
|
||||
mode. All ten run modes work in all three locations. A case is docker-backed or
|
||||
ssh-backed; a session is claude or codex or shell.
|
||||
|
||||
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
|
||||
@@ -130,10 +95,7 @@ would. That indirection buys:
|
||||
- **Real scrollback.** History is held by tmux, so reconnecting replays what happened while
|
||||
you were gone instead of starting from blank.
|
||||
- **Attach from anywhere else.** The same session is reachable from a terminal over SSH
|
||||
with `codeman tui` (F1 detaches), `./scripts/tmux-manager.sh` (likewise F1), or plain
|
||||
`tmux -L codeman attach`. A plain attach has no detach key: Codeman keeps tmux's prefix
|
||||
table on its socket down to `send-prefix`, so `Ctrl+B d` does nothing there; leave it
|
||||
with `tmux -L codeman detach-client` from another shell, or bind a key yourself.
|
||||
with `codeman tui`, or plain `tmux -L codeman attach`.
|
||||
- **Secrets off the command line.** Environment overrides are injected with socket-scoped
|
||||
`tmux setenv` rather than being visible in the spawn command.
|
||||
|
||||
@@ -142,14 +104,14 @@ sessions never appear in a bare `tmux ls`.
|
||||
|
||||
## What persists
|
||||
|
||||
| Survives | Does not survive |
|
||||
| ------------------------------------- | ------------------------------------ |
|
||||
| Closing the browser | `tmux -L codeman kill-server` |
|
||||
| Losing the network | A machine reboot (tmux dies with it) |
|
||||
| Restarting the Codeman server | Killing the session from the UI |
|
||||
| `codeman web --stop` | |
|
||||
| A dropped SSH link, for remote cases | |
|
||||
| A container restart, for docker cases | |
|
||||
| Survives | Does not survive |
|
||||
| -------------------------------------------- | --------------------------------------------------- |
|
||||
| Closing the browser | `tmux -L codeman kill-server` |
|
||||
| Losing the network | A machine reboot (tmux dies with it) |
|
||||
| Restarting the Codeman server | Killing the session from the UI |
|
||||
| `codeman web --stop` | |
|
||||
| A dropped SSH link, for remote cases | |
|
||||
| A container restart, for docker cases | |
|
||||
|
||||
Conversation history is a separate question: Claude transcripts live in `~/.claude/`, so a
|
||||
conversation can be resumed even after the tmux session is gone. That is what the welcome
|
||||
@@ -159,19 +121,18 @@ screen's **Resume Conversation** list offers.
|
||||
|
||||
Everything Codeman knows lives under `~/.codeman/`:
|
||||
|
||||
| File | Holds |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. |
|
||||
| `settings.json` | User preferences that sync across your devices. |
|
||||
| `mux-sessions.json` | tmux recovery data. |
|
||||
| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. |
|
||||
| `linked-cases.json` | Registered cases. |
|
||||
| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. |
|
||||
| `webviews.json` | Saved dashboard URLs. |
|
||||
| `users.json` | Multi-user accounts, mode 0600. |
|
||||
| `push-*.json` | Web push keys and subscriptions. |
|
||||
| `certs/` | Self-signed TLS for `--https`. |
|
||||
| `backups/config/` | Timestamped copies of the files above that you customized ([Config Backups](Config-Backups)). |
|
||||
| File | Holds |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. |
|
||||
| `settings.json` | User preferences that sync across your devices. |
|
||||
| `mux-sessions.json` | tmux recovery data. |
|
||||
| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. |
|
||||
| `linked-cases.json` | Registered cases. |
|
||||
| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. |
|
||||
| `webviews.json` | Saved dashboard URLs. |
|
||||
| `users.json` | Multi-user accounts, mode 0600. |
|
||||
| `push-*.json` | Web push keys and subscriptions. |
|
||||
| `certs/` | Self-signed TLS for `--https`. |
|
||||
|
||||
None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets
|
||||
Codeman to a fresh install without touching your code.
|
||||
@@ -201,11 +162,9 @@ report events back: a permission prompt appeared, the turn finished, the agent w
|
||||
task completed. Those events drive tab alerts, the Approvals Inbox, notifications, and the
|
||||
wait primitives.
|
||||
|
||||
This is why some features are Claude-only. One partial exception is DeepSeek Harness,
|
||||
This is why some features are Claude-only. The one partial exception is DeepSeek Harness,
|
||||
whose terminal front door reports idle, working and blocked to Codeman over the harness's
|
||||
own supervisor contract, so it gets the hook-driven signals without a hook file. Codex gets
|
||||
one of them from its screen: Codeman reads its approval, question and trust dialogs off the
|
||||
pane and raises the same red "needs you" alert a `permission_prompt` hook raises. The other
|
||||
own supervisor contract, so it gets the hook-driven signals without a hook file. The other
|
||||
CLIs have no equivalent, so for them Codeman falls back to watching terminal output, which
|
||||
is coarser: it can see that something happened, not what it was.
|
||||
|
||||
@@ -213,21 +172,21 @@ See [Hooks And Integrations](Hooks-And-Integrations).
|
||||
|
||||
## Vocabulary
|
||||
|
||||
| Term | Means |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Case** | Named working directory. |
|
||||
| **Session** | One CLI in one tmux session. |
|
||||
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
|
||||
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
|
||||
| **Ralph loop** | An autonomous single-session task loop. |
|
||||
| **Orchestrator** | A phased plan driven across multiple agents. |
|
||||
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
|
||||
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
|
||||
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
||||
| Term | Means |
|
||||
| --------------- | ---------------------------------------------------------------------------- |
|
||||
| **Case** | Named working directory. |
|
||||
| **Session** | One CLI in one tmux session. |
|
||||
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, shell. |
|
||||
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
|
||||
| **Ralph loop** | An autonomous single-session task loop. |
|
||||
| **Orchestrator**| A phased plan driven across multiple agents. |
|
||||
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
|
||||
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
|
||||
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
||||
|
||||
## Read next
|
||||
|
||||
- [The Dashboard](The-Dashboard) - what the UI is showing you.
|
||||
- [Agent CLIs](Agent-CLIs) - the eleven run modes in detail.
|
||||
- [Agent CLIs](Agent-CLIs) - the ten run modes in detail.
|
||||
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
|
||||
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
|
||||
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
|
||||
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
|
||||
dependencies and open a PR.*
|
||||
|
||||
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
|
||||
**App Settings → Header & Panels → Scheduling**.
|
||||
The ⏰ **Cron** header button is opt-in. Turn it on in
|
||||
**App Settings → Header & Panels**.
|
||||
|
||||
## Creating a job
|
||||
|
||||
@@ -51,11 +51,10 @@ or a server restart shifts later runs slightly. That drift is accepted rather th
|
||||
|
||||
## Prompts are single line
|
||||
|
||||
This is the rule people trip over. A cron job fires with nobody watching, and the check that
|
||||
re-presses an Enter the agent ignored cannot reliably recognise a multi-line paste in the
|
||||
composer, so a multi-line prompt could sit there unsent. In `paste` mode the newlines would
|
||||
reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a
|
||||
prompt file whose contents are multi-line fails the run with a clear message.
|
||||
This is the rule people trip over. Programmatic input into an agent session is single line
|
||||
everywhere in Codeman, because the terminal UIs these CLIs use treat a newline as submit. A
|
||||
multi-line prompt would be silently mangled, so it is **rejected** instead: the form refuses
|
||||
it, and a prompt file whose contents are multi-line fails the run with a clear message.
|
||||
|
||||
For anything longer than a sentence, put the instructions in a file and make the prompt tell
|
||||
the agent to read it:
|
||||
@@ -97,19 +96,11 @@ The skip policy has the details you would want it to have:
|
||||
|
||||
Every fire is recorded per job, with a status:
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| `created` | The run record was created; the session has not started yet. |
|
||||
| `session_started` | The session started; prompt delivery is still pending. |
|
||||
| `prompt_sent` | The prompt was sent to the session. |
|
||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
|
||||
|
||||
History records whether the session started and the prompt was delivered, **not whether
|
||||
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
|
||||
If the session is closed during the readiness wait or the server restarts before
|
||||
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
|
||||
clients must not poll forever waiting for a terminal status.
|
||||
| Status | Meaning |
|
||||
| --------- | -------------------------------------------------------------------- |
|
||||
| `created` | The run started and a session was created. |
|
||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||
| `failed` | The prompt could not be resolved, or the working directory was gone. |
|
||||
|
||||
The schedule is advanced **before** the session launches, so a slow start cannot cause the
|
||||
same job to re-trigger.
|
||||
|
||||
@@ -27,20 +27,14 @@ hosts — these are machine-level infra, not a per-user setting.
|
||||
shows up without another manual click of **Discover**. One endpoint being unreachable on a
|
||||
given cycle (powered off, wrong network) never blocks the others from refreshing.
|
||||
|
||||
**Context length is picked up automatically where it can be, safely.** Every model's own
|
||||
listing entry is read for a stated context figure (`context_length`, then `context_window`,
|
||||
which current llama.cpp, llama-swap and gateway-style servers state to varying degrees);
|
||||
against a llama-swap server the stronger figures from its launch command and from llama.cpp's
|
||||
`/props` probe win where they exist, and those probes only ever run for a model the server
|
||||
already reports loaded, since asking about an unloaded model can trigger an actual, slow model
|
||||
swap as a side effect. A model that states no figure keeps whatever an earlier cycle learned:
|
||||
the stored figure always beats the endpoint's **Default context length** field
|
||||
(`modelContextLengths[id] ?? defaultContextLength`), which only applies where no figure was
|
||||
ever learned (that number is the model's whole window, never a per-slot figure). A model
|
||||
neither covers simply gets no override, never a guess. The window is applied to the launched session by the harnesses
|
||||
that can take it: Claude Code today (see below), OpenCode as the model's context limit, and
|
||||
Codex as a `model_context_window` entry in its generated config file, which also sets Codex's
|
||||
own auto-compact point via `model_auto_compact_token_limit` at 75% of the window.
|
||||
**Context length is picked up automatically where it can be, safely.** Against a
|
||||
llama.cpp/llama-swap server, discovery also learns each _currently loaded_ model's real
|
||||
context window and applies it to the launched session (Claude Code today — see below), so
|
||||
the harness stops assuming a large default window for a model name it doesn't recognise and
|
||||
overflowing a much smaller real one. It's deliberately never probed for a model that isn't
|
||||
already loaded, since asking a llama-swap server about an unloaded model can trigger an
|
||||
actual, slow model swap as a side effect — a model just not currently loaded keeps whatever
|
||||
context length an earlier cycle already learned for it instead.
|
||||
|
||||
## Running a session against one
|
||||
|
||||
@@ -54,26 +48,21 @@ pointed at.
|
||||
|
||||
**Which model it uses depends on how many the endpoint has discovered.** With exactly one,
|
||||
the session launches straight away on that model — nothing to choose. With two or more, a
|
||||
small dialog asks which one to use for this launch before starting the session; a search box
|
||||
at the top narrows a long list with a case-insensitive substring match, so a server hosting
|
||||
hundreds of models stays usable. The
|
||||
small dialog asks which one to use for this launch before starting the session; the
|
||||
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
|
||||
use a different one without changing the saved default. The list is not raw discovery order
|
||||
either: the model llama-swap reports loaded and ready is moved to the top and tagged
|
||||
**Currently loaded**. When nothing is reported loaded, the model one of your own running
|
||||
sessions is using on that endpoint is moved up instead and tagged **In use by a session** (it
|
||||
beats **Last used**, never **Currently loaded**), and failing that the model you last launched
|
||||
on this harness and endpoint pair is moved up and tagged **Last used** (a per-device browser
|
||||
value, so another device starts from its own history). With none of those, a line under the
|
||||
search box says nothing is marked, so the plain list is not mistaken for an answer. The default
|
||||
model keeps its own **Default** pill in every case, and nothing is ever auto-chosen: the
|
||||
promoted row is simply the one under your thumb.
|
||||
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
|
||||
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
|
||||
another device starts from its own history). The default model keeps its own **Default** pill
|
||||
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||
thumb.
|
||||
|
||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches
|
||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
|
||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||
session's process ever starts. **Claude still restarts the harness's process in place** —
|
||||
same tab, same conversation (`--resume`) — after a normal native launch, since that restart
|
||||
is far less jarring for Claude than for the others, whose own TUI can fully
|
||||
is far less jarring for Claude than for the other seven, whose own TUI can fully
|
||||
reinitialize on a restart. Either way, every supported harness reads its endpoint config at
|
||||
process start, never per turn, so there is no live hot-swap while a turn is running.
|
||||
|
||||
@@ -161,88 +150,20 @@ relying on auto-fit (`--fit-ctx`), which sizes the context around fitting the bi
|
||||
rather than the biggest context — for example adding `-c 65536` to that model's llama-swap
|
||||
entry. "Launch anyway" is still there if you want to try regardless.
|
||||
|
||||
**A context that is big enough overall can still be too small once it is split across
|
||||
slots.** `llama-server` runs several parallel slots by default (four on the build this was measured on) and,
|
||||
unless you say otherwise, they all share one pool of context. Claude Code sends more than one
|
||||
request at a time: the main session plus every subagent it runs in parallel, and each of those
|
||||
requests carries the roughly 40K tokens of system prompt and tools on its own. Four requests
|
||||
of 40K tokens do not fit in a shared 131K pool, so the server refuses them with HTTP 500
|
||||
`Context size has been exceeded`, even though any single request would have been fine.
|
||||
Discovery therefore also records how many slots each loaded model has. It reads an explicit
|
||||
`--parallel` or `-np` from the launch command, and otherwise asks the server for
|
||||
`total_slots`, because a server started without the flag chooses the number itself. When the
|
||||
context divided by the slot count drops below what Claude Code needs, picking that model shows
|
||||
the same in-app dialog, this time explaining the shared slots. The division uses the context
|
||||
Codeman read from the launch command (`--fit-ctx` or `-c`), never a larger figure from the
|
||||
server's `/props`, which has been measured reporting far more than the server really runs
|
||||
with. So a default llama-swap model with auto slots shows the dialog, which names the fix and
|
||||
lets you launch anyway. The fix is a single slot:
|
||||
|
||||
```yaml
|
||||
cmd: >-
|
||||
llama-server -m /models/your-model.gguf --parallel 1 ...
|
||||
```
|
||||
|
||||
With one slot the active conversation gets the whole context, requests from parallel
|
||||
subagents queue behind it instead of failing, and Claude Code's repeated prompt prefix stays
|
||||
in the cache instead of being processed again in a different slot each time. The alternative
|
||||
is a context large enough that every slot can hold 40K tokens. A slot count Codeman could not
|
||||
read is treated as one slot, never guessed.
|
||||
|
||||
## Which harnesses actually work
|
||||
|
||||
| Harness | Status |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **GitHub Copilot CLI** | Verified at the CLI level (1.0.95) against a local OpenAI-compatible endpoint: it posts to `/v1/chat/completions` with your key and model name and needs no GitHub sign-in. Tool calls through a custom endpoint were not exercised. |
|
||||
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
|
||||
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
|
||||
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
|
||||
| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. Such a session does not carry over your home-level `cordis.patch.yml`, your own `llm-pi-ai` providers, or a profile-level `effort`/`preset`. |
|
||||
| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. |
|
||||
| **Antigravity** | No known custom-endpoint mechanism at all. Not offered. |
|
||||
|
||||
Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry,
|
||||
not a fixed list here, so this table can go stale before this page does — a greyed-out or
|
||||
missing entry is the more current answer.
|
||||
|
||||
## A model that runs on this Mac
|
||||
|
||||
If the Codeman server runs on a Mac with Apple silicon, you do not need a server of your
|
||||
own: Codeman can set one model up for you on that Mac. This is experimental.
|
||||
|
||||
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
|
||||
on this Mac).
|
||||
2. The window shows what fits this Mac. A bar shows how much of the memory the selected
|
||||
model takes at its full context; a nearly full bar means a tight fit. Pick a model and
|
||||
the coding agent to run it with.
|
||||
3. Press **Install on this Mac**, check the sizes and where they go, and confirm. Nothing is
|
||||
downloaded before this click.
|
||||
4. Codeman downloads a model runtime (Ollama, about 186 MB) and the model into
|
||||
`~/.codeman/local-model`, loads the model, checks that all of it runs on the GPU and that
|
||||
it can call a tool, installs the coding agent if it is missing, and saves an endpoint
|
||||
named **This Mac**. No administrator password is asked for.
|
||||
5. Press **Start a session**.
|
||||
|
||||
Good to know:
|
||||
|
||||
- You can close the window: the download continues while Codeman is running. **Cancel**
|
||||
pauses it and **Continue** picks it up where it stopped. After a Codeman restart an
|
||||
unfinished install waits for your click.
|
||||
- The memory figure is an estimate until the runtime has started once. From then on it is
|
||||
what the runtime itself measured, and the model is judged against that before it is
|
||||
downloaded.
|
||||
- The endpoint "This Mac" belongs to this install. It is in the list and in the Run menu,
|
||||
but it is changed and removed in the local model window, not here.
|
||||
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
|
||||
starting next time.
|
||||
- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The
|
||||
coding agent stays installed.
|
||||
- If the model on disk is no longer the one that was installed, Codeman refuses to start a
|
||||
session on it and tells you to remove and install it again.
|
||||
- Any program on this Mac, and any web page served from `localhost`, can reach the model
|
||||
runtime: its port has no login. It listens on this Mac only.
|
||||
- On a Mac without Apple's developer tools, macOS will offer to install them the first time
|
||||
a coding agent runs `git` or Python. The window tells you when that applies.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **No remote or Docker sessions yet.** Both restart their agent differently under the hood
|
||||
@@ -252,10 +173,6 @@ Good to know:
|
||||
- **No button to un-point a session from the UI yet.** Clearing back to native cloud is an
|
||||
HTTP call (`POST .../custom-model {"clear": true}`) or deleting the session; the settings
|
||||
panel manages saved endpoints, not what a running session is currently pointed at.
|
||||
A clear, or a switch to another endpoint, gives the session back what it had set itself under
|
||||
a key the endpoint replaced, such as its own `DSH_HOME` or `CLAUDE_CONFIG_DIR`. If Codeman
|
||||
restarted in between, only the home directory comes back, and an own `DSH_HOME`, `CODEX_HOME`
|
||||
or `GROK_HOME` only when the endpoint was picked before that restart.
|
||||
- **Nothing is shared with your real cloud credentials.** The endpoint's own key, if any,
|
||||
never touches your Anthropic/OpenAI/Google login — a custom endpoint is a separate,
|
||||
explicit choice per session.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# Discord Bridge
|
||||
|
||||
Answer a blocked agent from your phone, from anywhere. Codeman posts to a private Discord
|
||||
server when a session needs you, gives every tab its own thread, and types your reply in that
|
||||
thread into that tab. It only makes outbound connections, so nothing is exposed to the internet
|
||||
and it works when you are away from your network.
|
||||
|
||||
**Opt-in, off by default. One server, one channel, threads per tab.**
|
||||
|
||||
> **Treat the Discord account like a login to the machine.** A reply from an allowed account is
|
||||
> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop. A
|
||||
question or permission prompt shows what is on the tab's screen right now, so you can see what is
|
||||
being asked; a finished turn or an error carries the CLI's whole last message.
|
||||
- **One thread per tab** in a parent channel, so a reply always reaches exactly one session.
|
||||
- **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter).
|
||||
- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can
|
||||
send a file back.
|
||||
- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent`
|
||||
posts to the tab's thread. Sessions from the installer or an npm install have the command (a git checkout run without the installer does not); see [below](#telling-the-agents).
|
||||
|
||||
## Set it up
|
||||
|
||||
The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token
|
||||
handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md).
|
||||
In short:
|
||||
|
||||
1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs.
|
||||
2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and
|
||||
invite it with the `bot` scope and only the permissions the guide lists (no Administrator).
|
||||
3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**,
|
||||
then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again.
|
||||
|
||||
The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner
|
||||
only), never in the shared settings, and never shown again once saved.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Choices | Default |
|
||||
| ---------------------------------------- | ------------------------------------------- | --------------- |
|
||||
| Create threads | On the first alert, or for every new tab | First alert |
|
||||
| When a tab is deleted | Keep, archive or delete the thread | Archive |
|
||||
| Stale threads | Keep, archive or delete threads no tab uses | Keep |
|
||||
| @ ping | Never, critical events only, every post | Critical only |
|
||||
| Which events | Needs attention, or everything | Needs attention |
|
||||
| Tell Claude how to reach you | On or off | On |
|
||||
|
||||
A finished turn, and an error, is posted as the CLI's whole last message, with its formatting and questions
|
||||
kept, split over several posts if it is long. A shell, and permission or question dialogs, show the last lines
|
||||
of the screen instead.
|
||||
|
||||
Only some CLIs raise a turn end. Claude Code does (an idle alert by default, and a stop alert when **Which
|
||||
events** is Everything), and DeepSeek Harness sends a stop. The others (Codex, OMP, OpenCode, Gemini,
|
||||
Antigravity, Pi, Grok, Copilot) send none, so their messages reach Discord with an error or `codeman notify`;
|
||||
Codex's dialogs are read off its screen.
|
||||
|
||||
The stale-thread setting also archives or deletes threads that **When a tab is deleted** kept or archived, because a
|
||||
deleted tab's thread leaves the bridge's map.
|
||||
|
||||
Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a
|
||||
permission prompt, a question, an error, or a post an agent marked urgent.
|
||||
|
||||
## Telling the agents
|
||||
|
||||
An agent only posts deliberately if it knows it can:
|
||||
|
||||
- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command).
|
||||
- **Every other CLI** is not told. It can still run `codeman notify` if you tell it to.
|
||||
|
||||
Tabs that were already open when you turned the bridge on are not told. The alerts still reach
|
||||
Discord for them; only the agent-initiated `codeman notify` habit is missing.
|
||||
|
||||
## Files
|
||||
|
||||
- **You to the agent:** attach a file to a reply. It is saved as
|
||||
`<tab folder>/.codeman-discord/<message id>-<name>` and the line the agent sees ends with
|
||||
`[attached: <path>]`. At most 5 files of 8 MB each. The folder ignores itself, so nothing in it is committed.
|
||||
- **Agent to you:** `codeman notify "message" --file <path>` uploads a file of at most 8 MB that lies
|
||||
inside the tab's folder. Credential files such as `.env` are refused.
|
||||
- Remote (ssh) tabs have no local folder, so they neither send nor receive files.
|
||||
- A file you send is untrusted content to the agent that opens it.
|
||||
|
||||
## Safety
|
||||
|
||||
- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on.
|
||||
Everything else is dropped.
|
||||
- A reply is cut to one printable line (1000 characters) and limited to one per second per tab.
|
||||
- Posts can ping only the allowlisted users, so agent output cannot ping the whole server.
|
||||
- A Discord outage or a bad token never affects a session; it backs off, and the last error shows in Settings.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| Send test says 401 | The token is wrong or was reset. Paste the current one. |
|
||||
| Send test says the bot cannot see it | Check the bot's role and the channel ID. |
|
||||
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. |
|
||||
| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. |
|
||||
| A thread is not deleted | The bot lacks Manage Threads on the channel. |
|
||||
|
||||
See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear
|
||||
from an agent, and [Security](Security) for how secrets are handled.
|
||||
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
|
||||
reproducible toolchain, and for the ability to pick the whole environment up and move it to
|
||||
another machine.
|
||||
|
||||
A docker case is a **location overlay**, not a run mode. All eleven run modes work inside a
|
||||
A docker case is a **location overlay**, not a run mode. All ten run modes work inside a
|
||||
container. See [Core Concepts](Core-Concepts).
|
||||
|
||||
## One-time setup: the base image
|
||||
@@ -98,10 +98,6 @@ Adoption is **admin-only in multi-user mode**. Linking creates Codeman's own con
|
||||
with one bind mount that has already been checked; an adopted container's mounts belong to
|
||||
whoever started it, and one that mounts `/` hands the adopter the host.
|
||||
|
||||
That is about a container. A tmux session someone started by hand inside a container is opened
|
||||
from the home screen instead, and it needs a Docker case pointing at that container: see
|
||||
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
|
||||
|
||||
## Credentials
|
||||
|
||||
Your existing host logins work inside the container without logging in again. Credentials
|
||||
@@ -122,12 +118,6 @@ invisible from the host (`pi -c` and `grok -c` inside a docker case see only tha
|
||||
container's history). OMP's `sessions/` is the exception and is shared read-write, because
|
||||
Codeman reads it host-side for history and resume.
|
||||
|
||||
GitHub Copilot CLI is not seeded: on a host without a keyring its sign-in token sits in plain
|
||||
text in `~/.copilot/config.json`, and there is no Docker credential row for it. Sign in inside
|
||||
the container (`copilot login`). A `COPILOT_GITHUB_TOKEN` cannot be handed in instead: a docker
|
||||
case refuses per-session environment overrides, and Copilot forwards no server variable into
|
||||
the container.
|
||||
|
||||
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
|
||||
with the `azure-devops` extension), off by default, and its git then uses them as credential
|
||||
helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are
|
||||
@@ -157,12 +147,6 @@ in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
|
||||
`docker/README.md` ("Private repositories") has the details and the matching switches for
|
||||
the server image.
|
||||
|
||||
To give agents a fixed Git commit identity, set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and
|
||||
`CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together in that same environment (in the Docker
|
||||
deployment, set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` instead, which feeds
|
||||
both images). An existing `codeman/agent:base` only picks it up after a `--no-cache` rebuild
|
||||
and recreated case containers; `docker/README.md` ("Git commit identity") has the details.
|
||||
|
||||
## Isolation
|
||||
|
||||
Every container runs hardened by default:
|
||||
@@ -229,8 +213,7 @@ this does not widen your network exposure. Add it to the service unit and restar
|
||||
|
||||
- Per-session environment overrides, effort, and per-CLI configuration are **rejected** for
|
||||
docker cases, because they do not cross into the container. Configure the container through
|
||||
the docker host's per-mode command override instead. A `$` in that override expands inside
|
||||
the container, not on the Codeman machine.
|
||||
the docker host's per-mode command override instead.
|
||||
- tmux must exist in the base image. It is a hard prerequisite and is probed when linking a
|
||||
host.
|
||||
- On macOS, Docker Desktop takes a dedicated uid path, and memory caps are subject to the
|
||||
|
||||
@@ -4,182 +4,61 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
|
||||
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
|
||||
supervising other sessions.**
|
||||
|
||||
Three routes. Start with the skill, whichever CLI the agent is. Where the skill does not
|
||||
reach (a pi or shell session), use the `codeman agent` commands. Raw HTTP is there for
|
||||
everything else.
|
||||
Two routes. Start with the skill.
|
||||
|
||||
## The agent skill
|
||||
|
||||
A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP
|
||||
and most other agent CLIs read. It teaches the agent the whole API, so you ask in plain
|
||||
English instead of pasting endpoint documentation into prompts:
|
||||
A Claude Code skill that teaches the agent the whole API, so you ask in plain English
|
||||
instead of pasting endpoint documentation into prompts.
|
||||
|
||||
Since 1.50.0 Codeman puts it into every new session's folder by default (App Settings →
|
||||
Agents & CLIs → **Codeman Agent Skill**), so an agent inside Codeman usually has it already.
|
||||
For pi, or a copy that does not depend on that setting:
|
||||
### Install it
|
||||
|
||||
```bash
|
||||
codeman skill install # or: npx skills add Ark0N/Codeman --skill codeman -g
|
||||
```
|
||||
| How | Command | Scope |
|
||||
| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. Pick this or `codeman skill install`, not both, or the skill is listed twice (`codeman` and `codeman:codeman`). |
|
||||
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
|
||||
| Bundled CLI | `codeman skill install --case <name>` | One case. |
|
||||
| Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. |
|
||||
|
||||
Then ask the agent in a Codeman session something like *"use codeman to start 5 claude
|
||||
workers, one per TODO item"*. It starts the workers, hands each one its task, gathers the
|
||||
answers and deletes the sessions it created.
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a
|
||||
`skills/codeman` you wrote yourself.
|
||||
|
||||
**[The Codeman Skill](Codeman-Skill)** has the rest: recordings of real runs, every install
|
||||
route (Claude Code plugin, skills CLI, per case, the web UI setting), prompts that work as
|
||||
written, what it will and will not do on its own, and the notes for Codex. It drives
|
||||
### Then just ask
|
||||
|
||||
| You say | What happens |
|
||||
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| "What sessions are running right now?" | Lists them with name, mode, and status. Read-only. |
|
||||
| "Start a shell worker on the `myapp` case, run the test suite, tell me if it passes." | Spawns, waits on a completion marker, reads the exit code, cleans up. |
|
||||
| "Spin up 3 workers for lint, typecheck and tests, run them in parallel, report failures." | One session per task, all started first, then gathered as each finishes. |
|
||||
| "Have a claude worker summarize `src/session.ts`, then close it." | Spawns, runs the readiness ladder, sends and waits, reads the answer, deletes the session. |
|
||||
| "Watch session w4 and tell me if it gets stuck on a permission prompt." | Blocks on the `blocked` signal and surfaces the question to **you**. |
|
||||
|
||||
Sessions the agent creates get deleted when it is done. You can watch the tabs appear and
|
||||
disappear in the dashboard while it works.
|
||||
|
||||
### What it will and will not do
|
||||
|
||||
- **It self-gates.** Outside a Codeman session it refuses to act and does not guess an API
|
||||
URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **Unprompted, it may only** spawn sessions, prompt them, and delete ones **it created in
|
||||
that conversation, by exact id**, behind a guard that refuses to delete the agent's own
|
||||
session.
|
||||
- **It will not** answer another session's permission prompt on your behalf. It surfaces the
|
||||
question instead.
|
||||
- **Deleting a case** (which erases a real directory of your code), bulk kills, respawn,
|
||||
Ralph, cron, orchestrator, and settings writes all require you to ask, naming the target.
|
||||
|
||||
Turning the setting back off **does not remove already-injected copies**, because a
|
||||
create-time sweep would yank the skill out from under other live sessions sharing that
|
||||
directory. Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
The skill ships with the verb index always loaded, plus on-demand references for the verbs,
|
||||
worked multi-worker recipes, endpoint tables, and cross-session messaging. It drives
|
||||
DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alpha
|
||||
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
|
||||
completion signals.
|
||||
|
||||
## The `codeman agent` commands
|
||||
|
||||
The skill drives the API with `curl` and `jq`. Where it does not reach (a pi or shell
|
||||
session, a machine without `jq`), the agent runs in the same environment, so `codeman agent`
|
||||
packages the same verbs as shell commands. It is a thin client over the endpoints in
|
||||
[the manual path](#the-manual-path), so auth and ownership apply unchanged, and it refuses to
|
||||
act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"other sessions:
|
||||
`codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
|
||||
SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default)
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor
|
||||
codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only
|
||||
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
|
||||
codeman agent interrupt "$SID" # a bare ESC, conversation intact
|
||||
codeman agent post "$SID" 'context for later, not a prompt' # stored in its inbox, not typed into the pane
|
||||
codeman agent inbox --wait --timeout 300000 # your own inbox: blocks while nothing is new
|
||||
codeman agent ack <msg-id> # remove what you handled (read never removes)
|
||||
codeman agent rm "$SID" # any session except this one
|
||||
```
|
||||
|
||||
- **`spawn [case]`** uses the current directory when no case is supplied. Use
|
||||
`codeman agent spawn --working-dir /path/to/project --mode codex` for another existing
|
||||
local directory. A case and `--working-dir` cannot be combined. An empty case name
|
||||
(`spawn "$CASE"` with `CASE` unset) is never read as "no case": it goes to quick-start,
|
||||
which refuses it (exit 1). An empty `--working-dir` is refused before any request
|
||||
(exit 4), so it never falls back to cwd.
|
||||
A cwd that is your home directory is refused the same way, because Codeman's hooks would
|
||||
land in `~/.claude`; pass `--working-dir ~` to start a worker there on purpose.
|
||||
Explicit case names retain quick-start behavior (creating a scratch case if missing).
|
||||
A directory launch is `POST /api/v1/sessions` followed by
|
||||
`POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and
|
||||
parent lineage, and never labels it as agent scratch. Choose a separate worktree for
|
||||
each worker that will edit code. If startup fails or its response is lost, the CLI
|
||||
reports the exact created session ID and exits `1`; inspect that session before
|
||||
deciding what to do next.
|
||||
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
|
||||
ambiguous prefix.
|
||||
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
|
||||
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
|
||||
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
|
||||
prompt is output too, so ask for the marker in halves and wait on the joined form.
|
||||
- **`post`, `inbox`, `ack`** are the agent inbox: a message for another session that starts
|
||||
no turn. `post` stores ONE quoted argument (multi-line is fine, so `"$(cat task.md)"` works)
|
||||
and types nothing into the pane. `inbox` reads this session's mail, or peeks at another's
|
||||
with an id, and removes nothing; `ack <id>…` removes what you handled. `inbox --wait`
|
||||
blocks while nothing is new, so mail you read but did not ack does not wake it again.
|
||||
`ls` shows each session's mail in an INBOX column. Mail lives in memory unless
|
||||
**Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)),
|
||||
and goes when its session is deleted.
|
||||
- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN`
|
||||
naming the setting until **Experimental agent coordination** is on (see
|
||||
[Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by
|
||||
default), and they may change in any release. `inspect` returns bounded evidence with an
|
||||
opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a
|
||||
local Codex composer, with no Enter, and returns a receipt that expires after two minutes
|
||||
once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands
|
||||
and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter
|
||||
would accept), so send commands with `send`. `submit` sends one Enter only if the
|
||||
composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details:
|
||||
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input).
|
||||
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
|
||||
worker exited, `4` refused. `--json` prints the response's `data`, except a directory
|
||||
`spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a
|
||||
readiness wait ran, `readinessError` when that call failed, and `startupError` when the
|
||||
start failed.
|
||||
|
||||
## Reading saved Codex conversations
|
||||
|
||||
`codeman history read` is the one command here that needs no server and no Codeman
|
||||
session: it reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else
|
||||
`~/.codex/sessions`) from disk and writes nothing.
|
||||
|
||||
```bash
|
||||
codeman history read --session-id 019fc604 # one conversation, by id or 8+ character prefix
|
||||
codeman history read --root-session-id <uuid> --topic auth # it and every sub-agent or fork under it
|
||||
codeman history read --file /path/to/rollout.jsonl --format text
|
||||
```
|
||||
|
||||
- **Filters** keep whole user/assistant turns: `--topic` is a literal, case-insensitive
|
||||
match, and `--after`/`--before` are inclusive UTC dates.
|
||||
- **Forks and sub-agents**: a child's copy of its parent's history is dropped, so every
|
||||
record belongs to the thread that wrote it.
|
||||
- **`complete: false`** in the JSON means the evidence is partial: a store file that could
|
||||
not be attributed, a compressed `.jsonl.zst` rollout (not read), a record a running
|
||||
Codex is still writing, an ancestor not found.
|
||||
- **Budgets** fail closed and are raisable: `--metadata-bytes` (attributing the store,
|
||||
default 1 GiB), `--max-entries` (65536), `--scan-bytes` (the selected transcripts,
|
||||
16 MiB) and `--max-bytes` (output, 8192). `--json` and `--format text` together are an
|
||||
error.
|
||||
|
||||
The full reference ships with the skill as
|
||||
[`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md).
|
||||
|
||||
## The MCP server
|
||||
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server, so any MCP
|
||||
client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor) can drive Codeman, from
|
||||
inside a Codeman session or from an assistant on your desktop. It is a client of the same
|
||||
HTTP API, with seven tools: `list_sessions`, `start_session`, `send_prompt`,
|
||||
`wait_for_session`, `read_output`, `interrupt_session` and `delete_session`.
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `CODEMAN_PASSWORD` to
|
||||
`env` when the server has a password.
|
||||
|
||||
- **It never guesses a server.** Without `CODEMAN_API_URL` (or `--url`) every call says how
|
||||
to configure it.
|
||||
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions it
|
||||
started; listing, reading and waiting work on everything. `codeman mcp
|
||||
--allow-any-session` lifts that, for an assistant that should drive your existing tabs.
|
||||
- **Waits are short by default** (45 s, at most 600 s), so they fit a client's tool
|
||||
timeout. A `timed_out` answer means call again.
|
||||
|
||||
## The manual path
|
||||
|
||||
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
|
||||
@@ -200,14 +79,11 @@ These are set in every managed session. Read them rather than hardcoding anythin
|
||||
|
||||
Read these before writing any code. Each one has cost somebody an afternoon.
|
||||
|
||||
1. **Input must end with `\r`.** Enter fires only when the payload contains a carriage
|
||||
return. Without it the text sits unsubmitted on the prompt, the request still succeeds,
|
||||
and a combined wait burns its full timeout on a turn that never started. With
|
||||
`useMux`, embedded newlines are kept: the text goes in as one paste and the Enter after
|
||||
it submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The check
|
||||
that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so a
|
||||
long multi-line prompt to a just-started agent is still safer in a file it is told to
|
||||
read.
|
||||
1. **Input is single line and must end with `\r`.** Enter fires only when the payload
|
||||
contains a carriage return. Without it the text sits unsubmitted on the prompt, the
|
||||
request still succeeds, and a combined wait burns its full timeout on a turn that never
|
||||
started. Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs
|
||||
the joined `echo Aecho B`. One line per call.
|
||||
2. **Make input idempotent.** Send a stable `clientId` and a monotonic per-session `seq`. The
|
||||
server deduplicates, so a retry after a dropped connection cannot double-deliver.
|
||||
3. **Auth.** With `CODEMAN_PASSWORD` set, use HTTP Basic or the session cookie. A missing
|
||||
@@ -219,20 +95,17 @@ Read these before writing any code. Each one has cost somebody an afternoon.
|
||||
5. **Wait instead of polling, and a timeout is not an error.** The wait endpoints answer
|
||||
`200` with `wait.timedOut: true`. Loop over short waits rather than one long call, because
|
||||
tunnels cut idle connections.
|
||||
6. **Only `claude` and `deepseek` sessions emit `stop`; `blocked` also fires for `codex`.**
|
||||
Claude's come from Claude Code hooks, DeepSeek's from the harness reporting its state to
|
||||
Codeman, and Codex's `blocked` from Codeman reading its approval, question and trust
|
||||
dialogs off the screen (a wait that asks for `blocked` while such a dialog is already up
|
||||
answers at once). Shell and the other external CLIs accept only `idle`, `working`, and
|
||||
`exit`; asking for `stop` explicitly there (codex included) is a `400`, while omitting
|
||||
`until` is always safe. On a shell session
|
||||
6. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Claude's come from
|
||||
Claude Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and
|
||||
the other external CLIs accept only `idle`, `working`, and `exit`; asking for `stop`
|
||||
explicitly there is a `400`, while omitting `until` is always safe. On a shell session
|
||||
`idle` fires **once at startup and never again**, so synchronize hook-less sessions with an
|
||||
output marker instead.
|
||||
7. **Nothing reports "ready", so wait for it explicitly.** A new session answers
|
||||
`{"signal":"exit","immediate":true}` until its PID exists, and that means *not started*,
|
||||
not *crashed*. A Claude worker in a fresh case then sits on the CLI's trust dialog, whose
|
||||
highlighted option is `No, exit`: prompt it there and your Enter quits the CLI. Codeman
|
||||
holds `idle` while the dialog is on screen, but that is not a readiness signal either.
|
||||
not *crashed*. A Claude worker in a fresh case then sits on the CLI's trust dialog; prompt
|
||||
it there and the wait resolves on idle in about two seconds looking exactly like a finished
|
||||
turn, while your text sits stuck in the dialog.
|
||||
|
||||
### Recipes
|
||||
|
||||
@@ -316,14 +189,13 @@ followed by a wait races, and reports the previous turn's state.
|
||||
## Lineage
|
||||
|
||||
A create request can name the session that spawned it, through a body field or a header, and
|
||||
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
|
||||
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
|
||||
|
||||
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
||||
failing the spawn, because a cosmetic field must never break a worker.
|
||||
|
||||
## Read next
|
||||
|
||||
- [The Codeman Skill](Codeman-Skill) - the skill in action, its install routes and its limits.
|
||||
- [HTTP API](HTTP-API) - the endpoint map and the envelope.
|
||||
- [Hooks And Integrations](Hooks-And-Integrations) - events flowing the other way.
|
||||
- [Watching Agents Work](Watching-Agents-Work) - seeing the fan-out in the UI.
|
||||
|
||||
@@ -23,18 +23,14 @@ stores, or refreshes your credentials.
|
||||
|
||||
### Which agent CLIs does it support?
|
||||
|
||||
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness,
|
||||
OMP and GitHub Copilot CLI, plus a plain shell, chosen per session. Claude is the reference mode and a few features
|
||||
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness and
|
||||
OMP, plus a plain shell, chosen per session. Claude is the reference mode and a few features
|
||||
are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
|
||||
|
||||
### Does Codeman send my code or prompts anywhere?
|
||||
|
||||
No. There is no telemetry, no analytics, and no phone-home. Beyond the traffic between
|
||||
your browser and your server, the server makes a few requests of its own for features you use
|
||||
(the Codex usage poll, web push, webhooks). One is on by default: while GitHub Copilot CLI is
|
||||
installed and signed in, every 10 minutes the server asks `api.github.com` for your own
|
||||
premium-request quota with Copilot's own token. Turn off Plan Usage Limits in App Settings to
|
||||
stop it (see [Agent CLIs](Agent-CLIs)).
|
||||
No. There is no telemetry, no analytics, and no phone-home. The only network traffic
|
||||
Codeman itself makes is between your browser and your server.
|
||||
|
||||
Your agent CLI is a separate matter: Claude Code talks to Anthropic, Codex talks to OpenAI,
|
||||
and so on. That traffic is the CLI's, on your own account, exactly as it would be in a
|
||||
@@ -179,13 +175,12 @@ the defaults discovers and attaches your live sessions.
|
||||
| Install route | Update with |
|
||||
| ------------- | --------------------------------------------------------------------------- |
|
||||
| Installer | Re-run the install one-liner, or **App Settings → System → Updates**. |
|
||||
| Installer, without git | Re-run the install one-liner. The in-app updater says so instead of offering an update. |
|
||||
| npm | `npm update -g aicodeman` |
|
||||
| git clone | `git pull && npm install && npm run build`, then restart the service. |
|
||||
|
||||
The in-app updater covers git-clone installs supervised by systemd or launchd. It stashes a
|
||||
dirty tree rather than discarding it, and streams progress across the restart. npm installs
|
||||
and installs without git report as non-updatable.
|
||||
report as non-updatable.
|
||||
|
||||
### Will updating kill my running sessions?
|
||||
|
||||
@@ -212,15 +207,10 @@ cover them properly.
|
||||
### Can agents start and supervise other agents?
|
||||
|
||||
Yes. Codeman ships an agent skill that lets an agent inside a session drive the HTTP API:
|
||||
list sessions, spawn workers in any installed CLI, send prompts, and block until a worker's
|
||||
turn finishes. Install it once with `codeman skill install` or the Claude Code plugin, then
|
||||
ask in plain English, for example *"use codeman to start 5 claude workers, one per TODO
|
||||
item"*. Since 1.50.0 Codeman also injects it into every new session's case by default, so an
|
||||
agent inside Codeman usually has it already (App Settings → Agents & CLIs → **Codeman Agent
|
||||
Skill** turns that off).
|
||||
list sessions, spawn workers, send prompts, and block until a worker's turn finishes. It is
|
||||
off by default and enabled per case.
|
||||
|
||||
See [The Codeman Skill](Codeman-Skill), with recordings of real runs, and
|
||||
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the other routes.
|
||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent).
|
||||
|
||||
### Can I run a case in a container?
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Git Worktrees
|
||||
|
||||
Sessions in one case share its folder. That is fine for one agent, but three agents in the
|
||||
same checkout edit the same files, overwrite each other's builds, and commit each other's
|
||||
half-finished work. A **git worktree** gives each of them a checkout of its own: same
|
||||
repository, same history, a separate folder on a separate branch.
|
||||
|
||||
Codeman creates worktrees for a case whose folder is a git repository, starts sessions in
|
||||
them, and removes them again when the work is done.
|
||||
|
||||
## Where they live
|
||||
|
||||
`<case>/.claude/worktrees/<name>`, on a new branch `worktree-<name>`. That is exactly where
|
||||
Claude Code's own `claude --worktree <name>` puts them, so a worktree made in Codeman is one
|
||||
Claude Code recognizes, and the other way round. Codeman's Recent Sessions and Session Manager
|
||||
lists mark a session that runs in one with a `⑂ <name>` pill.
|
||||
|
||||
The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/`
|
||||
(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the
|
||||
`codeman` folder it writes while **Codeman Agent Skill** is on, the default) to the repository's local
|
||||
`.git/info/exclude` when git does not ignore them already. Without that, those files would make
|
||||
every worktree look modified and git would refuse to remove it. Your tracked `.gitignore` is
|
||||
never touched.
|
||||
|
||||
## Creating one and starting a session in it
|
||||
|
||||
1. Pick the case in the toolbar's case picker.
|
||||
2. Open the picker again and choose **Git worktrees…** (on a phone: the case sheet's
|
||||
**Worktrees** button).
|
||||
3. Under **New worktree**, type a name (letters, digits, `.`, `_`, `-`, up to 64 characters).
|
||||
4. Pick the CLI under **Start sessions with** (Claude Code, Codex, a shell, any installed run
|
||||
mode).
|
||||
5. **Create & start session**.
|
||||
|
||||
The worktree is created from the case's current commit, and the session opens in it as a tab
|
||||
named `w1-<name>`. Under **Branch and start point** you can name the branch yourself or start
|
||||
from another branch, tag or commit. An existing branch is never reused or reset: pick another
|
||||
name.
|
||||
|
||||
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
|
||||
|
||||
## When an agent moves into a worktree
|
||||
|
||||
Claude Code can move a running session into a worktree part way through (its EnterWorktree tool),
|
||||
and the session's tab stays on the case. While the session's pane is in a worktree inside the
|
||||
session's folder, the **Files** panel lists that worktree, so you see the files the agent is
|
||||
editing. A strip under the panel's title names the worktree, and its **Session folder** button
|
||||
switches to the session's own folder (**Back to worktree** returns). The panel follows the agent
|
||||
in and back out on its own while it is open.
|
||||
|
||||
Only the Files panel moves. The **Git** panel stays on the session's folder, so it can show
|
||||
`main` while the strip names the worktree. And `.claude` is a hidden folder of the session's
|
||||
folder, so an `.html` file in `.claude/worktrees/<name>` opens as source rather than as a
|
||||
rendered page. A session started in the worktree does not have that limit.
|
||||
|
||||
A worktree beside the repository (`git worktree add ../feature`, or a setup that makes
|
||||
EnterWorktree put worktrees there) is followed too, as long as the session was started at the top
|
||||
of the repository or of one of its worktrees. The panel then lists that folder with full paths,
|
||||
and you can preview and edit its files there. Codeman follows such a worktree only when the
|
||||
session's own repository lists it. Codeman never follows one of these:
|
||||
|
||||
- your home folder or above it, or a folder that holds the session's folder;
|
||||
- a folder at, inside or around a Docker case's workspace;
|
||||
- in multi-user mode, unless the session's owner is an admin, a folder outside the owner's own space and the session's folder;
|
||||
- any worktree beside the repository while the server confines attachments to the workspace;
|
||||
- a folder at, inside or around a tree the server blocks for attachments, or a folder that is itself a credential folder such as `.ssh`.
|
||||
|
||||
A session started in a subfolder of the repository follows no worktree outside that subfolder,
|
||||
including the one Claude Code creates at the top of the repository. Open such a worktree as a case
|
||||
of its own with Add Case > Link Existing.
|
||||
|
||||
## Removing one
|
||||
|
||||
Two ways, and both use `git worktree remove` **without** `--force`:
|
||||
|
||||
- **Remove…** on the worktree's row in the dialog.
|
||||
- Closing a session that runs in a worktree: the Close Session dialog offers **Kill & remove
|
||||
worktree**, which kills the session and then removes its worktree.
|
||||
|
||||
git refuses to remove a worktree that still holds uncommitted or untracked files, and Codeman
|
||||
shows that refusal as git worded it and leaves everything in place. Commit, stash or delete the
|
||||
work first, then remove again. A worktree a live session still runs in is refused too.
|
||||
|
||||
**Also delete its branch** deletes the branch with `git branch -d`, which keeps a branch whose
|
||||
commits are not merged yet. The worktree is still removed, and the message says the branch was
|
||||
kept.
|
||||
|
||||
If a worktree's folder was deleted by hand, its row says **missing** and offers only
|
||||
**Remove…**: that clears the record git keeps of it, so the name can be used for a new worktree.
|
||||
|
||||
## What is not supported
|
||||
|
||||
- **Docker cases and remote (SSH) cases.** A container can write to its workspace, and creating
|
||||
a worktree runs the repository's hooks and checkout filters on the Codeman machine, so git is
|
||||
never run there. A remote case's repository is on another machine. For the same reason a
|
||||
worktree that a Docker case uses as its workspace (or that holds one) is never removed from
|
||||
here: remove the Docker case first.
|
||||
- A case folder that is not the top of a repository (a subfolder of one), a bare repository, or
|
||||
an empty repository with no commit yet.
|
||||
- Worktrees outside `.claude/worktrees/` (made by hand with `git worktree add`) are listed, but
|
||||
Codeman neither starts sessions in them from the dialog nor removes them.
|
||||
|
||||
The HTTP endpoints are in the [HTTP API](HTTP-API) reference (`/api/cases/:name/worktrees`).
|
||||
@@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain:
|
||||
| ------------------- | -------- | --------------------------------------------------- |
|
||||
| System | 56 | Status, settings, digest, updates, tunnel. |
|
||||
| Sessions | 34 | Create, input, terminal, wait, last response, kill. |
|
||||
| Cases | 39 | Create, link, clone, worktrees, remote and docker cases. |
|
||||
| Cases | 34 | Create, link, clone, remote and docker cases. |
|
||||
| Files | 17 | Preview, edit, raw, attachments, path picker. |
|
||||
| Orchestrator | 10 | Plans and phases. |
|
||||
| Ralph | 9 | Loop control and configuration. |
|
||||
@@ -92,32 +92,6 @@ Roughly 235 handlers across 26 route modules. By domain:
|
||||
|
||||
Each route module documents its own endpoints in its file header.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
|
||||
versioned endpoints are:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/cron/jobs` | List jobs |
|
||||
| POST | `/api/v1/cron/jobs` | Create a job |
|
||||
| GET | `/api/v1/cron/jobs/:id` | Read a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
|
||||
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
|
||||
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
|
||||
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
|
||||
| GET | `/api/v1/cron/runs` | Read all accessible run history |
|
||||
|
||||
Responses use the envelope above: job lists/history have arrays in `data`, a
|
||||
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
|
||||
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
|
||||
`session_started` and `prompt_sent` describe launch/delivery, not task success.
|
||||
|
||||
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
|
||||
lists all request and response fields, validation, and ownership restrictions.
|
||||
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
|
||||
|
||||
## Long-polling instead of polling
|
||||
|
||||
Three calls block until something happens, so an agent driving Codeman from a shell can wait
|
||||
@@ -142,10 +116,9 @@ Three semantics that break callers who assume otherwise:
|
||||
`wait-output` matches a **literal substring, never a regex.** That is deliberate: no regex
|
||||
means no catastrophic backtracking on attacker-influenced output.
|
||||
|
||||
Only `claude` and `deepseek` sessions emit `stop`: Claude's come from Claude Code hooks,
|
||||
DeepSeek's from the harness reporting its state to Codeman. `blocked` fires for those two
|
||||
and for `codex`, whose approval, question and trust dialogs Codeman reads off the screen.
|
||||
Shell and the other external CLI sessions accept `idle`, `working`, and `exit`.
|
||||
Only `claude` and `deepseek` sessions emit `stop` and `blocked`: Claude's come from Claude
|
||||
Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and the other
|
||||
external CLI sessions accept `idle`, `working`, and `exit`.
|
||||
|
||||
## SSE
|
||||
|
||||
@@ -171,12 +144,6 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
|
||||
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
||||
curl -s "$API/api/subagents" | jq # background agents
|
||||
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
||||
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
|
||||
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
|
||||
curl -s "$API/api/config-backups" | jq # config snapshots: folder, retention, list (admin only in multi-user mode)
|
||||
curl -s -X POST "$API/api/config-backups" | jq # back up the customized config files now
|
||||
curl -s -X POST "$API/api/config-backups/cfg-20261011-120000/restore" | jq # put a snapshot back (after a pre-restore copy)
|
||||
curl -s "$API/api/config-backups/diff?from=cfg-20261011-120000" | jq # what differs from the running config (`to=` another snapshot id to compare two)
|
||||
|
||||
# with ID set to a session id:
|
||||
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
||||
@@ -189,7 +156,7 @@ curl -s -X POST "$API/api/sessions/$ID/custom-model" -H 'Content-Type: applicati
|
||||
|
||||
| Limit | Default |
|
||||
| --------------------- | ------------------------------------------ |
|
||||
| Max sessions | 50 (`CODEMAN_MAX_SESSIONS`) |
|
||||
| Max sessions | 50 |
|
||||
| Max agent windows | 500 |
|
||||
| Max SSE clients | 100 |
|
||||
| Terminal buffer | 32 MB per session |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
Codeman runs your coding agents on your own machine and puts them behind one dashboard you
|
||||
can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
|
||||
Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to the browser, and keeps
|
||||
Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to the browser, and keeps
|
||||
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
|
||||
subscription limit resets, runs jobs on a schedule, and shows every background subagent
|
||||
live.
|
||||
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
|
||||
|
||||
**Already running it**
|
||||
|
||||
- [Agent CLIs](Agent-CLIs) - the eleven run modes, their setup, and which features are Claude-only.
|
||||
- [Agent CLIs](Agent-CLIs) - the ten run modes, their setup, and which features are Claude-only.
|
||||
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
|
||||
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
|
||||
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
|
||||
@@ -41,8 +41,7 @@ codeman web # then open http://localhost:3000
|
||||
|
||||
**Driving it from code**
|
||||
|
||||
- [The Codeman Skill](Codeman-Skill) - one prompt, a whole crew: an agent starting and supervising worker sessions.
|
||||
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives, reading saved Codex conversations offline.
|
||||
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the bundled skill, worker sessions, wait primitives.
|
||||
- [HTTP API](HTTP-API) - the envelope, auth, the endpoint map, SSE events.
|
||||
- [Hooks And Integrations](Hooks-And-Integrations) - events flowing back into Codeman.
|
||||
|
||||
@@ -63,11 +62,8 @@ codeman web # then open http://localhost:3000
|
||||
| Page | What it answers |
|
||||
| ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| [The Dashboard](The-Dashboard) | What is the UI telling me? |
|
||||
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
|
||||
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
||||
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
|
||||
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
||||
| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? |
|
||||
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
||||
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
|
||||
| [Keyboard Shortcuts](Keyboard-Shortcuts) | What can I drive from the keyboard? |
|
||||
@@ -79,7 +75,6 @@ codeman web # then open http://localhost:3000
|
||||
| ------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| [Keeping Agents Running](Keeping-Agents-Running) | How does it run unattended overnight? |
|
||||
| [Notifications And Approvals](Notifications-And-Approvals) | How do I know an agent needs me, and answer from my phone? |
|
||||
| [Discord Bridge](Discord-Bridge) | How do I answer a blocked agent from Discord, in a thread per tab? |
|
||||
| [Cron Jobs](Cron-Jobs) | How do I run an agent on a schedule? |
|
||||
| [Autonomous Loops](Autonomous-Loops) | What are the Ralph and Orchestrator loops for? |
|
||||
| [Watching Agents Work](Watching-Agents-Work) | How do I see what the subagents are doing? |
|
||||
@@ -104,7 +99,6 @@ codeman web # then open http://localhost:3000
|
||||
|
||||
| Page | What it answers |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------- |
|
||||
| [The Codeman Skill](Codeman-Skill) | How do I get an agent to start and run workers for me? |
|
||||
| [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) | How does an agent spawn and drive workers? |
|
||||
| [HTTP API](HTTP-API) | What can I call, and what comes back? |
|
||||
| [Hooks And Integrations](Hooks-And-Integrations) | How do I wire Codeman into something else? |
|
||||
@@ -114,7 +108,6 @@ codeman web # then open http://localhost:3000
|
||||
| Page | What it answers |
|
||||
| --------------------------------------------- | -------------------------------------------------- |
|
||||
| [Running As A Service](Running-As-A-Service) | How do I keep it up across reboots, and update it? |
|
||||
| [Config Backups](Config-Backups) | How do I get my settings back after they were changed or lost? |
|
||||
| [Troubleshooting](Troubleshooting) | Why is it doing that? |
|
||||
| [FAQ](FAQ) | The questions that keep coming up. |
|
||||
| [Contributing](Contributing) | How do I send a fix? |
|
||||
@@ -129,7 +122,7 @@ codeman web # then open http://localhost:3000
|
||||
| OS | macOS or Linux. Windows works through WSL2. |
|
||||
| Node.js | 22 or newer. |
|
||||
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
|
||||
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI. Plain shell sessions need none. |
|
||||
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP. Plain shell sessions need none. |
|
||||
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
|
||||
|
||||
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
|
||||
|
||||
@@ -19,14 +19,11 @@ terminal into something that can notify you.
|
||||
| `teammate_idle` | An agent-team member goes idle. | Team surfaces. |
|
||||
| `task_completed` | A task finishes. | Task tracking, run summary. |
|
||||
|
||||
This is why several Codeman features are Claude-only. One partial exception is DeepSeek
|
||||
This is why several Codeman features are Claude-only. The one partial exception is DeepSeek
|
||||
Harness, whose terminal front door reports idle, working and blocked to Codeman over the
|
||||
harness's own supervisor contract, so it gets the hook-driven surfaces without any hook
|
||||
file. Codex gets one of them from its screen: Codeman reads its approval, question and
|
||||
trust dialogs off the pane and raises the same red "needs you" alert a `permission_prompt`
|
||||
hook raises (see [Agent CLIs](Agent-CLIs#codex)). The other CLIs have no equivalent, so for
|
||||
them Codeman watches terminal output, which reveals that something happened but not what it
|
||||
was.
|
||||
file. The other CLIs have no equivalent, so for them Codeman watches terminal output, which
|
||||
reveals that something happened but not what it was.
|
||||
|
||||
### How hooks get installed
|
||||
|
||||
|
||||
@@ -125,10 +125,9 @@ Sending prompts over the API has one rule that catches everyone: **the payload m
|
||||
`\r`** or Enter is never sent. The request still succeeds, the text sits unsubmitted in the
|
||||
composer, and any wait burns its whole timeout on a turn that never started.
|
||||
|
||||
With `useMux`, embedded newlines are kept: multi-line input goes in as one paste and the
|
||||
Enter after it submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The
|
||||
check that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so long
|
||||
multi-line content is still safer in a file the agent is told to read.
|
||||
Input is also **single line**. Embedded newlines are stripped rather than rejected, so
|
||||
`"echo A\necho B\r"` runs the joined `echo Aecho B`. Put multi-line content in a file and
|
||||
tell the agent to read it.
|
||||
|
||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent).
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it
|
||||
| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
|
||||
|
||||
Codeman itself sends no telemetry and phones no home. The only network traffic is your
|
||||
browser to your server, and whatever the agent CLI you chose does on its own, plus the
|
||||
server's own few requests (Codex usage, web push, webhooks) and, on by default while Copilot
|
||||
CLI is installed and signed in, a read of your own GitHub Copilot quota every 10 minutes
|
||||
(Plan Usage Limits in App Settings turns it off; see [Agent CLIs](Agent-CLIs)).
|
||||
browser to your server, and whatever the agent CLI you chose does on its own.
|
||||
|
||||
## Route A: the installer (recommended)
|
||||
|
||||
@@ -25,8 +22,7 @@ curl -fsSL https://getcodeman.com/install | bash
|
||||
|
||||
This installs Node.js, tmux and a build toolchain if they are missing (node-pty ships no
|
||||
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
|
||||
builds it. On a Mac, the [Codeman app](#on-a-mac-the-codeman-app) can run this installer
|
||||
for you, with no Terminal.
|
||||
builds it.
|
||||
|
||||
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
|
||||
an existing install), then asks everything it needs up front, then does the work
|
||||
@@ -79,8 +75,6 @@ install.sh uninstall # remove (offers to undo a rename it performed)
|
||||
install.sh tailscale # retrofit Tailscale access onto an existing install
|
||||
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
|
||||
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
|
||||
install.sh preflight # print what is on this machine as JSON; changes nothing
|
||||
install.sh agent <id> # install one agent CLI and nothing else, also next to a running Codeman
|
||||
```
|
||||
|
||||
**Flags** answer the questions from the command line and pipe through `bash -s --`:
|
||||
@@ -97,74 +91,14 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every
|
||||
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
|
||||
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
|
||||
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
|
||||
`--ai-cli <id>` answers the agent menu that appears when no agent CLI is found, with no
|
||||
terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it.
|
||||
To add an agent later, `install.sh agent codex` installs that one and changes nothing else:
|
||||
it does not touch Codeman or its service, and an agent that is already there is left alone.
|
||||
Codeman Desktop does the same from *Add a coding agent*. A running Codeman notices the new
|
||||
agent at once; reload an open page to see it.
|
||||
|
||||
**Automation and CI**: with no terminal attached, any step that would change the system
|
||||
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
|
||||
approve those steps. With nothing else said, that also installs the first agent CLI of the
|
||||
catalogue when none is found; `--ai-cli` (or `CODEMAN_AI_CLI`) says which one, or none.
|
||||
`CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
|
||||
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
|
||||
installs Tailscale itself non-interactively; a non-interactive run never renames the
|
||||
machine and never starts a service. Everything the unattended steps print goes to
|
||||
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
|
||||
|
||||
### Without administrator rights
|
||||
|
||||
Codeman itself always lived in your home directory. What used `sudo` (or Homebrew on a
|
||||
Mac) were the things it needs first. On an account that has no administrator rights the
|
||||
installer says so in its summary (`Admin not used (...)`) and gets those another way:
|
||||
|
||||
| Needed | With administrator rights | Without |
|
||||
| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Node.js 22 | system package or Homebrew | The official tarball from nodejs.org, checked against the `SHASUMS256.txt` published beside it, unpacked to `~/.codeman/runtime/node`. |
|
||||
| tmux | system package or Homebrew | The tmux project's own build ([tmux-builds](https://github.com/tmux/tmux-builds)), checked against a SHA-256 pinned in the installer, in `~/.codeman/runtime/bin`. |
|
||||
| git | system package or Xcode tools | Not installed. Without a working git, the source is downloaded as an archive instead of cloned. |
|
||||
| Build toolchain (Linux) | system package | Cannot be supplied. See below. |
|
||||
|
||||
This route is taken by itself when `sudo` is not installed, when the account is outside the
|
||||
`sudo`, `wheel` and `admin` groups and `sudo` will not run without a password, when the
|
||||
`sudo` password is refused, and on a Mac for an account that is not an administrator and
|
||||
has no Homebrew it can write to.
|
||||
`--no-admin` (or `CODEMAN_NO_ADMIN=1`) forces it on an account that does have the rights;
|
||||
`CODEMAN_NO_ADMIN=0` switches the detection off. Nothing on this route runs `sudo`,
|
||||
Homebrew or the Xcode tools installer.
|
||||
|
||||
The group check cannot see a sudoers entry, so an account that sudoers lists by name, with
|
||||
a password, takes this route too. The summary then reads `could not confirm that this
|
||||
account may use sudo`, and to use `sudo` anyway:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | CODEMAN_NO_ADMIN=0 bash
|
||||
```
|
||||
|
||||
What to know about it:
|
||||
|
||||
- **Linux needs make, a C++ compiler and python3 to be there already.** node-pty compiles
|
||||
from source on Linux, and a compiler is a system package. The installer checks this
|
||||
before it downloads anything and names what is missing. macOS needs no compiler.
|
||||
- **The tmux download needs macOS 15 or newer** (and x86_64 or arm64 on either system).
|
||||
On an older Mac an administrator has to install tmux once (`brew install tmux`); the
|
||||
rest then works without one.
|
||||
- **An install without git updates by re-running the install line**, not from App
|
||||
Settings. The re-run builds the new version beside the old one and swaps it in only
|
||||
after the build succeeded, so a failed download or build leaves the install as it was.
|
||||
Once the machine has a working git, the next re-run turns the install into a normal
|
||||
clone and in-app updates work again. On a Mac, "a working git" means the Xcode Command
|
||||
Line Tools are installed: without them `/usr/bin/git` only opens Apple's install dialog,
|
||||
and the installer never runs it.
|
||||
- **Tailscale works where it is already installed and your account may configure it.** On
|
||||
Linux, installing it, logging in and granting serve rights each need an administrator
|
||||
(`sudo tailscale set --operator=$USER`, once). Local network and this-machine access
|
||||
need nothing, and `install.sh cloudflared` puts `cloudflared` in `~/.local/bin`.
|
||||
- `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin`, and
|
||||
`~/.codeman/runtime/node/bin` is added to your PATH, so a CLI installed with
|
||||
`npm install -g` is found as well.
|
||||
|
||||
## Route B: npm
|
||||
|
||||
```bash
|
||||
@@ -207,93 +141,18 @@ See [Contributing](Contributing) for the rest of the development loop.
|
||||
## Route D: Docker Compose
|
||||
|
||||
Codeman itself can run in a container and spawn Docker cases as sibling containers through
|
||||
the host's Docker socket. You need Docker with the Compose v2 plugin (2.27.2 or newer), and
|
||||
an account that can use Docker without sudo (on Linux, the `docker` group). Then, on Linux:
|
||||
the host's Docker socket. Copy `docker/.env.example` to `docker/.env`, set
|
||||
`CODEMAN_PASSWORD`, then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
|
||||
bash docker/Start-Codeman.sh
|
||||
```
|
||||
|
||||
The first run asks for a data folder, a port and a password. Enter takes each default,
|
||||
including a generated password, shown on your terminal and kept in `docker/.env`. It then
|
||||
writes that file, builds the image (a few minutes the first time) and ends on the URL once
|
||||
Codeman answers. The image
|
||||
already includes Claude Code, Codex, Gemini CLI and OpenCode: start a session and log the CLI
|
||||
in once, and the login is kept in the data folder.
|
||||
|
||||
Run the script again after updating rather than a plain `docker compose up`, so the rebuilt
|
||||
image, the refreshed volumes and the entrypoint arrive together. The full guide, including
|
||||
Unraid, storage and networking options, is
|
||||
storage and networking options, is
|
||||
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
|
||||
|
||||
## On a Mac: the Codeman app
|
||||
|
||||
[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg):
|
||||
one universal app for Apple Silicon and Intel, macOS 11.3 or later.
|
||||
|
||||
A small native app that opens Codeman in its own window. Its first screen has two tabs:
|
||||
|
||||
- **This Mac** looks for Codeman here and offers what fits: *Open Codeman* when it runs,
|
||||
*Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is
|
||||
not there. It also lists the Codeman servers of yours that already run on this Mac (one
|
||||
started by hand in a terminal, say) and offers to connect to those; it never starts a
|
||||
second one next to them. Install runs [the installer](#route-a-the-installer-recommended)
|
||||
for you, with no Terminal and no administrator password: it asks which coding agent to
|
||||
install (only when the Mac has none, and *Skip for now* is there) and who may reach
|
||||
Codeman (this Mac only, or the local network with a password), then shows the install as
|
||||
a checklist. *Add a coding agent* on this tab (*Add a Coding Agent to This Mac…* in the app menu) installs one later.
|
||||
- **Another machine** connects to Codeman by address, a tailnet name included, and keeps
|
||||
the password in the Keychain. *Codeman is not installed there yet?* opens Terminal and
|
||||
runs the installer on that machine over SSH.
|
||||
|
||||
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
|
||||
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
|
||||
your own for working, for waiting for you and for needing permission. All three are off
|
||||
until you choose, and the menu only exists on such a Mac.
|
||||
|
||||
### First launch
|
||||
|
||||
1. Open `Codeman.dmg` and drag Codeman into Applications.
|
||||
2. Open Codeman from Applications. macOS asks once whether to open an app downloaded from
|
||||
the internet: click **Open**.
|
||||
3. On macOS 15 and later, the first connection to an address on your local network (a LAN
|
||||
IP, a `.local` name) makes macOS ask whether Codeman may use the local network, and that
|
||||
first attempt can fail while the question is open: connect again after answering. If
|
||||
you denied it, turn Codeman on in **System Settings > Privacy & Security > Local
|
||||
Network**.
|
||||
|
||||
What to know about it:
|
||||
|
||||
- **Quitting the app does not stop Codeman.** An install from the app runs Codeman as a
|
||||
LaunchAgent that starts at login, so agents keep working with the window closed, and a
|
||||
browser or phone reaches it as before.
|
||||
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but
|
||||
Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later
|
||||
installed. On an older WebKit the app says so and does not connect.
|
||||
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes
|
||||
from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the
|
||||
app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
|
||||
- **The install from the app reaches as far as the local network.** Tailscale needs its own
|
||||
login and setup, which the installer does at a terminal: choose *Install in Terminal
|
||||
instead*, or run `install.sh tailscale` afterwards.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a
|
||||
self-signed certificate, which macOS's WebKit rejects. Use `http://127.0.0.1:3000` for a
|
||||
server on this Mac. For another machine, serve Codeman through `tailscale serve`
|
||||
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse proxy with a
|
||||
real certificate.
|
||||
- **The Codeman it installs is an ordinary installer install,** made on the
|
||||
[route without administrator rights](#without-administrator-rights), so
|
||||
[Updating](#updating) and [Uninstalling](#uninstalling) below apply to it.
|
||||
- **The app does not update itself yet.** Download the new `Codeman.dmg` and drag it over
|
||||
the old copy.
|
||||
- **Deleting the app leaves Codeman running.** The app's own settings are in
|
||||
`~/Library/Application Support/Codeman/`, and its saved passwords in the Keychain under
|
||||
*Codeman Desktop*.
|
||||
|
||||
Everything else, including how to build it yourself:
|
||||
[`packages/desktop/README.md`](https://github.com/Ark0N/Codeman/blob/master/packages/desktop/README.md).
|
||||
|
||||
## Installing an agent CLI
|
||||
|
||||
Codeman drives CLIs, it does not bundle them. Install at least one:
|
||||
@@ -332,7 +191,6 @@ If the dashboard loads and **+ New Session** opens, you are done. Continue to
|
||||
| Path | What |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `~/.codeman/app` | The installed code (installer route only). |
|
||||
| `~/.codeman/runtime` | Node.js and tmux, when the installer downloaded them instead of using system packages ([no administrator rights](#without-administrator-rights)). |
|
||||
| `~/.codeman/` | All state: `state.json`, settings, session history, push keys, TLS certs. See [Core Concepts](Core-Concepts). |
|
||||
| `~/codeman-cases/` | Cases created from scratch. Linked cases stay wherever they already are. |
|
||||
| `~/.codeman/web.log` | Log for a detached (`-d`) server. |
|
||||
@@ -356,10 +214,9 @@ Full detail, including logs and the self-updater, is in
|
||||
| Install route | How to update |
|
||||
| ------------- | ----------------------------------------------------------------- |
|
||||
| Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. |
|
||||
| Installer, without git | Re-run the one-liner. The in-app updater says so instead of offering an update. |
|
||||
| npm | `npm update -g aicodeman` |
|
||||
| git clone | `git pull && npm install && npm run build`, then restart. |
|
||||
| Docker Compose | `git pull`, then re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
|
||||
| Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
|
||||
|
||||
The in-app updater covers git-clone installs supervised by systemd or launchd. It restarts
|
||||
the process that is running it, so the actual work happens in a detached script and the
|
||||
@@ -372,12 +229,8 @@ install.sh uninstall # installer route
|
||||
npm uninstall -g aicodeman # npm route
|
||||
```
|
||||
|
||||
The installer route asks before it removes `~/.codeman/runtime` (the Node.js and tmux it
|
||||
downloaded, if any), and keeps it by default when you installed CLIs there with
|
||||
`npm install -g`. That folder is inside `~/.codeman/`, so saying yes to the data directory
|
||||
question that follows removes it even if you kept it a step earlier, and the question says
|
||||
so. By default neither route removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand
|
||||
if you want the state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
|
||||
Neither removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand if you want the
|
||||
state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
|
||||
point at directories you already had, but cases created from scratch have their only copy
|
||||
there.
|
||||
|
||||
|
||||
@@ -27,14 +27,12 @@ keystroke echo. Idle now lands a few seconds after a turn genuinely ends.
|
||||
There are several layers stacked on that: a completion message from the CLI, an AI check,
|
||||
output silence, and token stability.
|
||||
|
||||
**For the other CLIs** it depends on what the CLI tells Codeman. Codex, OpenCode, Gemini
|
||||
CLI, Pi, OMP and Antigravity each declare their own prompt marker and working line, so they
|
||||
get the same screen check Claude does (before 1.26.1 every Codex session reported idle for
|
||||
its whole life). DeepSeek Harness reports its end of turn and its dialogs to Codeman itself,
|
||||
which is as precise as hooks; its working and idle display still comes from its output.
|
||||
Grok Build declares neither. Codeman calls a new grok pane idle 3 seconds after launch, and
|
||||
after that it has only generic cues to go on (Claude's `❯` prompt marker, a braille spinner
|
||||
glyph), which grok may not draw, so its working and idle status is unreliable.
|
||||
**For the other CLIs** it depends on what the CLI tells Codeman. Codex declares its own
|
||||
prompt glyph and working line, so it gets the same screen check Claude does (before 1.26.1
|
||||
every Codex session reported idle for its whole life). DeepSeek Harness reports idle,
|
||||
working and blocked to Codeman itself, which is as precise as hooks. Everything else is
|
||||
output stabilization: the session is idle when output stops changing. Coarser, and it is
|
||||
why the features further down this page are Claude-only.
|
||||
|
||||
## The Respawn Controller
|
||||
|
||||
@@ -101,27 +99,10 @@ limit-paused. Without that, the next cycle would fire `/clear` and wipe the conv
|
||||
are waiting to resume. This is the single most useful setting for overnight runs on a
|
||||
subscription plan.
|
||||
|
||||
## The prompt cache: readout and keep-warm
|
||||
|
||||
**Claude only.** On the Respawn tab, above the loop controls.
|
||||
|
||||
Claude Code bills a turn against the cached conversation while that cache is warm, and lets the cache expire after a fixed idle time: one hour on a subscription within plan usage, five minutes on an API key, a cloud provider or extra-usage credits. The first turn after a longer break re-processes the whole context at the cache-write rate, which is why coming back to a big session after lunch costs more than the turns around it.
|
||||
|
||||
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
|
||||
|
||||
**Keep the cache warm while idle** is the switch under the readout. With it on, Codeman sends a one-line keep-alive turn (`Codeman cache keep-alive: no action needed, reply with just "ok".`) about five minutes before the cache would expire. Claude answers `ok`, the request refreshes the cache for another hour, and your next real prompt reads the conversation from cache instead of re-processing it. Measured on sessions of about 110k tokens, the keep-alive cost a cache read plus a one-word reply, a small fraction of the re-write it prevented.
|
||||
|
||||
It only fires when there is nothing else going on: the session is idle with an empty prompt (Claude Code's greyed-out prompt suggestion counts as empty), no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it; a keep-alive that could not be sent is retried the same way. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why.
|
||||
|
||||
Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before.
|
||||
|
||||
**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook, nor a row in the Away digest or the session's Summary. It never clears an alert that was already waiting for you. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides.
|
||||
|
||||
## The plan usage chip
|
||||
|
||||
A header chip showing live subscription usage: Claude (the 5-hour and weekly windows), Codex
|
||||
(its limits) and, while Copilot CLI is installed and signed in, Copilot's monthly premium-request
|
||||
quota. On by default on desktop and off on phones.
|
||||
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
||||
off on phones.
|
||||
|
||||
It works through a status line exporter that Codeman hands to `claude` as an ephemeral
|
||||
setting when it spawns the session, never written to disk, which posts Claude's own rate
|
||||
@@ -129,8 +110,7 @@ limit data back to Codeman. Your own status line (project-local, project, then
|
||||
`~/.claude/settings.json`) is wrapped and printed through, and a `claude` you run by hand
|
||||
outside Codeman sees nothing of it. Workspaces an older Codeman wrote the exporter into are
|
||||
cleaned up the first time a session starts there. Codex limits come from a read-only poll of
|
||||
its own app-server, and the Copilot quota from a read-only request to `api.github.com` every
|
||||
10 minutes with Copilot's own token (on by default; Plan Usage Limits off stops it). Known limit: sessions inside a Docker case do not feed the chip yet.
|
||||
its own app-server. Known limit: sessions inside a Docker case do not feed the chip yet.
|
||||
|
||||
The chip and the exporter are the same setting. Turning the chip on without the exporter
|
||||
would leave it showing a dash forever, so resolve it in one place: **App Settings**. A
|
||||
@@ -173,10 +153,8 @@ run summary and lifecycle log carry the detail.
|
||||
- **The update prompt is what makes a reset survivable.** After `/clear`, everything the
|
||||
agent knows comes from that summary and the project files. A vague update prompt produces
|
||||
a vague next cycle.
|
||||
- **Non-Claude sessions can respawn**, but with no usage-limit auto-resume, and with
|
||||
output-based idle detection except on DeepSeek Harness, whose own end-of-turn report
|
||||
reaches the respawn controller the way Claude's `stop` hook does while its status bridge
|
||||
is on (a local session, by default).
|
||||
- **Non-Claude sessions can respawn**, but with output-based idle detection and no
|
||||
usage-limit auto-resume.
|
||||
- **Do not run respawn on a session you are actively typing in.** It will send prompts
|
||||
underneath you.
|
||||
|
||||
|
||||
@@ -8,17 +8,14 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | --------------------------------------------------------------- |
|
||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. On the **New session** row, `Left` / `Right` change the agent it starts. |
|
||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||
| `Ctrl+W` | Kill the active session. |
|
||||
| `Ctrl+Tab` | Next session. |
|
||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||
|
||||
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||
|
||||
## Terminal
|
||||
|
||||
| Shortcut | Action |
|
||||
@@ -39,42 +36,18 @@ from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||
|
||||
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
||||
|
||||
## Tile grid
|
||||
|
||||
| Shortcut | Action |
|
||||
| --------------------------- | ------------------------------------------------------------ |
|
||||
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
|
||||
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
|
||||
| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). |
|
||||
|
||||
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
|
||||
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
|
||||
default. See [Tile Grid](Tile-Grid).
|
||||
|
||||
## Everything else
|
||||
|
||||
| Shortcut | Action |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| `Ctrl+Shift+V` | Toggle voice input. |
|
||||
| `Ctrl+?` | Shortcut reference overlay. |
|
||||
| `Escape` | Close panels and modals. |
|
||||
| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. |
|
||||
| Shortcut | Action |
|
||||
| -------------- | ------------------------------- |
|
||||
| `Ctrl+Shift+V` | Toggle voice input. |
|
||||
| `Ctrl+?` | Shortcut reference overlay. |
|
||||
| `Escape` | Close panels and modals. |
|
||||
|
||||
## Rebinding
|
||||
|
||||
**App Settings → Shortcuts.** Bindings live in a registry with per-user overrides, so a
|
||||
rebind is stored as an override on top of the default rather than replacing the table.
|
||||
Previous Session (`Alt+[`) and Next Session (alternate, `Alt+]`) are rows like any other,
|
||||
so they can be rebound too.
|
||||
|
||||
A chord another action already uses is refused, and the message names that action. The same
|
||||
check runs when you re-enable a disabled shortcut and when you press Reset: if its chord was
|
||||
given to something else in the meantime, it stays as it was. `Alt+1` to `Alt+9`,
|
||||
`Shift+Enter`/`Ctrl+Enter` and `Ctrl+V`/`Cmd+V` are reserved.
|
||||
|
||||
Two things are deliberately not rebindable:
|
||||
|
||||
@@ -87,11 +60,8 @@ Two things are deliberately not rebindable:
|
||||
|
||||
The terminal sees keystrokes before the app does. Any chord the app claims has to also be
|
||||
swallowed at the terminal layer, or xterm writes the control byte into the session as well
|
||||
as triggering the action. A chord **you** rebind an action to is kept out of the terminal
|
||||
too, so rebinding Previous Session to `Alt+J` switches the session and sends nothing to the
|
||||
CLI. The built-in defaults are deliberately not held back this way: `Ctrl+L` clears the
|
||||
screen and also reaches the CLI, which redraws on it. Binding an action back onto its own
|
||||
default behaves like the default.
|
||||
as triggering the action. If you rebind something to a chord the terminal cares about
|
||||
(`Ctrl+D`, say), expect the CLI to see it too.
|
||||
|
||||
`Alt+1` through `Alt+9` are matched on **physical key position** rather than the character
|
||||
produced, so macOS Option layouts that produce `¡™£` still switch tabs.
|
||||
|
||||
@@ -37,17 +37,14 @@ a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs s
|
||||
the hinge, and opening or closing the device is treated as the device changing shape, never
|
||||
as the keyboard appearing.
|
||||
|
||||
**Swipe left and right** on the terminal to switch sessions, in the order the tabs are shown
|
||||
(a grouped or sorted tab layout included). A visible text selection pauses the swipe, and a
|
||||
terminal wider than the screen pans sideways instead.
|
||||
**Swipe left and right** on the terminal to switch sessions.
|
||||
|
||||
## The home screen
|
||||
|
||||
Tapping the "C" logo gives a session overview rather than a welcome page:
|
||||
|
||||
1. **NEEDS YOU** first: sessions blocked on a question, with answer strips (Approvals Inbox
|
||||
on) so you can resolve a Claude dialog without opening the session. A Codex or DeepSeek
|
||||
Harness row offers **Answer in terminal** instead, which opens the session.
|
||||
1. **NEEDS YOU** first: sessions blocked on a question, with answer strips so you can
|
||||
resolve them without opening the session.
|
||||
2. **CURRENT SESSIONS** with live status.
|
||||
3. **PAST SESSIONS**, resumable.
|
||||
|
||||
@@ -66,12 +63,10 @@ A row of keys above the virtual keyboard, and what it contains depends on the se
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
|
||||
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
|
||||
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
|
||||
by Enter, so your line breaks reach the agent intact. A prompt written in Persian, Arabic or
|
||||
Hebrew lays out right to left. Anything already typed on the terminal prompt moves into the
|
||||
editor when it opens. Drafts are kept per session and in memory only,
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Attach button attaches photos, videos or documents and puts their paths
|
||||
into the draft.
|
||||
is parked. The editor's Image button attaches photos and puts their paths into the draft.
|
||||
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
|
||||
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
|
||||
editing the last queued message and walking the prompt stack.
|
||||
@@ -105,42 +100,13 @@ looks exactly like a dead button.
|
||||
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
|
||||
into the Run dropdown.
|
||||
|
||||
## The Attach button
|
||||
|
||||
The toolbar's **Attach** button (the picture icon beside the mic) is the keyboard-down way to
|
||||
hand the agent a photo, a video or a document. One tap opens the system's sheet (camera,
|
||||
photo library, or a file from Files or a cloud drive), nothing else: no editor, no keyboard.
|
||||
The chosen files upload and their paths land on the prompt, so you then tap the terminal,
|
||||
write the rest of your message and press Enter. Videos (mp4, webm, mov, m4v, ogv)
|
||||
upload as recorded, up to 1 GB each by default (`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the
|
||||
server changes it), so a long 4K clip takes a moment on the way up. Documents are PDFs,
|
||||
Office files (docx, pptx, xlsx), zips and plain-text formats such as txt, md, csv and json;
|
||||
a document keeps the plain-letters part of its name in the saved file, so the agent and you
|
||||
can tell the uploads apart. See [Working With Files](Working-With-Files) for where they land and how long they
|
||||
stay.
|
||||
|
||||
If the keyboard was up when you tapped Attach, iOS closes it to show the photo sheet and does
|
||||
not bring it back, so the tap on the terminal is how you get it again. That is the system's
|
||||
behaviour around its own picker rather than a Codeman setting.
|
||||
|
||||
The paths go onto the prompt as unsent text, exactly like something you typed, which means
|
||||
they travel with it. Tap **Compose** afterwards and the editor opens holding the paths *and*
|
||||
anything you had already typed, so you can write in front of a path, fix a word, and Send.
|
||||
**Use terminal keyboard** hands a single-line message back the other way, onto the prompt,
|
||||
so neither surface holds a half of your message. A multi-line message cannot make that trip
|
||||
(the prompt is a single line), so it stays parked as a draft and the dot on the Compose key
|
||||
shows it is waiting. The Compose editor's own Attach button remains for the times you are
|
||||
already writing in the editor.
|
||||
|
||||
## Tapping, links and copying
|
||||
|
||||
- **Tap a link** in terminal output and it opens in a new tab. Same for a web link in an agent's
|
||||
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
|
||||
answer in the response viewer — it opens a tab rather than navigating the dashboard away,
|
||||
which on a phone would unload the whole session view.
|
||||
- **Tap a file path** an agent printed, or a link to one in its answer (Codex writes
|
||||
`[report](/tmp/report.json)`), and the file-preview overlay opens (Markdown rendered, HTML
|
||||
as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up
|
||||
transcript too.
|
||||
- **Tap a file path** an agent printed and the file-preview overlay opens; a log path opens the
|
||||
log viewer. Works in scrolled-up transcript too.
|
||||
- A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's
|
||||
numbered choice still answers the dialog even when the row contains a path — the dialog wins,
|
||||
because on a phone it is the only interaction that matters.
|
||||
@@ -154,12 +120,6 @@ already writing in the editor.
|
||||
|
||||
- The terminal and toolbar shift up when the keyboard opens, tracked through the browser's
|
||||
visual viewport rather than guessed.
|
||||
- **Opening the keyboard does not resize a Claude session.** Each resize made Claude reprint
|
||||
its screen at the new height and drop transcript lines from the session's history, so for a
|
||||
local Claude session the terminal keeps its size while the keyboard is up and the view
|
||||
scrolls instead, with the prompt and the line under it just above the keyboard. Codex,
|
||||
Claude's fullscreen mode, remote and Docker sessions still resize. **Keep Size Under
|
||||
Keyboard** in App Settings turns it off on this device.
|
||||
- **Two ways to dismiss the keyboard**: tap outside the terminal on inert space, or tap twice
|
||||
on inert terminal content. Tapping a control never dismisses it, and tapping the prompt row
|
||||
keeps focus so you can place the caret.
|
||||
@@ -178,9 +138,9 @@ phone, and it is the main reason the feature exists.
|
||||
|
||||
## Notifications
|
||||
|
||||
Push notifications reach you with no tab open, and with the Approvals Inbox on, those for a
|
||||
Claude permission prompt carry **Approve** and **Deny** buttons handled by the service
|
||||
worker, so you can unblock a Claude agent from the lock screen.
|
||||
Push notifications reach you with no tab open, and with the Approvals Inbox on they carry
|
||||
**Approve** and **Deny** buttons handled by the service worker, so you can unblock an agent
|
||||
from the lock screen.
|
||||
|
||||
Setup in [Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
@@ -188,7 +148,6 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
|
||||
last answer as scrollable text instead, with a **More** button for additional context.
|
||||
Persian, Arabic and Hebrew answers read right to left there.
|
||||
|
||||
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
|
||||
enough to fix a typo an agent introduced while you are away from your desk.
|
||||
@@ -199,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
||||
enforces it.
|
||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||
- The desktop home tab rail, which needs a wide window.
|
||||
- Lineage lines, which are a desktop overlay.
|
||||
- Lineage arcs, which are a desktop overlay.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -60,38 +60,6 @@ Admins see everything.
|
||||
Ownership threads through every list endpoint, the session lookup helper, the WebSocket
|
||||
layer, and file previews. A user cannot address another user's session even by id.
|
||||
|
||||
## Server-wide settings are admin only
|
||||
|
||||
There is one set of server settings (`~/.codeman/settings.json`) for everyone: the feature
|
||||
switches, the run mode, respawn presets, the case order, the Cloudflare tunnel, the default
|
||||
models. Every user can **read** them, but only an **admin** can change them. For a regular
|
||||
user the settings write routes answer `403 FORBIDDEN` (see the
|
||||
[HTTP API](HTTP-API) and [`docs/api-reference.md`](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
A regular user reads them without what is the admin's: the Deepgram key an admin saved for
|
||||
voice dictation, and the admin's case names (case order, last used case).
|
||||
|
||||
In the browser this means:
|
||||
|
||||
- **App Settings → Save** still keeps a regular user's per-device settings (skin, fonts,
|
||||
header buttons, keyboard bar and the rest of the per-device keys) on that device. If they
|
||||
also changed a server-wide setting, one notice says it can only be changed by an admin,
|
||||
and the setting keeps the server's value.
|
||||
- A regular user's run-mode pick and respawn presets are their own: kept in that browser
|
||||
under their name, not sent to the server, and still theirs on the next visit (over what
|
||||
an admin set). Their last used case is not recorded server-wide.
|
||||
- Reordering cases and switching the tunnel on or off are admin actions.
|
||||
- If the page could not tell yet who is signed in (a slow or failed sign-in check), the
|
||||
server's refusal is read the same way: the same notice, and nothing more is sent.
|
||||
|
||||
The operations that act on the whole server are admin actions too: updating Codeman,
|
||||
opening the multi-display window, regenerating the tunnel QR code (it clears every user's
|
||||
pending QR login) and cleaning up stale session state. So is the **Opened outside Codeman**
|
||||
block on the home screens, which shows everyone's processes and opens a shell as the server's
|
||||
account. **Revoke All** signs a regular user out of their own logins only, and the tunnel
|
||||
panel lists only their own.
|
||||
|
||||
Per-user server settings are not part of this version.
|
||||
|
||||
## Safer defaults for regular users
|
||||
|
||||
Non-admins get tighter defaults, and lifting them is an explicit per-user grant:
|
||||
@@ -101,7 +69,6 @@ Non-admins get tighter defaults, and lifting them is an explicit per-user grant:
|
||||
| Claude runs in `auto` permission mode | Anthropic's classifier-guarded mode instead of skip-prompts. |
|
||||
| Raw shell sessions require a grant | A plain shell is unmediated machine access. |
|
||||
| Skip-permissions requires a grant | Same reasoning. |
|
||||
| Codex **Allow network access** requires a grant | It lets a sandboxed command reach any host, Codeman's own API included. |
|
||||
| Cron `launchCommand` requires a grant | It is an arbitrary command on a schedule. |
|
||||
| Pi project trust defaults to off | Trust makes Pi execute repo-local TypeScript. |
|
||||
|
||||
|
||||
@@ -6,17 +6,15 @@ opening the session.
|
||||
|
||||
## The signals, cheapest first
|
||||
|
||||
| Surface | Reaches you | Default |
|
||||
| ------------------------------ | ------------------------------------------------ | ------- |
|
||||
| Tab alert | While the dashboard is open | On |
|
||||
| Browser title flash | Another tab in the same browser | On |
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
|
||||
| Discord bridge (two-way) | Anywhere, and you can answer in a thread per tab | Opt-in |
|
||||
| Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
| Surface | Reaches you | Default |
|
||||
| ---------------------- | ------------------------------------------------- | ------- |
|
||||
| Tab alert | While the dashboard is open | On |
|
||||
| Browser title flash | Another tab in the same browser | On |
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Approvals Inbox | One queue across every session | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
|
||||
## Tab alerts
|
||||
|
||||
@@ -35,13 +33,8 @@ reloading the dashboard while a permission dialog is blocking a session does not
|
||||
with a normal-looking tab.
|
||||
|
||||
For Claude sessions, these come from Claude Code's hooks and are precise about *why* the
|
||||
session stopped; DeepSeek Harness sessions report the same states themselves. Codex has no
|
||||
hooks, but its dialogs are distinctive enough to read off the screen, so a Codex session
|
||||
waiting on an approval, a question or the "Trust this folder?" prompt turns red too, and
|
||||
clears a few seconds after you answer. For the other CLIs there are no hooks and no dialog
|
||||
reading, so you get only the coarser working and idle state: read off the screen for
|
||||
OpenCode, Gemini CLI, Pi, OMP and Antigravity, and unreliable for Grok Build, which
|
||||
declares nothing Codeman can read it from.
|
||||
session stopped; DeepSeek Harness sessions report the same states themselves. For the other
|
||||
CLIs there are no hooks, so you get the coarser output-based signal.
|
||||
|
||||
## Window title and OS notifications
|
||||
|
||||
@@ -67,66 +60,14 @@ Setup:
|
||||
|
||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||
|
||||
## Discord bridge: answer from your phone
|
||||
|
||||
A webhook is one-way. To also **reply** to a blocked agent, use the [Discord Bridge](Discord-Bridge): a
|
||||
thread per tab, your reply typed into that tab, files in both directions.
|
||||
|
||||
## Webhooks: ntfy, Slack, Discord
|
||||
|
||||
**Opt-in, off by default. One channel for the whole server.**
|
||||
|
||||
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
|
||||
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
|
||||
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
|
||||
and for a team channel.
|
||||
|
||||
It carries the same events as push: permission prompts, questions, idle sessions, session
|
||||
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
|
||||
is included only when **Which events** is set to **Everything**; the default, **Needs
|
||||
attention**, skips it. A session that is watching its own work stays quiet here too.
|
||||
|
||||
Setup, in **App Settings → Notifications → Webhook**:
|
||||
|
||||
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
|
||||
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
|
||||
sessionId, sessionName, host, at }`.
|
||||
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
|
||||
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
|
||||
Send test saves anything you changed first, so it always tests what is on screen.
|
||||
|
||||
The status line under the group shows the last delivery: when it worked, or why it did not
|
||||
(an HTTP status, a timeout, a refused connection).
|
||||
|
||||
Behaviour worth knowing:
|
||||
|
||||
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
|
||||
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
|
||||
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
|
||||
never shows it again: once saved, the box is empty and the hint shows only the scheme and
|
||||
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
|
||||
(which also turns the channel off).
|
||||
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
|
||||
the only thing keeping strangers out.
|
||||
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
|
||||
Link-local and cloud-metadata addresses are refused, both when you save and when the
|
||||
message is sent, and redirects are not followed.
|
||||
- **Repeats are folded.** The same event for the same session within three seconds is sent
|
||||
once, so a flapping prompt cannot flood a channel.
|
||||
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
|
||||
the webhook, and it receives every user's session events (session names, tool names, error
|
||||
text). Point it somewhere every user would be comfortable with.
|
||||
|
||||
## The Approvals Inbox
|
||||
|
||||
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
||||
front door reports its prompts to Codeman, and Codex sessions, whose dialogs Codeman reads
|
||||
off the screen.** DeepSeek and Codex cards are alerts: they say "Answer in the terminal" and
|
||||
carry no answer buttons, because Codeman cannot verify a keystroke into those dialogs.
|
||||
front door reports its prompts to Codeman.**
|
||||
|
||||
One queue of the prompts currently waiting on a human, across all your sessions, with the
|
||||
Claude ones answerable in place. When you have eight workers running, this is the difference
|
||||
between checking eight tabs and checking one list.
|
||||
One queue of every prompt currently waiting on a human, across all your sessions, answerable
|
||||
in place. When you have eight workers running, this is the difference between checking eight
|
||||
tabs and checking one list.
|
||||
|
||||
Turn it on in **App Settings**. Surfaces:
|
||||
|
||||
@@ -147,20 +88,17 @@ Behaviour worth knowing:
|
||||
sending, and refuses with a conflict if the dialog is no longer there. Otherwise your
|
||||
keystroke would land in the composer as stray text.
|
||||
- **Permission and question items clear only on definitive signals**: the turn ending, the
|
||||
dialog completing, an answer, a supersede, the session exiting, or a 12 hour timeout. A
|
||||
Codex item also clears a few seconds after its dialog leaves the screen, and a DeepSeek
|
||||
Harness item when the harness reports it is working again. They do not clear on a
|
||||
heuristic "looks busy again" signal, because that signal is wrong often enough to lose a
|
||||
real prompt.
|
||||
dialog completing, an answer, a supersede, the session exiting, or a 12 hour timeout. They
|
||||
do not clear on a heuristic "looks busy again" signal, because that signal is wrong often
|
||||
enough to lose a real prompt.
|
||||
- **In memory only.** Restarting the server clears the queue; the prompts themselves are
|
||||
still sitting in the sessions.
|
||||
|
||||
### Approve and Deny from the notification
|
||||
|
||||
With the inbox enabled, push notifications for a Claude permission prompt carry **Approve**
|
||||
and **Deny** buttons. Those are handled by the service worker directly, so they work with no
|
||||
tab open: tap Approve on a locked phone and the agent continues. A Codex or DeepSeek Harness
|
||||
notification carries no Approve or Deny, since those dialogs are answered in the terminal.
|
||||
With the inbox enabled, push notifications carry **Approve** and **Deny** buttons. Those are
|
||||
handled by the service worker directly, so they work with no tab open: tap Approve on a
|
||||
locked phone and the agent continues.
|
||||
|
||||
With the inbox off, the buttons are stripped from the notification payload entirely rather
|
||||
than being shown and failing.
|
||||
@@ -189,10 +127,6 @@ plain prose is not a dialog, so an agent that starts a monitor and then writes "
|
||||
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
||||
been quiet longer than the work it is waiting for should take.
|
||||
|
||||
An agent waiting for your comments on an artifact it published never counts as watching.
|
||||
Claude shows that as "1 Artifact comment monitor", but the agent hears nothing until you
|
||||
comment, so the session alerts you like any other quiet session.
|
||||
|
||||
## The phone overview
|
||||
|
||||
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
||||
@@ -200,9 +134,8 @@ current sessions, then past ones. Rows use the same language as the tab strip: a
|
||||
when fine, pulsing while working, yellow when waiting for input, red when a question is
|
||||
pending.
|
||||
|
||||
With the Approvals Inbox on, answer strips let you resolve a Claude permission or question
|
||||
dialog straight from the home screen without opening the session. A Codex or DeepSeek
|
||||
Harness strip offers **Answer in terminal** instead, which opens the session.
|
||||
Answer strips let you resolve a prompt straight from the home screen without opening the
|
||||
session.
|
||||
|
||||
## The Away Digest
|
||||
|
||||
@@ -215,8 +148,7 @@ It is the morning-after view for an overnight run. Enable its header button in
|
||||
## Recommended setup for unattended runs
|
||||
|
||||
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
|
||||
webhook to ntfy if no browser will ever be open.
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
|
||||
3. Approvals Inbox on.
|
||||
4. Auto-resume on usage limit on, for each session you leave running. See
|
||||
[Keeping Agents Running](Keeping-Agents-Running).
|
||||
@@ -226,13 +158,11 @@ from the lock screen.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
|
||||
has no such requirement, since the server sends it.
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
|
||||
- **iOS needs the home screen install.** A Safari tab will never receive push.
|
||||
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
|
||||
- **Approvals need real signals.** They are built on hook events, which Claude emits and
|
||||
DeepSeek Harness reports itself, and on Codex's dialogs as its screen shows them; the
|
||||
other CLIs give neither.
|
||||
DeepSeek Harness reports itself; the other CLIs do neither.
|
||||
- **A stale menu answer is refused, not sent.** If you answer a card for a dialog that has
|
||||
since gone away, Codeman declines rather than typing a digit into the composer.
|
||||
|
||||
|
||||