diff --git a/.changeset/story-mode.md b/.changeset/story-mode.md index 7dbd4b49..0a22f048 100644 --- a/.changeset/story-mode.md +++ b/.changeset/story-mode.md @@ -2,4 +2,6 @@ 'aicodeman': minor --- -Story Mode draws what Claude Code does as a comic. Turn on **Story Mode** in App Settings → Header buttons (per device, off by default) and its button opens the active Claude session as comic pages: one page per prompt, with panels for reading code, planning, calling in helpers, editing, running tests (POW! or KRAK!), asking you and saving, each with a plain caption anyone can follow. The **Director's cut** switch names every tool call instead and lists all of them beside the page. Everything is clickable: a file opens in the file viewer, the code page shows the diff of Claude's edits, a test board shows the output with clickable `file:line` links, and a helper shows what it was asked, what it answered and every step it took. A turn that is still running redraws as Claude works, new panels inking in where blue-pencil roughs stood. **Save image** downloads the page as a PNG to share with someone who never opens a terminal. On phones, open it from the session's Session Options. The data comes from `GET /api/sessions/:id/story` and `GET /api/sessions/:id/story/helpers/:agentId`, experimental read-only endpoints. +Story Mode draws what Claude Code does as a comic. Turn on **Story Mode** in App Settings → Header buttons (per device, off by default) and its button opens the active Claude session as comic pages: one page per prompt, with panels for reading code, planning, calling in helpers, editing, running tests (POW! or KRAK!), asking you and saving, each with a plain caption anyone can follow. Three reading levels: **Simple** (plain words, for anyone), **Advanced** (the story with file names) and **Director's cut** (every step and tool, listed beside the page), the default for now. Everything is clickable: a file opens in the file viewer, the code page shows the diff of Claude's edits, a test board shows the output with clickable `file:line` links, and a helper shows what it was asked, what it answered and every step it took. A turn that is still running redraws as Claude works, new panels inking in where blue-pencil roughs stood. **Save image** downloads the page as a PNG to share with someone who never opens a terminal. A prompt bar under the page, with the turn picker, sends Claude your next prompt without leaving the comic. On phones, open it from the session's Session Options. The data comes from `GET /api/sessions/:id/story` and `GET /api/sessions/:id/story/helpers/:agentId`, experimental read-only endpoints. + +A prompt sent from Story Mode's prompt bar or the phone's Compose sheet (a paste, then Enter) is now checked like an API prompt: while Claude Code is still ignoring Enter right after it starts, the server presses Enter again until the prompt leaves the composer. That check also recognises a multi-line prompt that Claude Code shows as `[Pasted text #1 +3 lines]`, and a prompt under 24 characters now has to match the composer exactly. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 539df457..6c01537f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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 only inside a Codeman-managed session.", - "version": "1.42.0", + "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.51.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/.github/SECURITY.md b/.github/SECURITY.md index db062166..f97d2c13 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -69,6 +69,7 @@ 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4df79374..cb9c00ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,35 @@ 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 @@ -151,6 +180,61 @@ 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 diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml new file mode 100644 index 00000000..a5118ce9 --- /dev/null +++ b/.github/workflows/desktop-ci.yml @@ -0,0 +1,72 @@ +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 diff --git a/.github/workflows/desktop-dmg.yml b/.github/workflows/desktop-dmg.yml new file mode 100644 index 00000000..0a3debfd --- /dev/null +++ b/.github/workflows/desktop-dmg.yml @@ -0,0 +1,399 @@ +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@, +# 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 # 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 + # -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 diff --git a/.github/workflows/local-model-catalog.yml b/.github/workflows/local-model-catalog.yml new file mode 100644 index 00000000..5534a628 --- /dev/null +++ b/.github/workflows/local-model-catalog.yml @@ -0,0 +1,128 @@ +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 diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml new file mode 100644 index 00000000..bb98e5e9 --- /dev/null +++ b/.github/workflows/publish-mcp.yml @@ -0,0 +1,85 @@ +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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca7f2b9e..5bc6deef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,11 @@ 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 @@ -60,15 +65,98 @@ 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="$NEW_TAG" \ - -f make_latest=true + -f name="Codeman ${VERSION}" \ + -f make_latest=true \ + "${BODY_ARGS[@]}" 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@` 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 }} diff --git a/.gitignore b/.gitignore index f3f25c5f..ff7d124e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,9 @@ DECISIONS.md # Written by install.sh into end-user clones when setup finishes .install-complete -# Dependencies -node_modules/ +# 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 # Build output dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a83158a0..118e157a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,358 @@ # aicodeman +## 1.51.0 + +### Minor Changes + +- 482ffe2: ### Thanks + - @Randalix for auto-compact and auto-clear that see the whole context (#777), and for the measurements in #585 that showed why they did not. + - @JDProfresh for the report and the change that lets the File Viewer edit `.env` files (#796, #797). + - @aakhter for the Copy Markdown button in the response viewer (#810). + - @DodgyBadger for the File Viewer button that follows its setting on a phone (#759, #809). + - @timkjr for omp tool approvals that raise an alert (#798). + - @irisitymichaelgrundberg for finding why Firefox still pasted twice (#799). + + **Local models: a library, more models, and the coding agent you choose (#803, #804, #806, #807, #808).** Run a local model (on a Mac) now lists every model that fits instead of five, with two new ones: Muse Glimmer 30B, the first of its class a 32 GB Mac can hold, and Ornith 1.0 9B, the first model that fits a 16 GB Mac. Installed models form a library: each shows its size on disk, and you can add another, switch between them or delete one. You pick the coding agent when you start a session (OpenCode, Claude Code, Pi or DeepSeek Harness), and a missing one installs from the same screen. The codeman skill starts a local worker with the agent you name (`spawn_worker local:pi`), and the wiki has a new Local Models page. Still experimental. + + **Mac app: With the Lid Closed no longer asks for a password.** Codeman > Keep This Mac Awake > With the Lid Closed is a plain switch now. 1.50.1 asked for your administrator password and installed a small root helper for it. The app now does what [LogoLight](https://logolight.app) does: it turns off macOS's sleep-on-lid-close itself while your agents work, and turns it back on when they stop, when the app quits or crashes, and on battery. If you ticked With the Lid Closed in 1.50.1, it keeps working after the update, and the old helper does nothing any more. Remove it with Codeman > Keep This Mac Awake > Remove the Old Lid Helper… (that asks for your password one last time). If the Mac ever sleeps under the lid anyway, the status line in the same menu says until when. + + **Auto-compact and auto-clear see the whole context (#777).** Both compared a token counter that never counted the cached part of a conversation, so on a long session they fired far too late or not at all. For Claude sessions they now read the context size Claude Code reports, and a threshold can go up to the model's window (1M with extended context). Behaviour change: a session with auto-compact or auto-clear on fires earlier than it did, because 110k and 140k now mean the real context. Other CLIs, remote and Docker sessions keep the old counter. + + **jcode as a run mode (#650).** Codeman can now start [jcode](https://github.com/1jehuang/jcode) sessions from the Run menu, the phone overview and the API (`mode: "jcode"`), locally, over SSH and in Docker cases. jcode runs every session's tools in one shared background daemon, so closing a jcode tab never stops that daemon, its sessions get no Codeman agent skill, and a jcode worker's answer is read from its terminal; see `docs/jcode-integration.md`. + + **The File Viewer edits `.env` files (#796, #797).** A workspace `.env`, `.env.local` or `prod.env` opened in the preview but could never be edited. It now gets the pencil like any config file, and a save keeps the file's mode and group. Other credential files (`.npmrc`, `credentials.json`, anything under `.ssh/`) and Codeman's own `~/.codeman/.env` stay read-only there, and downloads and attachments still refuse dotenv files. + + **Response viewer: a Copy Markdown button (#810).** Last Response has a Copy Markdown button. It copies the raw source of the newest answer, every part of its turn, so headings, lists and code fences survive a paste. Tool and status blocks are left out, and the full view (More) copies the same answer. + + **Fixes.** + - **Firefox no longer pastes twice on Ctrl+V (#799).** Firefox sent a second paste for the same key press, and some of the time it reached the terminal. + - **An omp pane that asks for a tool approval alerts (#798).** It raised no tab alert, no notification and no Approvals Inbox item. Behaviour change: Codeman writes a small hook file into `.claude/hooks/pre/` of an omp session's folder. + - **Phone: the File Viewer header button follows its setting (#759, #809).** Turning on File Viewer under App Settings > Header & Panels did nothing on a phone. The button now shows there once you turn it on, with a full-size touch target, and stays off until you do. Behaviour change: a desktop window or an iPad split view narrower than 600px shows the button too, where it was hidden before. + - **Local model: the "still using this model" refusal names every session (#804).** With a session open that an agent had started, Stop and Remove answered "Sessions are still using this model: . Close them first." Such a session has no name of its own, so the sentence now uses the name its tab shows. + - **Skill: two workers in one folder keep their own answers (#807).** On a Mac, where Claude Code runs zsh, two `sendmark` calls started in the same second named the same answer file, and one worker's written answer replaced the other's. Workers in folders of their own were not affected. + + **Fixes applied while landing.** Auto-compact and auto-clear decide again at the moment the command goes out, so a `/clear` is not sent into a conversation Claude Code just compacted itself, into a session paused on a usage limit, or after you switched the option off (#777). The File Viewer cannot reach Codeman's own `.env` by another spelling on a case-insensitive disk (#797). An omp alert stays up until omp says its dialog closed (#798). The test gate checks again that the File Viewer button is off on a phone nobody turned it on for (#809). The Copy Markdown button is named by the words on it, so voice control can find it (#810). + + **Docs and tests.** The experimental API list names the new local model routes, the wiki's Local Models page walks from install to cloud plus local, and the wiki says where the File Viewer button is on a phone and what Copy Markdown copies. + +## 1.50.1 + +### Patch Changes + +- e06257c: ### Thanks + - @opticon454 for syncing case options and the picked case across devices, over five review rounds (#732). + - @ludovicfourrage for dictation that survives a thinking pause and the Android dictation fixes, over three review rounds (#705). + - @johoja12 for the log viewer reconnect fix (#720). + - @shenlvkang-collab for the image zoom (#608) and the Files panel delete button (#529), both carried forward in #791 and #792, and for the foldable phone report (#702). + + **A MacBook keeps working with the lid closed (#795).** The Codeman app for Mac has a new menu, Codeman > Keep This Mac Awake: Off, While Agents Work (and 5 minutes after the last one stops) or Always. It only acts for a Codeman running on this Mac, and pauses on battery unless you untick Only When Plugged In. On a MacBook, With the Lid Closed… keeps a closed Mac awake on its power adapter. It asks for your administrator password once and installs a small helper; unticking it removes the helper. Behaviour change: while it keeps watch for a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, so the Mac stays awake; click the Dock icon to bring it back, and quit with Cmd+Q (#800). With no server open, or one on another machine, closing the window still quits. + + **The lid logo breathes while agents work (#794).** The Apple logo blink from 1.50.0 now has one switch, View > Blink Apple Logo. It breathes while agents work, blinks for a yellow tab and flashes for a red one: the more it moves, the more it needs you. It now moves on the logo's own hardware fades, so a blink no longer writes a system setting. Styles you picked in 1.50.0 are kept. + + **The logo keeps blinking with the lid closed (#801).** While the Mac stays awake behind a closed lid on its power adapter (Keep This Mac Awake > With the Lid Closed…, or an external display), the logo goes on showing your agents, and the keyboard stays dark. macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open, the way LogoLight 1.3.1 does. A line under View > Apple Logo Blink Styles says what the closed lid did last: blinking, or why not. On battery nothing changes: the Mac sleeps when you close it. + + **Codeman and LogoLight take turns on the logo (#802).** With LogoLight 1.3 or later running, LogoLight steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards. Until now Codeman left the logo alone whenever LogoLight ran. An older LogoLight still keeps the logo, and Codeman tells you to update it when you turn the switch on. + + **Dictation survives a thinking pause (#705).** A new Voice setting, Stop after silence, sets how long a pause may last before dictation stops: the provider's default, 3, 6, 15 or 30 seconds, or Manual. Claude dictation keeps going from one sentence to the next until you press Stop, and keeps your last words. On Chrome for Android, words are no longer repeated or lost at the end. Behaviour change: on iOS Safari dictation no longer stops after 750 ms without new words; it stops at the silence deadline like everywhere else. + + **Sync cases across devices (#732).** A new setting, off by default, keeps each case's Agent Teams and Opus 1M options on the server instead of in each browser. When you come back to a tab, the case picker follows the case you last picked on another device. + + **Delete a file from the Files panel (#792).** File rows in the Files panel, tree and search results, have a delete button that asks first. Symbolic links, `.git`, sensitive files and remote or adopted sessions are refused. + + **Zoom the path picker's image preview (#791).** Pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click for 2x, or the zoom buttons. A small image now shows at its own size instead of stretched. + + **Fixes.** + - **The phone overview appears on a fold (#790).** Folding a phone, or narrowing a window below 600px, left the welcome screen up when the page had loaded wide. Refs #702. + - **The log viewer stops reconnecting after a startup error (#720).** A refused path, a missing or too large file, the stream limit or a failed tail made the floating log viewer reconnect every few seconds. + - **The local model install view no longer flashes Paused at the end.** A view read while the install job finished could show the job as paused. + + **Fixes applied while landing.** #720 keeps the browser's own retry after a network error before the stream connects. The Mac app's window-close change (#800) came from checking #795 against how people quit the app. #705: the green Send button now stays up for the words saved when dictation is cut off by a reconnect, and the API reference says what a voice transcript frame holds. + + **Docs and tests.** Both READMEs have a Download for Mac button at the top. The Mac app's code was reorganised into smaller modules (#789) with no change in behaviour. + +## 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 `, `--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 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 ` 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 ` 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 `/`, 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 "" [--urgent] [--file ]`, 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 ""`, `codeman agent inbox [--wait]` and `codeman agent ack `, 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 `, 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 diff --git a/CLAUDE.md b/CLAUDE.md index 57b43dd6..0a552ae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ 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), `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), `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. ## Quick Reference @@ -44,7 +44,7 @@ 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` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=` 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 `` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default). -⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed::` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature. +⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed::` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature. Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open. @@ -633,17 +712,59 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one. -⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx ` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. +⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx ` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. The same rule covers the slot check: `/props` supplies `total_slots` only when `cmd` has no `--parallel`, and it NEVER widens the pool the slots divide (an earlier cut let a larger `/props` `n_ctx` become the pool and skip the warning; the 154112-vs-16384 measurement is exactly why that is wrong). `/props` refreshes llama-swap's last-use time for the model, so `discoverModels` probes it once per launch command (`propsByCmd`, in memory, keyed by host and model id and compared with `/running`'s `cmd`); probing on every 5-minute sweep would keep a model with a `ttl` loaded forever. With an explicit `--parallel N` llama.cpp reports `n_ctx` per slot, so a `/props` context beside it is stored with a slot count of 1: it is not divided a second time, and the 1 overwrites a count an earlier discovery round stored (`applyDiscoveredModels` merges onto what was learned before, so recording no count would keep the stale one). -⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. +⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, or when `contextLength / slotCount` (`perSlotContext()`, from `modelSlotCounts`; an unknown count is one slot) is below it, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` (plus `slotCount` when it is above 1) instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. ⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasCompletedOnboarding` and this session's own `projects[workingDir].hasTrustDialogAccepted` merge into the same `.claude.json` the API-key trust file already writes to, and `skipDangerousModePermissionPrompt` merges into `settings.json` (a different file, same corrupt-tolerant merge). ⚠️ **The loading banner shows the REAL backend log line, not a guess, and has no countdown or auto-timeout at all.** `getLatestLlamaSwapLogLine()` holds one `GET /api/events` SSE connection open per endpoint (confirmed live to stay open indefinitely — read past 220KB over 8s with no `done`; idle-closed after 30s via `pruneIdleLlamaSwapLogTails`, same 20s sweep as the swap-displacement check above), parsing `logData` frames and keeping only `source: "upstream"` (the real `llama-server` process's own stdout) lines, never `source: "proxy"` (llama-swap's own request-access log). ⚠️ `GET /logs` — the endpoint this feature's own first cut targeted, since the name suggested it — was confirmed live to carry ONLY the proxy log and never a single backend line, even seconds after a real, verified model swap; caught and corrected by a live check before merge, not after. The banner itself dropped its size-scaled expected-time estimate and matching auto-timeout (a guess dressed up as a fact that could kill a genuinely slow load partway through on slower hardware) for a generic hardware/model-size disclaimer plus a user-driven **Cancel** button (`_showCenterStatus`'s `onCancel` option, a real button distinct from the plain "×" close glyph an `'error'`-type banner gets) that ends the wait and closes the session on the user's own call rather than a guessed deadline. +### Local model install + +**A model that runs on this Mac** (`src/local-model/`, `src/config/local-model-catalog.ts`, routes `src/web/routes/local-model-routes.ts`; EXPERIMENTAL; guide `docs/local-models.md`): the server looks at the Mac it runs on, says which local coding model fits it, and installs one: a pinned runtime it supervises, the model, the checks, the coding agent, and one saved model endpoint. The read side (`hardware.ts`, the catalogue, the pure `recommend()`) is described in the root `CLAUDE.md`; this section is the install. + +**The layout** is one pure function (`localModelPaths()`, `paths.ts`): `/local-model/` holds `runtime/ollama-/` (plus its `.tgz.partial` and `.unpack` while it is fetched), `models/` and `home/`; `/local-model.json` is the state file. ⚠️ Every path comes from `dataPath()` through that function and NONE is ever read back from the state file (which holds no path), so a state file cannot point a delete anywhere. ⚠️ Nothing is written or deleted outside `local-model/` except the state file and the two writes of the connect step. The runtime's `HOME` is the private `home/` (measured to work), so its key pair lands there and the user's own `~/.ollama` is never created, read or removed; `home/` and a `.ollama/id_ed25519` anywhere are on the sensitive-path list. + +**The pin** (`OLLAMA_RUNTIME_PIN`, `runtime.ts`) is a COMPILED constant: version, URL, size, sha256. ⚠️ No file, setting, env var or request may supply any part of it: a hash that comes from the same writable place as the URL pins nothing. The archive is streamed to `*.partial` while it is hashed, continued with a Range request (3 bounded retries), refused when it grows past the pinned size, and the hash is compared BEFORE `/usr/bin/tar` unpacks anything into a temporary folder that is then renamed. + +**Supervision** (`createRuntimeSupervisor()`): the runtime is a NON-detached child, started at boot once a model is installed and enabled, restarted at most 3 times in a row, SIGTERMed at shutdown beside `stopDeepSeekWeb()`. ⚠️ Its environment comes from the pure `runtimeEnv()` ALLOWLIST (PATH, the private HOME, TMPDIR, LANG, the proxy variables, and exactly `OLLAMA_HOST=127.0.0.1:`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`, `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`, `OLLAMA_MAX_LOADED_MODELS=1`, plus the llama.cpp memory settings `OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_FLASH_ATTENTION=1`, `LLAMA_ARG_CACHE_RAM=1024`, `LLAMA_ARG_CTX_CHECKPOINTS=8`, which the catalogue's `llamacpp` rows are computed for, so the two change together): never `process.env` whole (it carries `CODEMAN_PASSWORD`, and a user's own `OLLAMA_ORIGINS=*` would open the port to every web page). ⚠️ The port is chosen ONCE by binding (11435 across 20; 11434 belongs to a runtime the user may run) and stored; it is never searched again on a later start, because it is part of the saved endpoint's URL. ⚠️ Pid and port are in the state file: a listener on the port that answers `/api/version` and whose pid runs a binary under OUR runtime folder is our own orphan (a server that was killed leaves one: a child is not ended by its parent's death) and is adopted or replaced; ANY other listener is `port-in-use`, never something to talk to. ⚠️ The supervisor never writes the state file: it reports through `onChange`, and the job stores what it reports. ⚠️ Under vitest nothing spawns and nothing is fetched unless a test injects it. + +**The budget is measured.** The runtime prints the device total in its log line "inference compute" (`total="37.4 GiB"`); `parseInferenceComputeTotal()` reads it, the job stores it and judges the chosen model against it (`modelFitsBudget()`) BEFORE the model is downloaded; from then on the GET recommends with `budgetSource: 'measured'`. One consent covers the whole install: the sizes that were confirmed do not change, so a model that no longer fits is a failure with the next model, not a second question. + +**The job** (`install-job.ts`) is a pure reducer (`reduceInstallJob()`) over three tables in that file (`INSTALL_STATE_TABLE`, `INSTALL_STEP_TABLE`, `INSTALL_TRANSITIONS`), driven by one service per process (`getLocalModelService()`). States: `idle`, `installing`, `paused`, `failed`, `installed`; steps in order: `runtime`, `model`, `verify`, `agent`, `connect` (`agent` is left out when the coding agent is installed). ⚠️ An event the transition table does not list is REFUSED by returning the same object, which the service answers with 409 `wrong-state`; add a transition to the table, never a special case to the service. ⚠️ ONE job at a time: verbs run on one queue, so two installs sent together cannot both find the state free. ⚠️ A job the state file still calls `installing` when the process has none was interrupted by a restart: it becomes `paused` the first time anything looks (`boot()`, a GET or a verb), and NOTHING continues without a click. `shutdown()` therefore writes nothing: it aborts the step and leaves `installing` in the file. ⚠️ An interrupted step is never recorded as failed, whatever its last call answered (an aborted fetch looks like a network error): the job checks its own signal before it writes `failed`. ⚠️ Every step is idempotent, because Resume and "Try again" re-enter the step the job stopped in. ⚠️ Cancel in the `connect` step is honoured only before the first write: once the two writes began they finish, so the pair is never left half done. ⚠️ The state file (`state-store.ts`) has ONE writer, serialized, temp file + rename, mode 0600, and reading is TOTAL (a file that does not parse, another schema, or a state that lacks a member it needs reads as idle): it holds nothing of the user, so it is replaced rather than refused, unlike `settings.json`. ⚠️ Progress is kept in memory and written at most every few seconds and when the job pauses or fails; the window reads the live figure. A 23 GB pull emits about 1.4 million lines, so neither the stream nor one state write per line is affordable. + +**Install is checked before anything is downloaded** (`LocalModelService.install()`): the model, the context and the coding agent must be a row the CURRENT recommendation offers (a model id is looked up, never passed on: without a slash it cannot name another registry host, and the request schema repeats that); the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`: the model plus the runtime archive while the runtime is not unpacked), else 409 `size-changed` with that total, so a consent is bound to the size that was shown; and a saved endpoint that already has the reserved id and is not ours is said now, not after a 23 GB download. + +**The check after the pull** (`verifyModel()`): one tiny chat with `reasoning_effort: "none"`, then `/api/ps` must show `size_vram == size` at the chosen context, then one tool probe (`tools`, `reasoning_effort: "none"`, `max_tokens` 1024) that passes on a tool call with parseable arguments. ⚠️ Thinking is switched off and the cap is generous on purpose: every catalogue model thinks first, and a probe that ends on its token limit proves nothing (`probe-unfinished`, never `no-tool-call`). ⚠️ A failure is a state with a code and ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed". ⚠️ The job fails when the runtime reports no digest for the model: there would be nothing to compare later. + +**The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP. + +**The coding agent is chosen per session.** The launch check (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent, so any CLI with a custom model recipe could always be started on `local-ollama` from the Run menu. What the feature adds is the list: `install.harnesses` (`buildHarnessRows()`, pure) is every catalogue harness that is not gated, has a `HarnessFact`, works at the model's context and accepts its name, each `ready`, `installable`, `installing` or `setup-needed`. ⚠️ `install.cli` stays the DEFAULT agent for a client that names none (the 1.50.0 skill reads only that field): the agent of the installation while it can be started, else the first row that can, so it never names an agent that is gone while another is there. ⚠️ `start-session` is listed only while a row is `ready`, once the rows are known; rows that could not be built (the registry threw) are "unknown", never "none", and a session is still offered. ⚠️ A LAUNCHER (`discovery.launcherProfile`, DeepSeek Harness) is never `installable`: its stock command installs the launcher and leaves it with nothing to launch, and the profile it needs is third-party code behind the privileged grant of `POST /api/deepseek/install-profile`. The rule reads the entry's data, never its id, and such an agent is offered only once the Run menu can start it. ⚠️ `POST /api/local-model/agent` (`installAgent()`) is NOT a job and NOT an event of the reducer: a job would put a working model into `failed` on an `npm` error and refuse every session until it was cleared. It runs `installCodingAgent()`, the function the `agent` step runs (same slot, same compiled command, same audit line), in the background; the state file never hears of it, the outcome lives in memory (`agentInstall`, `agentFailures`), one installer at a time, and `shutdown()` aborts it. A harness row may carry `readyMark` (a literal the composer draws once it takes input, for a script's `wait-output`): only a MEASURED mark goes there, the registry's `composerReadyMark` stands in when the row has none, and Pi has none on purpose (its composer rules are painted before it takes Enter, measured on 1.5.2). + +**The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [], defaultModelId, defaultContextLength: }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest. + +**The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment. + +**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove. + +**The library** (one model in use, several on disk). `LocalModelStateFile.library` lists every model that PASSED the check through Codeman: `{ id, label, context, digest, bytes?, cli?, verifiedAt? }`, no path. The service upserts the entry as a fact when `verify` passes (where it stores the digest); the reducer only carries the list, through every event but `remove`. An `installed` file an earlier build wrote has no list: `libraryOf()` then counts its one model, and an install or a switch from it writes that model into the list before the state's `model` member becomes the new job's. ⚠️ ONE model is in use: the endpoint record still lists exactly one model, the runtime still holds one (`OLLAMA_MAX_LOADED_MODELS=1`), and `launchRefusalMessage()` still starts only the state's model with its stored digest. Never widen the record to "every checked model": the port has no login, and two sessions on two models would make the runtime swap 20 GB on every request. + +⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`). + +⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again. + +**The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed. + +**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`. + +**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it. + +Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs, the agent install and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. + ### Approvals Inbox -**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). +**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge, omp's approval hook module and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). ⚠️ An omp item takes the pane frame ONCE as `context` and gets no `capture`, like a screen-read one: the re-read is shaped for Claude's numbered dialog, omp's has no numbered rows, and a numbered list in the agent's own output above it was parsed as the item's options, so the item was resolved as answered once that list scrolled out of the frame while omp still waited. Only the module's `elicitation_complete` clears it. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). @@ -673,6 +794,20 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`. +### Agent inbox + +**Agent inbox** (discussion #558): a per-session mailbox one agent posts to and another reads, so a coordinator can hand a worker text without typing into its pane. Store `web/agent-inbox.ts` (pure, `sessionWaits`-style singleton, no IO), routes `routes/inbox-routes.ts`, CLI verbs `codeman agent post|inbox|ack`, opt-in snapshot `web/agent-inbox-persistence.ts`. Nothing in it writes to a pane, and it is kept out of `toState()` so a post does not fan out a `session:updated` to every browser; the counts live behind `GET /api/agent-inbox/summary` (owner-scoped, counts only, never text), which `codeman agent ls` merges into its INBOX column. + +⚠️ **Read until ack.** A read removes nothing; the same messages come back until the receiver acks them BY ID (`POST …/inbox/ack`, idempotent: an id already gone is not an error). Acking by id rather than "everything I read" is what keeps a message posted between the read and the ack from being lost. + +⚠️ **Seen and ack belong to the receiver.** The caller is whatever `resolveParentSessionId()` makes of `X-Codeman-Parent-Session` (live session, same owner as the receiver), never a body field (`InboxPostSchema` is strict). Only a read whose caller IS the receiver marks messages seen; anyone else's read is a peek. An ack from anyone else is a 403, not a silent no-op, so a coordinator never believes it cleared a worker's mail. In multi-user mode this stops a non-admin caller from naming another owner's session (an admin passes `canAccessOwned` for every session, so an admin can act as any session of the receiver's owner); in single-user mode every local process holds the same credentials, so the header is the client's own claim about itself, which is all the seen/peek split needs. + +⚠️ **`?wait=` parks while nothing is UNSEEN**, not while nothing is pending: a receiver holding a seen-but-unacked message would otherwise get an immediate answer on every loop and spin. Inbox waiters are the registry's third waiter kind (`waitForInbox`/`notifyInbox`), so they count against the same per-session/owner/total caps and are ended by `cancelAll` like signal and output waiters. `notifyInbox` runs AFTER the store, so a woken reader finds the message. `parked` in the summary counts receiver waiters only. + +⚠️ **Caps refuse, they never drop.** 16 KiB per message (UTF-8 bytes), 512 KiB and 200 messages per inbox; a full inbox is 409 CONFLICT and nothing is stored. Dropping the oldest would lose exactly the message a slow receiver has not handled yet. TTL is 12 h from each message's own `createdAt`, swept lazily, and a restore keeps `createdAt`, so a restart does not restart the clock. + +⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row, except a record whose tmux session the multiplexer still lists (`mux.getSession`): a detach keeps that pane, and the next boot re-adopts it under the same id even without a `state.json` record. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`. + ### Voice dictation via Claude **Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses. @@ -683,7 +818,7 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**. -⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md` +⚠️ Claude transcript frames are cumulative within the current utterance: replace interims, then append each endpoint final once. An endpoint does not stop dictation unless finalization was requested. Finalization has a 5-second client backstop; reconnect cleanup salvages accumulated/interim text and releases the microphone immediately. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md` ### Files panel search @@ -691,9 +826,47 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested. +⚠️ **Both walks are a per-directory round-robin (breadth-first) over ONE 5,000-node budget.** They used to recurse depth-first through name-sorted folders, so an early folder could spend the whole budget: 4 of 57 top-level folders were shown in one real workspace. Each round divides what is left equally among the directories queued so far, and a folder's own files count as one more share beside the subfolders it has just found (`walkTurnEnd()`). Those subfolders are read in a later round, so a folder that spent all of its share on its own files left them nothing, alone in its round or beside a sibling, and search hits inside them were lost. A turn that found no subfolder holds nothing back. That is fair per directory, not per subtree: a folder of thousands of one-file subfolders still crowds out its neighbours one level down (`a/b/c/d/needle.ts` beside such a `z/` is found depth-first and missed here), and that trade is accepted, since a file browser wants the shallow levels first. Search results therefore arrive shallow-first. + +⚠️ **The budget caps what a listing returns, never what it reads.** Taking turns means opening every folder found so far, where the depth-first walk stopped reading soon after its budget ran out, and a share of 16 entries costs the same read as a share of 5,000. So `readWalkFolder()` keeps only what the listing can use (the entries it lists, sorted, no more than the budget has left, with `more` recording that it dropped some: such a cursor stays queued and is flagged), and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders. The folder that crosses that line is read whole, as any folder always was, the ones not opened yet wait in `unread` and are flagged, and what was already read keeps its turns. Without the two, 100 folders of 50,000 files cost one GET 5 million entries and a 400 MB heap died (which ends the server, not the request). The price is that folders adding up to the ceiling leave later ones unread: flagged, not hidden, but not the fairness the budget gets, and a depth-first walk over the same tree showed less. + +⚠️ **A folder below the walk's root is probed before it is read** (rule #516), because the round-robin reaches folders the depth-first walk stopped short of, each with an unbounded `readdir`. It is a bulk scan, so default options and the stall cap apply. `unknown` is never `absent`, so a folder the probe could not answer for goes on `unread` (`cursor.unanswered`), which flags it `incomplete` and sets `truncated`: a healthy mount that merely took longer than `PATH_PROBE_TIMEOUT_MS` on first touch would otherwise list as empty, and a search would miss it in silence. It stays unread until a later listing asks again. Only `absent` or `file`, a folder that left while the walk ran, drops out with no flag. With the cap engaged (two unrelated paths stalled, by default) every folder below the root is flagged, until one answers, which is the honest outcome: none of them was read. The root is not probed: a refused probe there would blank a healthy workspace. A `readdir` that fails after a good probe (permission denied) is still treated as an empty folder, as it always was. + +⚠️ A directory gets its node, with `children: []`, when it is counted and is read in a later round, so every cursor still queued after the loop, and every one in `unread`, is a folder that is unread or half read: it must carry `incomplete: true` or it reads as an empty folder, the failure that went from 0 to 635 such folders before the flag existed. `renderFileBrowserTree` draws one dim, inert row under it while it is expanded, worded to fit either reason, and the row is not a `.file-tree-item`, so the click wiring leaves it alone. The budget check inside the round runs BEFORE each `shift()`, so a directory the round skips stays queued and keeps its flag. Hidden and excluded entries are dropped at the read, so they never leave a folder looking unfinished, but a folder found as the budget ended is flagged unread even if it would have turned out to be empty: only reading it could tell. The walk's root has no node, so `truncated` is its only signal, and a folder dropped at the depth cap is not flagged: it looks the same as it always has. + ⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running. -Matching semantics: a query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring. +Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` or `root=live-any` listing; the returned `path`s stay relative to `workingDir`, except in a `root=live-any` listing of a worktree outside it, whose `path`s are absolute), otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring. + +### Files panel in a worktree + +**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude Code's EnterWorktree moves the running process into `/.claude/worktrees/`, while `Session.workingDir` is the session's identity and stays on the main checkout, so the panel kept listing a tree the agent had left. `paneCwd` does follow, in both directions, because tmux reads the process cwd (measured on 2.1.318). Hook payloads are the wrong signal: their `cwd` follows every `cd` in a Bash call too. + +When `paneCwd` changes, `refreshLiveWorktree()` (server.ts) runs `findLiveWorktree()`, which walks up from the pane and recognises a linked worktree by its `.git` FILE (`gitdir: .../worktrees/`). It never runs git, since this fires from the pane watcher and a file read starts no process, config or filter. The walk stops at `workingDir`, at a repository or submodule of its own and at anything it cannot read; probes are bounded (#516), and a `.git` that is a symlink or a FIFO is refused before anything opens it. The answer is published as `SessionState.liveWorktree`. A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. + +⚠️ **That answer is lexical and goes stale.** It is judged only when the pane moves, and a symlink can take the place of the folder, or of a parent, afterwards without moving the pane, while `probePathKind()` and `readdir` both follow one. So `root=live` and `root=live-any` check again on every request: `canWalkLiveWorktree()` (file-routes.ts) probes the folder, resolves it and `workingDir`, and the walk starts at the worktree only when that resolves strictly inside the resolved `workingDir`; otherwise it starts at `workingDir`, as it does for a worktree that has gone. The 1.49.0 release review reproduced the escape end to end on tmux 3.4: without this check a symlink swapped in after the lookup listed the names and sizes of a folder outside the workspace, though `file-content` and `file-raw` refused those paths. The root is the only place this can happen, because the walk does not follow a symlink below it (`Dirent.isDirectory()` is false for one). A swap between the check and the `readdir` is the same check-then-use race any path route accepts. + +⚠️ **Only the check uses resolved paths.** The walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`, and the listing's `path`s stop working on the file routes. + +⚠️ A `liveWorktree` inside `workingDir` only moves where a listing STARTS, so never make it a confinement root. Returned `path`s stay relative to `workingDir`, which is why `file-content`, `file-raw` and the edit route need nothing new for it. + +**A worktree outside the session's folder** (`git worktree add ../feature`, or an EnterWorktree that the user's own tooling places beside the repository) is the one case that IS a second confinement root, and it is published as `SessionState.outsideWorktree` (carrying `outside: true`), never in `liveWorktree`, which stays inside-only on the wire. `refreshLiveWorktree()` passes `findLiveWorktree()` the outside rules (`outsideWorktreeOptions()`, server.ts; none for a remote or Docker session), and `findOutsideWorktree()` answers only when all of these hold: + +- The session's `workingDir` is the top of its repository or of a linked worktree (its `.git` sits there), so its git common dir is known. A session started in a subfolder follows no worktree outside it. +- The nearest `.git` at or above the pane is a FILE naming `/worktrees/`, and that registry entry's own `gitdir` file names the candidate back. Or it is a `.git` FOLDER that is the common dir itself, which is the main worktree seen from a session started in a linked one. These are the files `git worktree list` reads; git is never run, because this fires from the pane watcher on every directory change. A forged `.git` file pointing at a real entry fails the back link. +- The candidate's real path is not `/`, not the home folder or above it, and does not hold `workingDir`. +- It is not at, inside or around a Docker case workspace. An unreadable `docker-cases.json` follows nothing, since it could hide a container's workspace. +- Attachment workspace confinement (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) is off. +- It is not at, inside or around an attachment blocked tree (`attachmentBlockedPaths` / `CODEMAN_ATTACHMENT_BLOCKED_PATHS`, plus the defaults), and not a sensitive folder: the sensitive patterns describe file locations, so `outsideWorktreeOptions()` also probes a child of the root, which refuses a root such as `~/.ssh`. The routes judge both guard rules again on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. The Docker and multi-user rules are judged only when the pane moves. +- In multi-user mode, the owner's `folderAllowedForUser` rule admits it (an admin owner is not limited by it). + +Every comparison uses real paths, except that a home folder or Docker workspace that cannot be resolved is compared as written, and the recorded `path` is the real path. ⚠️ **The routes must re-check it on every request**: `outsideWorktreeRootFor()` (route-helpers.ts) and `canWalkOutsideWorktree()` (file-routes.ts) accept the folder only while `realpath` returns exactly the recorded path, so a symlink swapped in later serves nothing from it. ⚠️ 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 or when `isSensitivePath()` names the file, so `.env`, `.aws/credentials` and `users.json` are never served from it (`validateLocalSessionFilePath()`, used by `file-content`, the edit route, `file-raw`, `file-preview`, `file-thumbnail`, `/api/download` and `html-view`). `html-view` also runs the attachment guard for such a path and mints its capability as outside the workspace, so the serve route's per-request confinement re-check covers it. A relative path still resolves against `workingDir` alone, so `../feature/x` is refused. A symlink inside the worktree into `workingDir` is served, since `workingDir` is served anyway. ⚠️ The browser routes a path inside it to those routes, not to the read-only attachment routes (`_isOutsideWorktreePath()`, panels-ui.js), and the terminal opens it in the preview, because the log viewer's `tail-file` stays confined to `workingDir`. + +A worktree of a session started below the repository's top is not followed, because Claude Code puts it at the top, outside the session's folder, and that session has no common dir to check it against. + +⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that report with the session's state, and an absent report would reload forever. A listing without `root=live-any` is the exception: it omits an outside worktree. While the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). + +Two surfaces do not move with a worktree inside `workingDir`. The Git panel keeps working from `workingDir`, so it shows the main checkout's branch while the strip names the worktree. And `POST /api/sessions/:id/html-view` applies its dot-segment rule below `workingDir`, so while the session's folder is the main checkout a page under `.claude/worktrees/` is refused and opens as source (a session started in the worktree renders it). For a worktree outside `workingDir` the Git panel also stays on `workingDir`, while `html-view` serves its pages as described above. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. ### Raw file bodies: streamed and range-aware @@ -707,6 +880,58 @@ Matching semantics: a query containing `/` matches the relative path, otherwise ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause. +### Edit history panel + +**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a claude session edited, local or remote SSH (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts` (remote: `remoteLocateClaudeTranscript` / `remoteReadClaudeEditSources` in `src/remote-files.ts`), sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history. + +⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`. + +⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `
/subagents/agent-.jsonl`. A different main file (`/clear`, resume): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending. + +⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first), plus up to 8 KiB of `meta.json` per subagent seen for the first time; a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write. + +⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`. + +⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail, and neither does any image of a remote session (its `file-raw` streams over ssh, one read per listed row); a click previews it. No route of this feature serves a file, least of all a transcript path. + +⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). A remote session's candidates go through `remoteProbePaths()` instead, one bounded ssh round trip per 40 paths; a failed probe ends the probing for that read, and its edits stay in. `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Docker and non-claude sessions never load a store on any route. + +Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Docker, non-claude and adopted sessions answer `unsupported` and are never read. + +⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `/sessions/.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else the most recently written `/projects/*/.jsonl` of the stored history's conversation, `` and `` (`remoteTranscriptIds`; nothing moves `claudeSessionId` after a remote `/clear`, and no fixed order is right both after a `/clear` and after a respawn that resumed the launch conversation); the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`), or several with more than 300 subagent requests (below), with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget per read, which the local reader does not have: a meta comes only with a subagent's first read, so one past the budget is never sent and that subagent shows its id instead of its description). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. The config dir is an absolute `CLAUDE_CONFIG_DIR` of the ssh command's environment, else `$HOME/.claude` (claude's own rule); the remote claude runs under `$SHELL -i -l -c`, the lookup under a plain `sh`, so a value set only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen. A round trip's timeout grows with its byte cap (`remoteEditSourcesTimeoutMs`: 30 s plus the transfer at 256 KiB/s), since a forward pass may carry 13 MiB and "Load older" 29 MiB. ⚠️ **Never wakes:** the lookup and the reads go through `buildRemoteEditHistoryCommand` (`buildRemoteFileCommand` with `exec`, so a timeout reaches ssh and not only the local `sh -c`, which on dash stays its parent), the image probes through `remoteProbePaths`, and all of them through `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only. + +⚠️ **A read with more than 300 subagent requests spans several round trips** (`planRemoteEditSourceRounds`, `EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP`). The script is one argument of the ssh line, Linux caps a single argv string at 128 KiB (`E2BIG` at about 1000 subagents), the cursors have no cap and the stored conversation is read first, so one script for all of them left a long history `unavailable`, and not even a `/clear` got past it. The cut is by NAME RANGE, not only by count: the script takes a file no `case` line names for a new one (its tail, its meta), so plain groups would read each other's files as new. Each round trip walks only the names between its cuts (one `awk` per round trip, never a fork per file), so a new file lands in exactly one range. Main comes with the first round trip, budget and meta budget carry over, and a later round trip still lists its files, because a requested file missing from the result loses its cursor. A failed round trip fails the whole read and nothing of it reaches the store; each one takes its own limiter slot. The merged result equals the local reader's, byte for byte (the fuzz test cuts every read at a random size). A read of up to 300 runs no `awk` at all. ⚠️ No `case` inside a `$(…)`: bash 3.2, macOS `sh`, cannot parse it, which is why the names come from a shell function. + +### Config backups + +**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced). + +⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap. + +⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case) and `lastPickedCase` (a pick in a case picker), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since). + +⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder). + +⚠️ **The origin is a random id in `/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again. + +⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a file that does not parse: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened. + +⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock. + +⚠️ **One walk of the folder per pass.** `runConfigBackupPass()` reads the folder once and hands the names to the temp sweep and the manifests to the retention fallback, the change check and the prune (with the snapshot it just made prepended: it is always the newest, by the date floor above). Each of the three used to list the folder itself, so every manifest was read three times per pass, every five minutes, on what may be a network folder; restore lists once too. `test/routes/config-backup-one-walk.test.ts` counts the reads. + +⚠️ **Restore verifies everything before it writes anything.** Every file's SHA-256 against the manifest, and the id must match the name pattern (no traversal); a mismatch is a 409 with nothing changed. It also refuses a snapshot folder or file that is a symlink or has a group/world bit: this module only ever writes 0700/0600 real files, so such a thing was planted or loosened later, and rewriting it as 0600 would turn it into trusted config (`clis.json` selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there). The mode test is skipped where the filesystem keeps no modes (vfat/exFAT, CIFS, WSL `/mnt/d` without `metadata` report 0755/0777 for everything, and Restore would refuse every backup there): `folderKeepsModes()` decides by creating a 0600 probe file with a random name and `wx`, because the earlier fixed `.mode-probe-` name let a planted symlink be truncated and chmodded by one Compare click. Compare applies the same checks to the snapshots it reads. + +⚠️ **The undo copy comes first, and a partial restore is reported, never hidden.** A `pre-restore` snapshot of the live files precedes the writes; it is not forced, so when nothing changed since the newest backup that backup is the undo copy and is the one reported (a forced copy spent a retention slot on a duplicate). It records the retention in force, so it cannot hide that retention from the next pass. Each file is replaced atomically (temp + rename, 0600). A file that cannot be written (a full disk, a directory in its place) does not stop the others: `ConfigBackupPartialRestoreError` carries `restored`, `failed`, `untouched` and `safetySnapshot`, and the route answers 500 with the error envelope plus those fields (and `restartRequired`) in `data`, the shape the tab-layout 409 uses. The bare 500 it replaced named neither the files already put back nor the undo copy. + +⚠️ **What a restore wrote must reach the running server**, on the full and the partial path alike (`afterRestore()` in the route): `intentStore.reload()` (the store caches `intents.json` and rewrote it whole on the next captured prompt, reverting the restore), `reloadCliRegistry()` (a restore of `clis.json` is the one write that bypasses `mutateRegistryFile()`, see CLI registry), and in multi-user mode a `config_backup_restore` admin-audit entry (`users.json` holds accounts, password hashes, roles and bypass grants). `users.json` itself is re-read within a second by the user store. `.env`, `push-keys.json` and `settings.json` (whose watcher toggles only `PUT /api/settings` runs) apply after a restart: `RESTART_REQUIRED`, returned as `restartRequired`. Settings re-reads `/api/settings` into the form after any restore that wrote `settings.json`, partial ones included, because the form still holds the old values and the next Save would PUT them over the restored file. + +⚠️ **Compare never returns a credential.** `REDACT_ALL_FILES` (`.env`, `users.json`, `push-keys.json`, `webhook.json` whose URL is a bearer secret, `intents.json` whose prompts carry secrets) report changed key names only; anywhere else a key matching `SECRET_KEY_RE` (token, secret, password, credential, auth, `*key`) is reported as changed without its value, and URL values lose userinfo, query and fragment. Listing returns file names and sizes only. + +⚠️ **Admin only in multi-user mode**: all four routes answer 403 for a non-admin and the Settings group is hidden for them (`_applyDoctorAdminGate()`), because the files include credentials. + +Tests: `test/config-backup.test.ts`, `test/config-backup-origin.test.ts`, `test/config-backup-restore-ui.test.ts` (vm harness over the real `settings-ui.js`), `test/routes/config-backup-routes.test.ts`, `test/routes/config-backup-unreachable.test.ts`, `test/routes/config-backup-one-walk.test.ts`. + ## Frontend ### Command palette and shortcut registry @@ -752,16 +977,18 @@ Feedback is silent on success except ONCE per page load (a feature that works by ### Terminal paste (Ctrl+V) -**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through `terminal.paste()`, so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both: +**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; plain text is a Codeman-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) handed to that pane's terminal via `terminal.input(framed)`. `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself. `input()` fires onData as user input, which scrolls to the bottom and clears the selection. The main pane's onData bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, which is where the local-echo flush and the delayed write live (#219). A frame wrapped in CSI 200~/201~ does not match the query-reply or mouse-report filters. The terminal is refocused; the paste is not written into the xterm buffer. Two independent routes deliver that event for one keypress, and a browser may fire both: 1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing. -2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well. +2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well. That holds for Chromium and WebKit, where it is the only paste. In Firefox the trap cancels the keydown once route 1 has delivered (below), so route 2 no longer happens there. -Measured against a live install, one `Ctrl+V` each: Firefox delivers two paste events, Chromium and WebKit one. Handling both wrote the clipboard text to the PTY twice, which is why `Ctrl+V` pasted twice while right-click → Paste pasted once. That menu path involves no keydown, so route 2 cannot exist for it. +Measured against a live install before the keydown was cancelled, one `Ctrl+V` each: Firefox delivered two paste events, Chromium and WebKit one. Handling both wrote the clipboard text to the PTY twice, which is why `Ctrl+V` pasted twice while right-click → Paste pasted once. That menu path involves no keydown, so route 2 cannot exist for it. -⚠️ **Route 2 alone is enough in all three engines, so route 1 is redundant where it can be measured.** Strip the `execCommand('paste')` call out and each of the three still delivers exactly one paste event to the trap, Firefox included. The call is kept anyway, because the trap technique was written for the mobile engines that a desktop measurement cannot reach, and a browser that resolves the key's default action against the element focused when the keydown began would send its paste to xterm's textarea instead. Text paste survives that on xterm's own `handlePasteEvent`; image paste does not, since the trap's listener is the only place clipboard image blobs are read. Removing the call is therefore a decision about mobile coverage, not a cleanup. +⚠️ **Once route 1 has delivered, the trap cancels the keydown.** Firefox dispatches the `execCommand` paste synchronously, inside the call. It delivers the key's own paste as a separate task about 10 ms later, measured in Playwright's Firefox. That later event races the `setTimeout(0)` that removes the trap and refocuses the terminal. When the timer wins, the event lands on xterm's textarea, and xterm's own `handlePasteEvent` sends the clipboard a second time; the trap's one-shot flag never sees that event. This race is why a one-shot flag alone left `Ctrl+V` pasting twice "sometimes". Both callers, the main pane's key handler and a tile's, pass the keydown as `target.keyEvent`. `_handleImagePaste()` calls its `preventDefault()` when the paste listener has already run by the time `execCommand` returns, so the browser never issues route 2. Chromium and WebKit dispatch nothing from route 1, so they keep the default action, which delivers their only paste event. ⚠️ Never cancel the keydown unconditionally: in those engines that loses the paste entirely. -The guard is a one-shot flag on each trap rather than a browser test or a reading of `execCommand`'s return value, so any count of events produces one insert. Tests: `test/image-paste-trap.test.ts`. +⚠️ **The `execCommand('paste')` call stays.** The trap technique was written for mobile engines that a desktop measurement cannot reach. A browser that resolves the key's default action against the element focused when the keydown began would send its paste to xterm's textarea. Text paste survives that on xterm's own `handlePasteEvent`; image paste does not, since the trap's listener is the only place clipboard image blobs are read. Removing the call is therefore a decision about mobile coverage, not a cleanup. + +The one-shot flag on each trap stays as a second guard, so any count of events that reach the trap produces one insert. Tests: `test/image-paste-trap.test.ts`. ### Settings surface: App Settings, Session Options, Add Case @@ -812,7 +1039,7 @@ Further detail: in the Settings section order, the version and the updater lead ⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation. ⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. -**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected. +**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load. **On a phone it is opt-in (#759):** the handheld branch of `getDefaultSettings()` defaults it OFF, `html.mobile-init` in `mobile.css` hides it until the settings have been applied (the template ships it visible, so it would flash otherwise), and switching it on in App Settings on that phone shows it there with a 44px touch target. `mobile.css` no longer hides it at phone width, so that default and the startup rule are the whole lock; `test/mobile-header-buttons-policy.test.ts` pins both in the gate, and `test/mobile/file-browser.test.ts` drives the real page at 390px. The setting never reaches the server (stripped from the PUT, absent from the schema), so a desktop cannot switch it on for a phone. ⚠️ A device that is not a handheld takes the desktop default at any width, so a desktop window or an iPad split view narrower than 600px now shows the button too. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected. ### Session list layout (header strip vs. left sidebar) @@ -850,7 +1077,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t ⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering. -⚠️ **The arrow-key walk is the one place that must follow the SORT rather than the DOM**: `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED `order` first (computed, not inline: web tabs get their `order: 9999` from CSS and would otherwise read as 0 and lead the walk). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the thing the user steps with their eyes follows the paint. +⚠️ **The arrow-key walk and the phone swipe are the two places that must follow the SORT rather than the DOM**: `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED `order` first (computed, not inline: web tabs get their `order: 9999` from CSS and would otherwise read as 0 and lead the walk). That walk is `_visibleTabRows()`, and the phone swipe shares it: `SwipeHandler` calls `nextSession({ visual: true })` / `prevSession({ visual: true })`, which step through `_stripSessionOrder()` (the grouped tree's session rows via `_tabTreeItems()`, otherwise `_visibleTabRows()`, web tabs skipped), so a swipe lands on the tab drawn next to the current one in the case and state layouts, a sorted rail and named groups; Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep `sessionOrder`. An active session the list does not draw steps in at the first row (next) or the last (previous), and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the things the user steps with their eyes (the arrow keys and the swipe) follow the paint. ### Split-pane sessions @@ -928,7 +1155,9 @@ Further detail: the design follows Apple's [Designing for iPhone Duo](https://de ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). -Tests: `test/terminal-touch-tap.test.ts`. +⚠️ **A sideways drag belongs to the session swipe**, never to a tap or a scroll. The touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls the buffer, `x` pans a terminal wider than its box, `swipe` when it cannot pan) and publishes it per gesture as `app._terminalGestureAxis`; a `swipe` drag is consumed without scrolling and never ends as a tap, which used to activate a link, a CLI click or a keyboard toggle on the session being left. ⚠️ Only up to the swipe's own drift limit (`SwipeHandler.maxVerticalDrift`): past it the swipe has given up, so the drag is handed back to the scroll (`y`). Otherwise a scroll whose first few pixels lean sideways (a thumb arc) locked as `swipe` and did nothing at all. `SwipeHandler` (mobile-handlers.js) reads that axis and stands aside for `x` and `y`, for a touch selection, after a second finger, and on surfaces with controls of their own (`IGNORE_SELECTOR`: the tile grid, the selection bar, the CJK input, the history banner); the phone overview and the welcome screen are deliberately not listed, since swiping from home into a session is a feature. A swipe is 80 px of travel within 500 ms (300 ms rejected an unhurried but deliberate swipe outright) with at most 50 px of vertical drift at any point: `touchmove` cancels it mid-gesture rather than only at the lift. Two more rules came from walking the sequences: a touch that starts outside the terminal (a swipe over the home screen) resets the published axis, so a terminal scroll a moment earlier cannot veto it, and a lift the terminal consumed (`defaultPrevented`: its long-press selection gesture, which on blank space leaves no selection behind to check) is never a swipe; the second-finger cancel stays set until the next touchstart, so the first finger lifting cannot clear it. + +Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (the real `SwipeHandler` and the terminal's axis lock), `test/terminal-touch-swipe-axis.browser.test.ts` (the real touch listeners: the drift handback and a clean swipe, browser suite), `test/mobile/tabs.test.ts` (CDP swipes, mobile suite). ### Entrance animations @@ -962,6 +1191,8 @@ Tests: `test/terminal-touch-tap.test.ts`. ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. +⚠️ **The opt-in tab "+" is a third icon in that cluster**: with **New Session Button on Tabs** on (`showTabNewButton`, per device, off by default; `html.tabs-show-new`) the active phone tab shows a 28px "+" between the gear and close, and `html.tabs-show-new .session-tab.active .tab-name` grows the reserve to 68px (gear 32 + "+" 28 + close 20 less close's 2px margin needs a 64px label, 66 with the test's rounding allowance), still under the 80px `max-width`. Growing the reserve while the setting is on is fine; shrinking the base one never is. The same test recomputes it with the "+" counted. + ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it. ### Tab layouts (by state, by case, ledger) @@ -978,11 +1209,13 @@ Tests: `test/terminal-touch-tap.test.ts`. **Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list. +**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite). + With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. ### Header stats styles -**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header. +**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header. The opt-in host readings after MEM (GPU, VRAM, GPU MHz, CPU MHz: `.stat-item` elements that start `hidden` and are shown by `_updateHostSensors` in panels-ui.js) rely on `html[data-header-stats] .header-system-stats .stat-item[hidden]` in styles.css outranking the Tiles style's own `display` on `.stat-item`; keep that selector's specificity above any style rule that sets `display` on a stat item, or the hidden placeholders (`--%`) reappear. Those four carry `.stat-item--host`, which the header divider's density rule (`data-header-density`) excludes from its label hide: their values read like CPU's and MEM's, so the label is the only thing naming them. At Compact + `tiny` (rings only, values hidden) a reading with no ring is hidden outright rather than left an empty flex item: `.stat-item--ringless`, static on the two clocks and toggled by `_updateHostSensors` on VRAM while its total (and so its ring) is unknown. Tests: `test/header-host-sensors.browser.test.ts` (browser suite, not in the gate). ⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it. @@ -1050,7 +1283,7 @@ Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Spac ⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`). -⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them. +⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one, pasted inside a second pair of markers, and get submitted. ⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast. @@ -1068,6 +1301,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. +⚠️ **An accessory key that keeps the keyboard up must not take focus on the press.** The keys that act on the terminal and leave the keyboard open (`ACCESSORY_REFOCUS_ACTIONS` in keyboard-accessory.js: scroll, arrows, Tab, Shift+Tab, Ctrl, Esc, clear input and the rest) cancel the `mousedown` default, the same pattern as the Attach input, so the hidden textarea keeps focus through the press; the `terminal.focus()` in the click handler stays as the fallback. Letting the tap blur the terminal and refocusing it afterwards made every such key start a keyboard dismissal, a blur/focus pair per tap that shifted the toolbar each time. A first tap on clear/compact only arms the confirm and is treated the same; the confirming tap, and keys that open a dialog or dismiss the keyboard on purpose, keep the default. Pinned against the real module by `test/accessory-keeps-terminal-focus.test.ts`. + ⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks. ### SSE staleness watchdog @@ -1082,9 +1317,13 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s". +⚠️ **The connect attempt has its own deadline, because the watchdog cannot see it.** An `EventSource` whose request never gets response headers (a phone resumed on a VPN or tailnet, a proxy that accepted the socket and went silent) stays `CONNECTING` without firing `open` or `error`, so `onerror` never schedules a retry and the status is never `connected` for `computeSseStale()` to judge: the page sat on "connecting" until a reload. `connectSSE()` therefore bounds each attempt by `SSE_CONNECT_TIMEOUT_MS` (15s, exported as `CodemanSseStale.CONNECT_TIMEOUT_MS` in constants.js; read once into `_sseConnectTimeoutMs`) and, if `open` has not fired, retries through `_handleSseConnectionFailure()`, the former `onerror` body, so a hung attempt gets the same backoff, status change and "Retrying in Ns" countdown as an errored one. ⚠️ The timer is cleared in exactly three places: at the top of `connectSSE()` **before** the offline early return (every path into `connectSSE()` supersedes the previous attempt; removing that clear fails the offline case in the test), on `open`, and in `_handleSseConnectionFailure()`. ⚠️ Both the timeout callback and `_handleSseConnectionFailure()` return early for an `EventSource` that has since been replaced, so a late error and the timeout never count one failure twice, and the timeout also skips a connection whose `readyState` is already `OPEN` before its `open` callback ran. ⚠️ The window `offline` handler deliberately leaves the timer running, since `navigator.onLine` can report offline on a working network; only a `connectSSE()` made while offline cancels it. Tests: `test/sse-connect-timeout.test.ts` (the real constants.js + app.js in a `vm` context with a fake `EventSource` and fake timers). + ### PTY and browser terminal geometry -**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. +**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. + +**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes. ### Terminal resilience: replay clears, renderer liveness, fetch deadlines @@ -1114,8 +1353,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u | **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) | | **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). QR has separate limiter | -| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | +| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter | +| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | | **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | @@ -1124,7 +1363,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Buffers, uploads, and terminal history -Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50 (env `CODEMAN_MAX_SESSIONS`, 1 to 462), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. **Prompt uploads live in `/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 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. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`. @@ -1170,9 +1409,9 @@ The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/ ⚠️ `CODEMAN_DATA_DIR` is the one that matters: `getDataDir()` reads it as an ABSOLUTE override before it ever looks at `homedir()`, so one inherited from the shell (a second instance, a beta run, a shell left over from `codeman web -d`) bypasses the temp HOME entirely, and a bare suite run once overwrote the real `remote-hosts.json` with a route test's fixture. `os.homedir()` itself DOES follow `$HOME`, so the temp HOME is what redirects everything else; `CODEMAN_INSTANCE` must be stripped in the setup file and never in a hook, because `config/instance.ts` captures it into a module-level const on first import. -Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. +Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. ⚠️ It also refuses everything unless HOME IS the throwaway dir `test/setup.ts` made (`isSandboxedHome()`: its basename starts with `TEST_HOME_PREFIX`, `codeman-vitest-`, the one thing a real home cannot have). "Under HOME" alone is vacuous when setup never ran: on 2026-10-10 a bare `vitest` run had the developer's real home as HOME, every real file was "under the test HOME", and the guard written to prevent exactly that deleted the real linked-cases registry. `isUnderTestHome()` (for single-file deletes such as `linked-cases.json`) carries the same requirement, and the shared route harness `createRouteTestHarness()` calls `assertSandboxedHome()`, so a route test built on it refuses to start against a real `~/.codeman` (a route test that builds its own app from `createMockRouteContext()` is not covered by that). -⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation. +⚠️ A bare `npx vitest` (no `--config`) resolves the root `vitest.config.ts`, which re-exports `config/vitest.ci.config.ts`, so it runs `setup.ts` and gets the temp HOME like `npm test`. Vitest looks for its config by walking up from cwd, so this holds from `test/` as well, while `packages/xterm-zerolag-input` keeps its own config. Before that file existed a bare run found no config, ran without the setup file, and wrote fixtures into the real `~/.codeman` (#640: a `clis.json` with a test entry, `users.json`, `webhook.json`, `.env`, settings values). `test/test-env-isolation.test.ts` pins it; a `--config` pointing at a config without `setupFiles` would still skip the isolation. ### install.sh: the public installer @@ -1193,3 +1432,120 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see. ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all. + +**No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, 2026-11-05). Everything Codeman writes was always under `$HOME`; administrator rights were only ever spent on prerequisites. `resolve_admin_mode` decides once per run whether those rights may be asked for: root yes; a Mac account outside the `admin` group no (unless a Homebrew it can write to exists); on Linux no without a `sudo` binary, or when the account is outside `sudo`/`wheel`/`admin` AND `sudo -n true` fails. ⚠️ It is never decided by `sudo -v`: that prompts, and a refused password from an account outside sudoers is reported to the administrator. A refused password on the system route offers this route instead of ending the run (`sudo_session_try`). ⚠️ The group test is a judgment, not a fact: no probe can read sudoers without reporting to the administrator, so an account sudoers lists by name, with a password, lands here too. `NO_ADMIN_REASON` must therefore say the rights could not be confirmed (`NO_ADMIN_UNCONFIRMED`), never that the account may not use `sudo`, and every message that ends or sums up the route carries `no_admin_override_hint`, which names `CODEMAN_NO_ADMIN=0` for that reason alone (`--no-admin` asked for the route; a `sudo` that is missing or refused has no way back). + +On this route Node.js comes from nodejs.org (`latest-v.x/SHASUMS256.txt` names the tarball AND carries its checksum, one fetch, no JSON parsing) into `~/.codeman/runtime/node`, and tmux from the tmux project's `tmux-builds` release into `~/.codeman/runtime/bin`. ⚠️ `tmux-builds` publishes no checksum file, so `tmux_build_sha256` pins all four digests of `TMUX_BUILD_VERSION`; bump the version and the digests together (the GitHub release API returns them as `digest`). ⚠️ A download is verified BEFORE it is unpacked or run, and an empty expected digest never passes. `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin` (`link_into_local_bin` refuses to replace a file that does not already point into the runtime, and uninstall removes only such links): the `codeman` launcher is `#!/usr/bin/env node`, and a non-interactive shell never reads the PATH line written to an interactive profile. The runtime's `node/bin` also goes on PATH and on the LaunchAgent's `svc_path`, since `npm install -g` puts AI CLIs beside that Node.js. + +⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting. + +**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli |none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent ` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and Run reads that memory, so `mark_cli_installed` touches `cli-installed` in the data dir and `resolve()` leaves the backoff when that file's mtime changes. ⚠️ That check is one `stat` inside the backoff branch and buys one run of the chain. Never replace it with a re-walk of PATH during the backoff: that puts a synchronous stat of every PATH entry on the request path (#516) and executes candidates already refused. The app never restarts the service for this. Only `install.sh` writes the marker, always under `~/.codeman`, so a CLI installed by hand, or another `CODEMAN_INSTANCE`, waits out the backoff as before, and so does a server older than this. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`). + +⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log. + +## Agent inspection and guarded submission + +These three operations are experimental across both CLI and HTTP, including the +`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule +in `docs/versioning-policy.md`, so do not infer stability from the URL prefix. + +⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming +`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session +features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The +gate runs first, before session lookup or body validation, and reads +`settings.json` fresh on every request, so a toggle applies without a restart. +`codeman agent inspect|stage|submit` pass the 403 through verbatim. + +`agent-session-routes.ts` is registered by `WebServer` with the other route +modules. Ownership is checked before transcript/pane/receipt lookup. Inspection +uses strict native metadata attribution, bounded traversal, separate prefix and +candidate-metadata budgets, bounded path probes before canonical reads, and +complete UTF8 JSONL records. A full page advances its cursor without claiming a +blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane +geometry is excluded so changing devices does not invalidate transcript paging. +Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8, +under the global 1,024), so one polling agent retires its own oldest cursors instead +of evicting every other caller's from the shared FIFO. + +⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store +walk per call. The first call walks (negative-filter prefixes read 1 KiB first, +4 KiB only when that window holds no complete `originator` field; non-UUID file +names are skipped unread), then the attributed `{path, dev:ino}` and its strict +metadata are remembered per session (`attributionMemo`, bounded LRU). A later call +re-verifies only that file's head, then scans just the date directories not older +than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is +never pruned) for a second candidate, which is how a codex `/new` or a new owned +rollout beside a configured resume is still caught. A head that no longer verifies +drops the memo and falls back to one full walk; a verified head whose incremental +scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it +and answers unattributed without one, because the full walk reads a superset of that +scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422 +OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and +error code only, never the path. + +Staging requires a fresh, known composer/caret capture. The composer glyph and +footer are registry data (`capabilities.workDetect.promptGlyph` and +`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI +declaring `composerFooterLine` takes guarded input (codex today). Codex's footer +words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available +columns are calculated from actual captured geometry and the rendered prefix, +including indentation; reaching the last column or wrapping is refused. A message +starting with a `composerCommandPrefixes` character or whose last word starts with a +`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a +frame with a `composerPopupLine` row in the block just above the composer is refused at +stage and submit: codex draws its popups there and keeps its footer, so the footer +alone read `› /stat` with the command list open as idle, and Enter ran `/status`. +⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` +50) for the first frame with the same generation, `revision === before + 1` and +`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys +and the first capture is often from before them; another writer or a lifecycle change +ends the wait at once, and every refusal after typing says the message may be in the +composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call +(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to +the two calls only for output it cannot split; a combined call that THREW goes to the +capture's catch and is never rerun (a hung tmux would block the event loop for a second +timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives +while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`, +`_inGuardedTurn()`), and written in arrival order when the lease is released: the +browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by +N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written; +a held async write (mux write, named key) holds everything behind it until it settles +(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with +`1013` and applies nothing later from it, so the browser resends in order. `POST +.../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain, +since its browser drainer is serialized and retries; untagged input there (the phone +accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the +answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write +by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue +it again behind later input and wait on itself). `write()` refuses during the lease +only, since the drain writes held input through it, so a direct `write()` caller such +as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining. +⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and +an Enter within 120 ms of one becomes a newline while submit would report it +delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and +bounded ephemeral identities only, expire, and are consumed before awaited +submission, including failures. + +Submission additionally binds input and geometry revisions and rechecks the +exact composer/caret at the conditional Enter boundary. The route must retain +the Session snapshot's staging generation rather than replacing it with the +inspection generation. Managed input and auxiliary named keys participate in the +same pending/lease boundary: they wait for the lease, and only a full hold queue +refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are +not edits. Never retry ambiguous Enter delivery or clear an unverified composer. +The ordinary SubmitVerifier uses the shared write boundary, so each re-press +updates `lastSubmitAt` just like any other Enter. + +## Discord bridge + +`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md). + +- **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. 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 typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`. +- **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state. +- **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false. +- **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded. +- **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires. +- **Files.** Received files come only from Discord's HTTPS hosts into `/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first. +- **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop. +- **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`). diff --git a/docs/browser-testing-guide.md b/docs/browser-testing-guide.md index 117d8335..8df6b47c 100644 --- a/docs/browser-testing-guide.md +++ b/docs/browser-testing-guide.md @@ -8,8 +8,8 @@ This guide documents the browser testing infrastructure, framework comparison re # Run standalone benchmark (recommended - avoids vitest hook issues) npx tsx scripts/browser-comparison.mjs -# Run existing browser E2E tests -npm test -- test/browser-e2e.test.ts +# Run the browser suite (Playwright; the files are listed in config/test-suites.ts) +npm run test:browser ``` ## Framework Comparison Results @@ -67,7 +67,7 @@ We tested three browser automation frameworks against the Codeman web UI: }); ``` -3. **Use the existing browser-e2e.test.ts pattern** which uses agent-browser CLI commands via `execSync` (avoids async hook issues) +3. **Drive agent-browser through its CLI** with `execSync`, as `scripts/browser-comparison.mjs` does (avoids async hook issues) ## Test File Structure @@ -93,10 +93,8 @@ fixed ports 3180-3182. | File | Framework | Status | |------|-----------|--------| -| `test/browser-e2e.test.ts` | agent-browser | ✅ Working | -| `test/browser-playwright.test.ts` | Playwright | ⚠️ Vitest hook issues | -| `test/browser-puppeteer.test.ts` | Puppeteer | ⚠️ Vitest hook issues | -| `test/browser-agent.test.ts` | agent-browser | ⚠️ Vitest hook issues | +| `test/*.browser.test.ts` and the other files in `BROWSER_TEST_GLOBS` (`config/test-suites.ts`) | Playwright | ✅ `npm run test:browser` | +| `test/mobile/**` | Playwright | ✅ `npm run test:mobile` | | `scripts/browser-comparison.mjs` | All three | ✅ Working (standalone) | ## Framework-Specific Patterns diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 53343f05..23c3d684 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -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 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. +Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP, GitHub Copilot CLI and jcode — 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, 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 (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. - 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*. @@ -47,14 +47,21 @@ interface CliEntry { 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? } + // 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, and a dialog it is blocked on) + // 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 and reasoning effort, via src/web/launch-defaults.ts) + // 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) overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store } ``` @@ -63,7 +70,7 @@ interface CliEntry { ### Regexes that come from config -Six 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` and `capabilities.modelDetect.screenLine`. All six 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. +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. `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. @@ -124,6 +131,33 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f 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 @@ -189,11 +223,15 @@ still reading. A caller that asked only for `idle` keeps getting `idle`. `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. +`approvalHookFile` names the hook module Codeman installs into a local workspace for a CLI whose hooks are files rather than a settings block (omp: `/.claude/hooks/pre/codeman-approval.ts`, written by `applyApprovalHookFile()` in hooks-config.ts from both create paths, after the launch pre-flight and only while `workspaceHooksEnabled` is on). The module forwards the CLI's own tool approval events to `/api/hook-event`, and that is all the field grants: `blocked` can fire and a dialog opens an alert-only Approvals Inbox item (`approvalHookAvailableForMode()` in session-wait-registry.ts, which says no for an adopted, remote or Docker session). It is deliberately not a `hooks` value: every reader of `hooks` takes a yes for Claude's full hook set, so `stop` stays a 400 for such a CLI. ⚠️ A bare `.ts` file name and nothing else (schema.ts): `clis.json` can set it, and it is written into the user's workspace. A file of that name without Codeman's marker is the user's own and is left alone, and a symbolic link at any path component is refused. + | CLI | Skill directory | | --- | --- | | claude | `.claude/skills/codeman` | | codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` | -| pi, shell | none | +| pi, shell, jcode | none | + +jcode reads `.agents/skills` too, but it is left out (`agentSkillInjection: false`): its tools run in one shared per-user daemon with the CODEMAN_* identity of whichever session started it, so an injected skill would act as that session ([jcode-integration.md](jcode-integration.md#the-shared-daemon)). Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `/.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. @@ -201,6 +239,10 @@ Measured 2026-10-11 against the installed builds or their source: every CLI in t `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. +## A CLI with a shared daemon + +`capabilities.sharedDaemon: true` says the CLI's TUI is a client of one per-user daemon that its first client starts (in its own session and process group) and every later session shares. jcode is the one stock entry that declares it. A session kill normally signals every descendant of the pane by parent pid, and the daemon is still a descendant of the pane that started it, so closing that one tab cut every other session of the CLI off (measured with jcode). With the flag set, `killSession` signals only the descendants still in the pane's own process group (`descendantsInPaneGroup()` in `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=` lookup; a lookup that fails keeps the full kill). `test/proc-walk-bounds.test.ts` pins that jcode alone declares it, so no other CLI's kill changes. + ## 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: @@ -298,7 +340,7 @@ already have. Three rules, and the middle one is why the embed matters: -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`). +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`). 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. @@ -334,11 +376,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C ## 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 (plus GitHub Copilot CLI as a sync-only target, 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`. +`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, jcode) 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.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `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`. 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. +**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`) and, for the sync-only Copilot CLI, `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 `/`, 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. +`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 `/`, 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. @@ -347,3 +389,9 @@ The rules the module keeps and the tests pin: it only ADDS (a name already defin - [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 `, 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 `/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. diff --git a/docs/copilot-integration.md b/docs/copilot-integration.md new file mode 100644 index 00000000..66955e29 --- /dev/null +++ b/docs/copilot-integration.md @@ -0,0 +1,159 @@ +# 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 `. 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 `, 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 `, 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 `, 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 `/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 --model ` / `--continue --model ` 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`): `❯ HH:MM`, + `● ` 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. diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 0be6ec77..824916f2 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -207,9 +207,10 @@ trailing carriage return: | `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. | | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. | -> ⚠️ **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 +> ⚠️ **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 > 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 @@ -261,8 +262,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`). At cap → - **`failed`**. +5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS`, 50 unless + `CODEMAN_MAX_SESSIONS` sets it). 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. @@ -284,7 +285,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 50-session cap in ~25 hours (sooner with existing tabs), after +> hits the global session cap (50 by default, `CODEMAN_MAX_SESSIONS`) 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. @@ -376,7 +377,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` | +| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` (env `CODEMAN_MAX_SESSIONS`) | | Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) | | `name` length | 1–200 | `CronJobSchema` | | `promptText` length | ≤ 100000 | `CronJobSchema` | diff --git a/docs/custom-model-endpoints-plan.md b/docs/custom-model-endpoints-plan.md index 5078f17b..aec0b910 100644 --- a/docs/custom-model-endpoints-plan.md +++ b/docs/custom-model-endpoints-plan.md @@ -8,8 +8,10 @@ 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 is DeepSeek's server-env-sourced -`DEEPSEEK_BASE_URL`, which isn't user-facing. +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). **Scope note**: this plan originally said "local LLM." It now covers any OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama, @@ -112,7 +114,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/` 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.]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly | -| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST /chat/completions` → `404`, `POST /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 | +| `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` | | `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/` 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 | diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index f37ddebe..187bf5f0 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -67,15 +67,23 @@ 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.** 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 +**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 `status.value === "loaded"` for — never for an unloaded one, because llama-swap treats `?model=` as a routing hint and asking about a model that isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side effect of what should be read-only discovery. A server with no `status` field -on any entry at all (not llama-swap) gets no context-length enrichment, -rather than guessing. A model's previously-learned context length survives a +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 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 @@ -97,6 +105,13 @@ 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 @@ -185,32 +200,66 @@ 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: +under your thumb. Three kinds of knowledge feed that promotion, best +evidence first, and the first one that answers wins: -- **"Currently loaded"** — a model from this host's own list that +- **"Currently loaded"** is a model from this host's own list that llama-swap reports `ready` right now, queried via `GET /api/model-endpoints/:id/running-status`. Bounded client-side to ~800ms (`Promise.race`), on top of the route's own 5s server-side timeout, so an endpoint that is asleep or firewalled cannot leave the modal invisible for the full 5s after the Run menu has already closed. -- **"Last used"** — shown only when nothing is currently loaded: the model - actually launched last for this exact (harness, endpoint) pair, read - from the per-device `codeman:customModelLastUsed::` - localStorage key. Written by `_runCustomModelEntryViaRestart` (claude) - and `_quickStartWithCustomModelConfirm` (every one-shot launch; the +- **"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::`. 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 `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. -Neither tag reorders anything past that one promoted row. The "Default" +No promotion 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 @@ -279,6 +328,15 @@ hot-swap. A Claude session is relaunched with `--resume || grok are relaunched with the `--model` value that selects the injected provider (`custom/` 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 ` 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. @@ -286,14 +344,16 @@ agent, so the selection would report success and change nothing. **Claude gets two more env vars when known/applicable, both declared on its registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:** -- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context - length (see the discovery section above) whenever one is known. Without +- `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 it, Claude Code assumes a large (200k) window for any unrecognized custom model id and never compacts, which reliably overflows a much smaller real local context — confirmed live: a stock ~33.7K-token system prompt against a 16384-token llama-swap model failed with `exceeds the available context -size`. No entry for the model in `modelContextLengths` means the var is - simply omitted, never a guess. ⚠️ **This var only affects when Claude +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 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 @@ -324,7 +384,10 @@ size`. No entry for the model in `modelContextLengths` means the var is 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. + 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. **That isolated directory needed one more fix to actually be usable non-interactively.** An otherwise-empty `CLAUDE_CONFIG_DIR` has none of a @@ -434,15 +497,17 @@ Both apply routes (the restart route and the one-shot `POST /api/quick-start`) now check for this **before** launching or restarting anything, gated on the CLI's registry entry declaring a `contextLengthVar` (currently only claude — the check is a no-op for every other CLI by -construction, never a hardcoded mode check). If the model's discovered -context (`modelContextLengths`, from discovery above) is below -`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured +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 ~36.4K overhead), the response is `{requiresContextWarning: true, modelId, -contextLength, minSafeContextTokens}` instead of applying — nothing is -restarted or created yet. A context length that was never discovered at -all skips the check entirely (nothing to compare, so it fails open rather -than warning on every model an endpoint hasn't reported a size for). -Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions). +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). The Run-menu picker shows this as an in-app modal (`#customModelContextWarningModal`, matching the llama-swap conflict @@ -473,13 +538,31 @@ id, model and injected key NAMES are persisted, the values are re-derived from the endpoint store on recovery, and the pane keeps running against the endpoint in between because tmux retains its environment. -⚠️ Clearing removes injected keys **by name**, and `CLAUDE_CONFIG_DIR` is one -of the names claude's selection injects — so a session that ALSO had -`CLAUDE_CONFIG_DIR` set through the generic `envOverrides` field (the -per-client-account case) loses that override on clear too, and silently -falls back to the server's default Claude account. If you route a session -to a specific account this way, re-apply the override after clearing a -custom-model selection from it. +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. **New sessions always default back to the harness's native backend.** A custom-endpoint selection is a per-session choice, never a sticky global @@ -495,6 +578,13 @@ automatically). Results: - **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply came back through the endpoint. + Pi's model entry carries the endpoint's context length when one is known + (`contextWindow`, the full window, and `maxTokens`, a quarter of it and + 8,192 at most). Without it Pi takes an unknown model for a 128,000-token + one (its footer reads `/128k`) and asks for 16,384 reply tokens, so on a + smaller window it never compacts before the server's real window is full. + Measured on pi 1.5.2: with both fields `pi --list-models` shows the window + and the request carries `max_tokens: 8192`. - **Codex** — the config is structurally correct, and against a llama-swap server that DOES answer `/v1/responses` (confirmed live: a plain, no-tool-call chat turn returned a real reply), the picture is more @@ -519,7 +609,12 @@ for '' 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. + 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. - **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 @@ -534,14 +629,40 @@ for '' not found. Defaulting to fallback metadata...` on launch — `POST /chat/completions` 404s against llama-swap while `POST /v1/chat/completions` succeeds; the harness's own error template (`DeepSeek API error (HTTP ${status})`) matches the originally - reported symptom exactly. `customModelInjection`'s new `appendV1Suffix` - (deepseek's entry only — claude/gemini must NOT get it, since claude was - already confirmed working against the raw `baseUrl`) fixes it by writing - `DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a - real `dsh` binary (no install available in this environment) — the fix - is source-confirmed and live-verified at the HTTP level, but a real - "hello world" reply through `dsh` itself is still outstanding before - calling this fully verified like the harnesses above. + 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. - **Antigravity** — no known custom-endpoint mechanism at all; unsupported. See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind @@ -549,6 +670,45 @@ 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 diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index 874f6fd1..02af1002 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -235,9 +235,11 @@ 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 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. + 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. - **Not every `user/message` is the user.** Each turn also records a plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a prompt. diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md new file mode 100644 index 00000000..86210d3e --- /dev/null +++ b/docs/discord-bridge.md @@ -0,0 +1,137 @@ +# 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 `. +- 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 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 `/.codeman-discord/-` (mode 0600) and the one-line reply the CLI sees ends with `[attached: ]`. 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 ` 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 | diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 4b8bd7bc..ebacef95 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -34,8 +34,8 @@ must not change what is inside an image tagged `codeman/agent:base`, or two mach that tag hold different images and every cache decision downstream is a lie. Each entry's `enabled` flag IS honoured, so a CLI that ships disabled is never baked in. -Five CLIs keep hand-written layers, for two different reasons that are easy to conflate. -`antigravity`, `grok` and `omp` declare no `npmPackage` at all, so they never enter the shared +Six CLIs keep hand-written layers, for two different reasons that are easy to conflate. +`antigravity`, `grok`, `omp` and `jcode` declare no `npmPackage` at all, so they never enter the shared npm layer and each gets a vendor-installer layer instead. `pi` and `deepseek` ARE on npm but carry `discovery.install.agentImageLayer` in `stock.ts` (a REGISTRY field, rather than an id-keyed table duplicated between the two producers of the image's build args), which pulls @@ -47,6 +47,7 @@ them out of the shared layer because a plain `npm install -g` is not enough for | `deepseek` | Needs `pnpm` alongside it (`dsh plugin`, issue #352) plus a `dsh-tui` profile install. | | `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). | | `grok`, `omp` | Not on npm — standalone vendor installers. | +| `jcode` | Not on npm: jcode.sh's installer, then the WHOLE version directory copied to `/opt/jcode` (the launcher is an `sh` script that execs the binary beside it). | `test/docker-agent-image-coverage.test.ts` requires every special case to carry a written reason AND still be present in the Dockerfile, so an exclusion cannot silently become an @@ -62,7 +63,7 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif ```bash docker run --rm codeman/agent:base bash -lc \ - 'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' + 'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' ``` ⚠️ `dsh --version` is the one line above that answers a different question than the @@ -81,6 +82,8 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md). +jcode's credentials are not seeded at all: `~/.jcode` keeps its OAuth tokens beside ~160MB of builds, its sessions and SQLite caches, and its API keys live in `~/.config/jcode`, so there is no small set of files to copy. Sign in to jcode inside the container (`docs/jcode-integration.md`). + The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated. 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. @@ -121,6 +124,13 @@ 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 @@ -202,7 +212,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. +- 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. - 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). diff --git a/docs/docker-compose.md b/docs/docker-compose.md index 8a838d6d..ca72e8c5 100644 --- a/docs/docker-compose.md +++ b/docs/docker-compose.md @@ -12,14 +12,25 @@ It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azu ## Prerequisites -- Docker Engine or Docker Desktop with Docker Compose v2 -- A reachable Docker daemon +- 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) The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access. ## Start -Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes. +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. ```sh cp docker/.env.example docker/.env @@ -31,12 +42,6 @@ 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 @@ -45,7 +50,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 `http://localhost:3000` and sign in with the username and password from `docker/.env`. +Open the URL the script printed (`http://localhost:3000` by default) and sign in with the username and password from `docker/.env`. ## Operations diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index eaf18448..324d7800 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -165,7 +165,7 @@ agent skill instead of teaching it these calls by hand: `skills/codeman` in the (`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install [--case ] [--mode ]`, or the synced `agentSkillEnabled` App Setting for automatic per-case injection on session create, into the directory each CLI scans for -skills). The skill carries the guard, the safety rules, and verified +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 @@ -183,7 +183,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 (single-line only, and it must end with \r: Enter is sent only +# Send a prompt (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 \ @@ -294,8 +294,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. Single line only, ending in \r (otherwise -# Enter is never sent and this wait times out on a turn that never started). +# 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). 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, @@ -414,14 +414,16 @@ 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 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. +- **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. - **`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. diff --git a/docs/file-viewer-edit-plan.md b/docs/file-viewer-edit-plan.md index af17b744..91868043 100644 --- a/docs/file-viewer-edit-plan.md +++ b/docs/file-viewer-edit-plan.md @@ -45,8 +45,9 @@ target escapes the workspace is already rejected. Ownership is `findSessionOrFai - CSS: `src/web/public/styles.css:9320-9430`. Overlay `z-index: 2000`, window `80vw/80vh`, capped `900x700`. There are **no `.file-preview-*` rules in `mobile.css` at all**. -**Reachability on phones.** The header File Viewer button is hidden below 430px -(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`), so on a +**Reachability on phones.** When this plan was written the header File Viewer button was hidden below 430px +(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`); since +#759 it shows on a phone once switched on there, and stays off by default. Without it, on a phone the preview overlay is reached through: 1. an attachment card's **Preview** button (`panels-ui.js:3451`), which is exactly the "agent just wrote a @@ -109,7 +110,9 @@ Decisions baked in: - `svg` and `env` are deliberately marked with `?` above: `svg` is served as an untrusted octet-stream on the read side (`file-routes.ts:118`) so allowing an edit is defensible, but I recommend **excluding both** in v1. `.env` files are matched by `isSensitivePath()` anyway and would be rejected downstream; - excluding them at the allowlist keeps a single obvious refusal. + excluding them at the allowlist keeps a single obvious refusal. Superseded for dotenv: the plain + preview serves a workspace `.env` in full, so the editor now opens dotenv files (`allowDotenv`, see + `docs/architecture-invariants.md#file-viewer-edit-mode`); `svg` stays excluded. - `isDeniedEditRelativePath` blocks the `.git/` subtree: `.git/hooks/*` is code execution and a corrupt index is unrecoverable-looking to a user who only wanted to fix a typo. Other dotfiles stay allowed but are not reachable from the tree UI anyway (`showHidden=false`). @@ -363,7 +366,8 @@ Route cases to assert explicitly: 4. symlink pointing inside is written through to the target 5. non-allowlisted extension gives 400 6. `.git/config` gives 403 -7. a `.env` in the workspace gives 403 (sensitive-path) +7. a `.env` in the workspace gives 403 (sensitive-path); superseded: it is now editable, while `.ssh/.env`, + the data dir's `.env` and the other credential files still give 403 8. a file with a NUL byte gives 400 9. a latin-1 file that fails the UTF-8 round-trip gives 400 10. stale `baseHash` gives 409 (`CONFLICT` envelope, no data); `force:true` then succeeds diff --git a/docs/grok-integration.md b/docs/grok-integration.md index 81a00803..2b0d4e7a 100644 --- a/docs/grok-integration.md +++ b/docs/grok-integration.md @@ -114,7 +114,7 @@ history. ## Remote SSH cases `grok` mode is routed through an interactive login shell -(`exec "$SHELL" -i -l -c 'grok'`), because sshd's remote-command PATH does not include +(`remoteLoginShellCommand('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. @@ -122,11 +122,11 @@ 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 and OMP a - screen check. So Codeman calls a new grok pane idle 3 seconds after launch, and after + 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, as for Antigravity. Grok has a hooks system, so a Codeman hook POSTing to + grok. 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///updates.jsonl`; nothing reads them yet. diff --git a/docs/jcode-integration.md b/docs/jcode-integration.md new file mode 100644 index 00000000..c29dda8a --- /dev/null +++ b/docs/jcode-integration.md @@ -0,0 +1,277 @@ +# jcode sessions + +Codeman can drive [jcode](https://github.com/1jehuang/jcode) (1jehuang/jcode, MIT, a +coding-agent TUI written in Rust) as a session backend, alongside Claude Code, OpenCode, +Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and GitHub Copilot. `jcode` is its own **run mode**: +its own PTY, its own tmux session, its own tab identity (amber, `JC` badge, a plain ring +standing in for jcode's halftone donut). It is not a location overlay like Docker or +remote-SSH cases, and it is not a web tab. + +Everything here was measured against jcode v0.98.1 on 2026-10-16 (see +[How it was measured](#how-it-was-measured)). The registry entry in +`src/config/cli-registry/stock.ts` carries the same facts in its comments and is the +source of truth when the two disagree. + +## Install + +```bash +curl -fsSL https://jcode.sh/install | bash +``` + +The installer downloads a checksummed release binary into +`~/.jcode/builds/versions//` and links `~/.local/bin/jcode` (its +`JCODE_INSTALL_DIR` default) to it. The linked `jcode` is a small `sh` **launcher** that +resolves its own path and execs the `jcode--.bin` beside it, so the version +directory is what matters, not the single file the link points at. + +Codeman resolves the binary through the generic registry resolver (there is no +jcode-specific resolver module): the server PATH, then `~/.local/bin`, `/usr/local/bin` +and `~/bin`, then an interactive login shell. npm carries an unrelated package also called +`jcode`, so a PATH hit is not trusted on its own: `jcode --version` must print +`jcode v` (the real one prints `jcode v0.98.1 (1c7426eb1)`), and anything else +counts as absent. Check what it resolved: + +```bash +curl -s localhost:3000/api/jcode/status | jq +# { "available": true, "path": "/home/you/.local/bin", "version": "0.98.1" } +``` + +## Authenticate + +jcode keeps its own logins: OAuth accounts in `~/.jcode/auth.json`, API keys in +`~/.config/jcode/.env`, the default provider and model in `~/.jcode/config.toml`. +Set them up with jcode itself (`jcode login`, `/login` inside the TUI, or +`jcode provider add --base-url ... --model ...` for an OpenAI-compatible server). +jcode can also offer to reuse logins other CLIs left behind; it asks before reading any of +them. Codeman forwards no credentials into a jcode pane. + +## What Codeman wires up + +`JcodeConfig` (per session, persisted in `state.json`, round-trips through respawn and +server restart): + +| Field | Flag | Notes | +| ----------------- | --------------- | ---------------------------------------------------------------------------- | +| `model` | `--model ` | e.g. `claude-opus-4-8`, `gpt-5.5` | +| `resumeSessionId` | `--resume ` | a session id (`session_fox_1792160401272_f5b47ce47d631006`) or its short name (`fox`) | + +Both values are regex-validated at the route and checked again by the argv engine, and a +value that fails is **dropped**, never escaped into the command. `resumeSessionId` uses the +`path-segment` pattern (a leading letter or digit) on purpose: jcode's `--resume` takes an +OPTIONAL value (a bare `--resume` opens its session picker), so a value starting with `-` +would be parsed as a flag of its own (`--resume --no-update` is two flags). + +The Run button sends **no** `jcodeConfig`: jcode has no approval prompts and no bypass +flag, so there is nothing to opt into (the same shape as pi). A bare spawn is just `jcode`. + +There is **no** `envOverrides` prefix for jcode: `JCODE_*` is not allowlisted. See +[The shared daemon](#the-shared-daemon) for why. + +## The shared daemon + +Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`). The first client +starts it, in its own session and process group (`setsid`), and every later client attaches +to it. It stays the first client's CHILD by parent pid, though, which is what the +[session kill](#closing-a-tab-never-takes-the-daemon-down) has to account for. The agent and its tools run in the DAEMON, with the daemon's +environment. Measured: a second client started with `CODEMAN_PROBE=second-client` asked its +bash tool to `echo "probe=[$CODEMAN_PROBE]"` and got `probe=[]`. + +Three things follow, and each one is a deliberate decision: + +- **The agent skill is not injected.** jcode does read `./.agents/skills` (and + `./.jcode/skills`, `./.claude/skills`), but a jcode agent's tools see the + `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` and `CODEMAN_MUX` of whichever session started + the daemon, or none at all if it was started outside Codeman. An injected skill would act + as THAT session: spawn children under the wrong parent, prompt another session's + workers. A user-level copy of the skill (`~/.agents/skills`, which jcode also reads) has + the same problem; this is the same trap as codex's shared `app-server`, only it is jcode's + normal case rather than an edge. +- **No `JCODE_*` envOverrides prefix.** Most of that namespace is read by the daemon, so a + per-session value would do nothing, and where it did act (in the session that happens to + start the daemon) it would reach every jcode session on the machine. The namespace + includes variables that run code or redirect traffic: `JCODE_HOOK_*`, `JCODE_SPAWN_HOOK`, + `JCODE_*_COMMAND`, `JCODE_*_API_BASE`. Configure jcode in `~/.jcode/config.toml`, or in + the Codeman server's own environment. +- **Deleting a Codeman jcode session leaves the daemon running**, by design: the other + sessions live there (next section). `jcode server stop --force` stops it by hand. + +### Closing a tab never takes the daemon down + +Codeman's session kill signals every descendant of the pane (by parent pid), so it used to +kill the shared daemon whenever the closed tab happened to be the one that started it. +Measured on a beta before the fix: two jcode sessions, the first one deleted, and the second +one's client looped on `Connection lost - retrying (attempt 9) - server closed the +connection` with its prompt queued (`⏳`), while a send-and-wait on it timed out. + +The jcode entry declares `capabilities.sharedDaemon: true`, and for such a CLI the kill +signals only the descendants still in the pane's own process group +(`descendantsInPaneGroup()`, `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=`). The daemon +left that group when it called `setsid`, and everything it runs inherits its group, so the +daemon and its tools are spared while the client and anything else in the pane's group are +killed as before. A lookup that fails keeps the old behaviour (kill every descendant). Every +other CLI is unaffected. Measured after the fix: the kill logs `Sparing 1 process(es) +outside pane 's group (the CLI's shared daemon)`, the daemon keeps its pid, and the +remaining session answered a send-and-wait in 5 s. + +The flip side: a process a jcode agent started (a dev server, a watcher) is a child of the +daemon, not of the pane, so closing the tab does not stop it. That is how jcode behaves in a +plain terminal too. + +## Approvals and multi-user mode + +jcode has no approval dialog. Its bash tool runs ungated apart from jcode's own model-side +reflection gate on risky commands and a hard deny on catastrophic paths. So there is no +bypass flag to clamp, nothing for the multi-user clamp to force off, and no dialog for +Codeman to read as `blocked`. In multi-user mode a jcode session is like a pi session: an +agent that runs tools without asking, under the one OS account every session shares. + +## Terminal behavior + +jcode is a **full-screen alternate-screen TUI with mouse tracking on** (tmux +`alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`). It takes the opencode strip, +`altScreen: 'strip-mux-and-mouse'`: the mouse DECSETs are stripped, so a drag selects text +locally (Auto Copy and copy-on-select work) instead of being reported to jcode. + +Scrolling: jcode pages its own transcript on PageUp/PageDown and also scrolls on SGR wheel +reports (both measured). It declares `transcriptPageKeys: true`, so over the hollow +alt-screen buffer a wheel or a swipe becomes PageUp/PageDown instead of doing nothing. + +Input: a long prompt sent the way Codeman sends one (a bracketed paste, then a separate +Enter) submits cleanly (measured with a 170-character prompt). + +### How Codeman reads a working pane + +At rest the composer row is `> ` (the prompt number, then `>`); a submitted prompt is +echoed into the transcript as `› `. While a turn runs the composer row reads +`…` and a status row appears directly above it: a braille spinner (the gemini/omp frame +set, about 12 fps), then the phase and the elapsed time: + +``` +⠋ sending… 0s +⠦ thinking… 2s · https +⠼ waiting for response… 3s · https +⠸ running bash · · 12s · Alt+B bg +⠼ 12s · 45.1 tps · ↑16k ↓20 (streaming: the time comes first) +↻ network disconnected, waiting to retry · … · 5s +``` + +Past a minute the time reads `1m 3s`, past an hour `1h 2m`. At rest that row is empty or a +dim tip, never a spinner, and the turn leaves a summary row (` 21s · 199.6 tps · ↑17k ↓2k`) +that has a time but no spinner. The `workingLine` pattern therefore needs a spinner (or the +`↻ network disconnected` row) at the start of a row AND an elapsed time on it. + +The composer glyph is the bare `>`. tmux redraws only the cells that changed, and on the +composer row at the end of a turn that is the `…` turning into `>` (the recorded client +stream carries `\x1b[43;2H...> `), never the prompt number, so `>` is what reliably arrives. +Mid-turn stream chunks never contain it. The glyph only ARMS the idle check; the screen and +the silence decide. + +Silence is a sound signal for jcode: the spinner repaints the pane at least every 100 ms for +the whole turn, and at rest jcode sends nothing at all (12 seconds recorded: zero bytes). + +The submit verifier stays inert for jcode, which is what it wants (it has no early-Enter +defect): every composer row starts with its prompt number, so no screen row starts with +`>` the way the verifier requires. + +### Reading a jcode worker's answer + +`GET /api/sessions/:id/last-response` returns an **honest empty** (`text: ""`, and +`messages: []` with `?context=full`) for jcode. The pane segmenter the other TUIs use cannot +read a repainting alternate-screen stream (measured: it served jcode's welcome banner as +"the answer"), and without this the route would fall through to the Claude transcript scan, +which could adopt a Claude conversation from the same folder. Read a jcode worker's screen +instead: + +- `codeman agent read --tail 3000` +- MCP `read_output` with `source: "terminal"` +- `GET /api/sessions/:id/terminal?tail=3000` + +There are no `stop`/`blocked` hook signals for jcode: wait for `idle`, or use `wait-output` +markers, as with every hook-less CLI. + +## First run: the onboarding wizard + +A fresh jcode install opens an onboarding wizard ("Welcome to jcode onboarding / Log in to +OpenAI?") instead of the composer, on every launch, until its launch counter passes 5 or +10 sessions exist. A prompt typed into the wizard is lost (measured: it wedged that client). +Before driving jcode from Codeman, run `jcode` once in a terminal and finish or skip the +wizard, or press Esc in the Codeman pane. + +## jcode's own defaults Codeman does not change + +- **Telemetry** is on by default in jcode (install count, version, OS, session activity, + tool counts; jcode's `TELEMETRY.md`). Opt out with `jcode telemetry disable`, or set + `JCODE_NO_TELEMETRY=1` in the Codeman SERVER's environment (it is not a per-session env + override, see above). +- **Auto-update** is on by default for release builds; `--no-update` is jcode's own flag and + Codeman does not pass it. +- **Self-dev mode**: jcode switches into it when started inside a checkout of jcode itself. + +## Docker cases + +jcode is not on npm, so the agent image installs it in a dedicated Dockerfile step through +jcode.sh's installer, then copies the WHOLE version directory to `/opt/jcode` (the launcher +execs the binary beside it, so copying only the linked file, as the grok and omp steps do, +would leave a launcher with nothing to run) and links `/usr/local/bin/jcode` to the launcher. +Root's `~/.jcode` is dropped in the same layer so the image does not carry the ~160 MB +binary twice. Verified in `node:22-bookworm-slim`, including `jcode --version` as the +unprivileged `agent` user. Rebuild with the mandatory `--no-cache`: + +```bash +node scripts/build-agent-image.mjs --no-cache +``` + +No credentials are seeded into the container: `~/.jcode` holds the OAuth tokens beside the +builds, sessions and SQLite caches, and API keys live in `~/.config/jcode`. Sign in inside +the container instead. + +## Remote SSH cases + +`jcode` mode is routed through an interactive login shell +(`exec "$SHELL" -i -l -c 'jcode'`), because sshd's remote-command PATH does not include +`~/.local/bin`. Per-session config does not cross ssh and is rejected rather than silently +ignored; a host whose `jcode` lives elsewhere takes the per-host `commands.jcode` override. + +## Not supported yet, and why + +- **A transcript reader** (a real `last-response`, Response Viewer content). jcode writes + sessions as `~/.jcode/sessions/session___.json` plus a `.journal.jsonl`, + which is easy to parse, but pairing a Codeman pane with ITS session file is ambiguous: the + session JSON's `last_pid` is the shared daemon's, not the pane's, so two jcode panes in + one folder cannot be told apart from the files alone. +- **Custom Model Endpoint Profiles** (`customModelInjection: unsupported`). jcode's provider + is resolved in the daemon, not the pane, so a per-session redirect has nothing reliable + to land on. +- **The model in tile headers** (`modelDetect`). The status bar names provider and model + (`qwen5090 qwen3.8 27b`), but its layout varies by provider and width and was not + measured enough to anchor a pattern on. A session shows its launch model, if any. +- **Hook-based `stop`/`blocked` signals.** jcode has lifecycle hooks (`session_start`, + `turn_start`, `turn_end`, `session_end`), but they run in the daemon too, with the + daemon's environment, so they cannot tell Codeman which session they belong to. +- **Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are off** + for jcode, as for every external CLI. + +A per-session daemon (`jcode --socket `) would give each pane its own environment, +but it gives up the shared daemon that is jcode's main design point (cross-session swarm +awareness, a few MB per extra session), so it was not done here. + +## How it was measured + +- jcode installed with its own installer into a scratch `HOME` with telemetry off + (`DO_NOT_TRACK=1`, `JCODE_NO_TELEMETRY=1`) and a short `XDG_RUNTIME_DIR` (the daemon's + Unix socket path must stay under the `SUN_LEN` limit), so nothing touched the real home. +- A provider set up with `jcode provider add` pointing at a local OpenAI-compatible server + (an RTX 5090 serving Qwen 3.8 27B), so no subscription login was involved. +- jcode run in a scratch tmux socket at 160x45; `tmux capture-pane -p` every 200 ms through + real turns (a plain reply, a bash tool call, a streamed answer); `pipe-pane` at rest; and a + tmux CLIENT attached through a PTY to record exactly what Codeman's PTY reads. +- PageUp/PageDown sent with `send-keys`; SGR wheel reports (`\x1b[<64;40;10M`) pasted raw. +- A second client with its own environment to prove where tools run. +- The Docker step run in a throwaway `node:22-bookworm-slim` container. +- End to end through an isolated beta Codeman built from this branch (its own + `CODEMAN_INSTANCE`, port and tmux socket): `POST /api/quick-start` with `mode: "jcode"`, + `/api/jcode/status`, send-and-wait (`wait: "idle"` returned after the turn, 12.7 s for a + turn with a 5 s tool call, not during it), two sessions with the first deleted, and a + Playwright pass over the Run menu entry, the `Run JC` button and the rendered transcript. + +The fixtures in `test/jcode-mode.test.ts` are verbatim from these captures. diff --git a/docs/local-models.md b/docs/local-models.md new file mode 100644 index 00000000..f259081e --- /dev/null +++ b/docs/local-models.md @@ -0,0 +1,598 @@ +# 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. The same window then lists the models that are on this Mac with what each takes +on disk, adds another one, switches between them and deletes one +([The models on this Mac](#the-models-on-this-mac)). + +**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 under it every other +model of the catalogue that fits (eleven models today: a 16 GB Mac sees one of them, a 512 GB +Mac all eleven). 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 9.3 GB at 65,536 tokens | +| 16 GB | 10.4 GB | A small model (`limited`): Ornith 1.0 9B at 65,536 tokens, 9.3 GB, tight (90%). Its build has no vision projector, which makes it 0.9 GB smaller than the other two 9B builds: MiMo V2.6 9B needs 10.2 GB there, 98.7% of the budget, just past the 98% a tight fit may reach. Once the runtime measures what a 16 GB Mac mini measured (10.7 GB), all three fit and MiMo is the pick. No 9B was run on such a Mac by us: only a run settles it | +| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Ornith 1.0 9B (10.5 GB) and Qwen3.5 9B are the alternatives | +| 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 | No longer a small-model Mac: Muse Glimmer 30B fits at 65,536 tokens (21.2 GB, 97%, tight). It is a dense model. On an M1 Max or M2 Max it computes to 11.8 tokens per second and is the pick; on a base or Pro chip it computes to under 6 and is listed as slow, and the pick stays MiMo V2.6 9B at 131,072 tokens with the note that it is a small model. 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. Run once on a Mac (an M5 Pro with 48 GB, 2026-11-12): this GGUF build wrote 15 tokens per second, and an OpenCode worker given a one-line task showed no answer after 7 minutes. Its MLX build, which finished an OpenCode turn on a one-line prompt in 45 seconds, needs 36.1 GB and is offered from 48 GB | +| 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. Muse Glimmer 30B at 131,072 tokens (21.6 GB, 78%) is the alternative with room to spare | +| 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). A session of many prompts on that Mac (a 95,000-token prompt, then twelve separate ones of 15,000 tokens, measured on 2026-11-12) ended at 33.4 GB of the 40.2 GB its runtime measures, still growing by 0.5 GiB a prompt: MLX may keep up to 8 GiB of earlier prompts, which brings this pair to the edge of the budget. Muse Glimmer 30B as its MLX build is offered here too, at 65,536 tokens (36.1 GB, tight) | +| 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 32 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. For Muse Glimmer the MLX + runner was measured holding 38,000 to 45,000 bytes per token, against the 7,072 its GGUF + build computes to: close to every layer's cache at full length, so it evidently does not + trim the sliding-window layers the way llama.cpp does. + +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 | +| `ornith:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B; its config and its GGUF header say so) | 9.3: the same architecture and weights size | +| `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 | +| `muse-glimmer:30b-q4_k_m` | llama.cpp | computed from its config: 13 full-attention and 39 sliding-window layers | 2.95: the dense 27B's, whose weights are the same size | +| `muse-glimmer:30b-mlx` | MLX | **measured** on an Apple M5 Pro with 48 GB, the 8 GiB of prefix snapshots counted | 5.4: measured beside the 35B-A3B on MLX, 0.32 of its 16.9 | +| `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 (the Ornith 1.0 and Muse Glimmer rows on 2026-11-10, Muse Glimmer's MLX build on +2026-11-12). Two rows carry memory figures measured on a Mac, both on an Apple M5 Pro with +48 GB; every other answer in the table above is a calculation. Three more rows were installed +and loaded on that Mac on 2026-11-12 (Qwen3.5 9B, Ornith 1.0 9B and Muse Glimmer as a GGUF +build): that checked their download size and that they load on the GPU, not their memory +figures. 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 35B-A3B's figures come from single long prompts, not a session of many +requests, in which MLX may keep up to 8 GiB more: such a session was measured on it later +(the 48 GB row above) and its figures were left as they are. Muse Glimmer's MLX row counts +the 8 GiB. The catalogue header lists the models left +out on purpose (no evidence of working tool calls, a restrictive licence, a known bug, a +build that needs a newer runtime than the pinned one) and why. The strongest of those today +is Ripple 65B Preview, published on 2026-11-09: every build of it requires Ollama 0.40.16, +one above the pin. + +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 same job adds a second model to an installed one +("Add a model"): the runtime is there already, so it downloads the model alone, and the +earlier model stays on disk. 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:`, `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. + +### Which coding agents, and what each was measured with + +The coding agent is chosen **per session**. An installed model lists every agent it can be +run with (`install.harnesses` of the GET), and the window shows them as chips under "Start a +session with": the agent the model was installed with is the default, and the choice is +remembered on the device. The launch check asks about the endpoint and the model, never about +the agent, so a session with one agent and a session with another run on the same model. + +| Agent | First request (its fixed prompt) | Installed from the window | What was seen, and where | +| ---------------- | ------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenCode | about 8,100 tokens, 11 tools (v1.19.5; more with user-level skills) | yes | A tool round trip on a Mac with a real model (the catalogue's tested entry), and on a scripted endpoint for v1.19.5 | +| Claude Code | about 36,000 tokens, so the first answer takes minutes | yes | Shipped in 1.50.0; not measured again for this table | +| Pi | 2,096 tokens, 4 tools (v1.5.2) | yes | A complete tool round trip against a real model: tool call, tool result, final answer, the file on disk | +| DeepSeek Harness | about 7,650 tokens, 25 tools (0.1.1-rc.2); 6,983, 27 tools (0.2.2-rc.1) | **no** | A tool round trip on a scripted endpoint only, on both versions. Against a real model the launch and the model name reached the runtime, and no turn finished | + +How to read the last column: the runs of 2026-11-10 were made on **Linux**, through Codeman's +own custom model launch, against Ollama 0.13.0 with a 1.7B model on a CPU ("a real model") or +against a scripted endpoint that replays that runtime's stream of a tool call. A scripted run +proves the agent's side of the protocol, not what a model does with its prompt. None of them +ran on a Mac or on the pinned runtime. Every agent sent the `name:tag` model name unchanged. + +Three things follow from how the agents differ: + +- **Pi is told the model's window.** Without it Pi takes an unknown model for a + 128,000-token one and never compacts before the real window is full. Codeman now writes the + endpoint's context length into the model list it gives Pi (the custom model feature as a + whole, not only this window). Two things remain: a custom model session gives Pi a home + directory of its own, so Pi fetches its two search tools again at every start (it needs the + network for that; `PI_OFFLINE=1` skips it), and Pi asks "Trust project + folder?" in every such session when a folder above the working directory holds agent + skills, because the answer is stored in the home it does not keep. +- **DeepSeek Harness is a launcher.** `dsh` starts nothing by itself: a session needs a + terminal profile, which is third-party code with its own consent in the Run menu. Its + install command installs the launcher only, so this window never installs it. It is listed + as "Needs setup" until the Run menu can start it, and as a normal agent from then on. It is + the one agent of the four besides Claude Code that reports the end of a turn. Its custom + endpoint recipe itself was verified with a real model on a llama.cpp server + ([custom-model-endpoints.md](custom-model-endpoints.md)); what is missing is a finished turn + on this feature's runtime. On today's version (0.2.2-rc.1 with dsh-TUI 0.19.0) two more + things were seen: a setup guide opens in every session until a person has finished it once + (a person in a tab presses through it; a script must not, see the codeman skill), and its + transcript file was renamed, so Codeman's response viewer and `last-response` are empty for + a dsh session on that version until the reader learns the new name. +- **OpenCode and DeepSeek Harness send a second, small request** beside the first prompt (a + title, a side question). The runtime answers one request at a time, so it queues. + +### Installing one more agent beside an installed model + +An agent that is not on the Mac has "Not installed" on its chip. Selecting it shows the +literal install command, and the main button becomes "Install ". That click is the +consent; `POST /api/local-model/agent` then runs the same stock command under the same rules +as the `agent` step above (catalogue row only, compiled command only, admin only, audited, +one installer at a time). + +It is **not a job**. The state stays `installed` whatever the installer does: the model stays +loaded, sessions are not refused, and a session with another agent can be started while it +runs. A failed install shows the sentence and the installer's last lines under the chip, and +leaves everything else as it was. The outcome is kept in memory only, so a restart of the +server forgets a failure (the chip then simply says "Not installed" again). + +### 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 selected 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: + +- no job that changes the model in use is running or stopped: until an install or a switch + ended, nothing is started ("The model on this Mac is being changed and is not ready"); +- the runtime child Codeman started is alive (whatever else answers on the port is not + ours); +- the digest the runtime reports for the model in use is the one stored when it was checked; +- the model asked for is the model in use. Another model that is on this Mac is not started + on the endpoint: switch to it in the window first. + +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, with the coding agents the model can be run with and the +models on the Mac; `spawn_worker local` starts the runtime when it was +stopped (as Start does), then starts the default coding agent (`install.cli`), or with +`local:` (`local:pi`, `local:deepseek`) another one that is ready, 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 or a coding agent 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. + +## The models on this Mac + +The model store can hold several models. **One of them is the model in use**: the one the +endpoint record `local-ollama` lists, the runtime is started for and a session can be +started on. The runtime holds one model in memory at a time (`OLLAMA_MAX_LOADED_MODELS=1`), +so this is what the Mac can really do, and it keeps every rule above: one record, one +model, one stored digest per model. + +The installed screen lists what is on this Mac, the model in use first: + +| A row says | It means | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| In use | The model sessions start on | +| Ready | Codeman downloaded and checked it. **Use** switches to it | +| Not checked | The runtime lists it and Codeman never checked it: a model an earlier Remove kept, or one another program pulled through the runtime's port. It can only be deleted | +| Changed | It was checked, and the runtime now reports another digest under its name. It is never started; delete it and install it again | +| Missing | It was checked, and the runtime no longer lists it. **Delete** drops the entry | + +Each row shows the context the model was checked with and its size on disk, as the runtime +reports it. Under the list is what all downloads take together: the size of the files in +`local-model/models/blobs`, so layers two models share are counted once and an interrupted +download is included. The window asks the runtime for the list at most every five seconds +and measures the store at most every thirty, never on every poll. + +- **Add a model** opens the chooser. A model that is not on this Mac is installed like the + first one, with its sizes confirmed; when the job ends it is the model in use, and the + earlier model stays in the list. A row that is on this Mac already says so and is switched + to, without a download and without a confirm screen. +- **Use** switches to a checked model. It is a job of two steps, `verify` and `connect`: + the runtime is restarted with the context the model was checked with, the model is loaded + and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the + stored one (else the switch fails with `model-changed` before the model is loaded), and + the endpoint record is rewritten. The default coding agent becomes the one the model was + checked with when it is still installed, else the one of the installation, else any other + agent that is on the Mac and can run the model. +- **Delete** removes one model through the runtime's own delete, after one question in the + row. The request carries the model's name, never a path, and the server deletes only a + name of its own listing. The model in use cannot be deleted: switch first, or Remove. It + needs the running runtime, so a stopped model shows no Delete until Start. + +Adding a model and switching are refused while a session uses the model ("Sessions are +still using this model"), like Stop: their coding agents were started with the other model's +name, and the runtime would swap models on every request. + +While a job that started from an installed model runs, or after it stopped (paused or +failed), no session is started: the checks above want a finished installation, and the +refusal says so (`job-unfinished`). The window +then lists the checked models with **Use**, which is the way back to the earlier one. Going +back deletes what the stopped job left of a model that never passed its check, the way a new +install does; a checked model is never deleted that way. + +## Stop, Start, Remove + +- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes + that, and so does a switch to another model or the install of one: the job starts the + runtime, and the model it ends on is in use. +- **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 models (all of them) are deleted only when + asked. The coding agent stays. While a live session uses the model, Remove answers with + the session names instead. +- **Models a Remove kept** are not lost space: with nothing installed the window says how + much they still take and offers "Delete them", and after a new install they show in the + list as "Not checked", each with its own Delete. + +## Where everything is + +All under the data directory (`~/.codeman`, or the instance's own): + +``` +local-model.json the state file (mode 0600), with the list of checked models +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, use, delete a model, remove | +| `failed` | stays failed, same error | refused | resume, install, use, delete a model, remove | +| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, install (add a model), use, delete a model, remove | + +`use` and "delete a model" are listed only while a row of the list offers them, and in +`idle` "remove" is offered only while the model store still holds something. + +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.** Two models were measured 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. +- **A tool call that quotes a tool call can be cut off.** The pinned runtime reads the tool + calls of every Qwen-family model in the catalogue (the Ornith, MiMo and Qwen rows, the pair + we ran included) with one parser, and that parser ends a call at the first closing tag it + sees. An argument that itself contains a whole tool call, such as a prompt handed to a + sub-agent that shows one, arrives cut off and the rest as text (runtime issue 16992, filed + on `ornith:35b`; the fix, runtime pull request 17021, is open). The check after a download + asks for one plain tool call and does not see this. +- **The model stays in memory for ten minutes** after the last request, then the runtime + unloads it. +- **One model runs at a time.** Several can be on disk; the runtime holds one, and a switch + restarts it and loads the other model (seconds to a minute). Close the sessions on the + model first: the window refuses the switch while one is open. +- **The disk figures are the runtime's.** A row's size is what the runtime reports for the + model, and the total is the size of its blob files. Neither was compared with Finder on a + Mac yet. + +## 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` | diff --git a/docs/multi-user-plan.md b/docs/multi-user-plan.md index c8ae1cdc..f9652944 100644 --- a/docs/multi-user-plan.md +++ b/docs/multi-user-plan.md @@ -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 a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest. +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). 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. @@ -161,7 +161,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab | 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//` 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| +| 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`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (`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 | | `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 | diff --git a/docs/omp-integration.md b/docs/omp-integration.md index 5729dfd9..80bf4bc7 100644 --- a/docs/omp-integration.md +++ b/docs/omp-integration.md @@ -55,6 +55,19 @@ documented default `tools.approvalMode` is `yolo`, so an omp pane auto-approves with no flag from Codeman — the CLI's own config, not Codeman, is what would need to change that. +**Approval alerts.** When a session is created (`POST /api/sessions` or +`POST /api/quick-start`, local workspaces only, and only while `workspaceHooksEnabled` +is on), Codeman writes a hook module, `.claude/hooks/pre/codeman-approval.ts`, into the +workspace; omp loads project hooks from that folder. It forwards omp's +`tool_approval_requested` / `tool_approval_resolved` events to `/api/hook-event`, so a +pending approval raises a tab alert, a notification and an Approvals Inbox item. The +item is **alert-only**: answer the dialog in the omp pane (omp's composer keeps taking +typing while the prompt is docked, so Codeman never types an answer for you). The +registry declares this as `capabilities.approvalHookFile`, separate from `hooks`, which +stays `'none'`: the `blocked` wait signal works for omp, `stop` is still a 400. ⚠️ With +omp's default `tools.approvalMode: yolo` omp never asks, so the module only does +anything once that setting is changed. + Env overrides: the `OMP_*` prefix is allowlisted, and per omp's own `docs/environment-variables.md` it is not the narrow surface it looks like. omp reads roughly 40 provider keys from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, @@ -149,7 +162,7 @@ shared nor seeded. ## Remote SSH cases `omp` mode is routed through an interactive login shell -(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not +(`remoteLoginShellCommand('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 +176,13 @@ 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 +- **No idle/completion hook.** The approval hook module (see Approval alerts + above) reports approvals only, so 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. + Without it an omp session that had started a turn never left `busy`. Reporting + end-of-turn from the same module would be the follow-up that earns omp `stop`. - **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 diff --git a/docs/orchestrator-loop-architecture.md b/docs/orchestrator-loop-architecture.md index 1026b53b..aeed92c1 100644 --- a/docs/orchestrator-loop-architecture.md +++ b/docs/orchestrator-loop-architecture.md @@ -363,5 +363,5 @@ The Orchestrator can optionally USE the Ralph Loop internally for phase executio | `src/types/orchestrator.ts` | Type definitions | | `src/prompts/orchestrator.ts` | Prompt templates | | `src/web/routes/orchestrator-routes.ts` | API endpoints | -| `src/web/public/orchestrator-ui.js` | Frontend panel | +| `src/web/public/orchestrator-panel.js` | Frontend panel | | `src/web/ports/orchestrator-port.ts` | Port interface | diff --git a/docs/pi-integration-plan.md b/docs/pi-integration-plan.md index 6c4d165e..c5ae19b1 100644 --- a/docs/pi-integration-plan.md +++ b/docs/pi-integration-plan.md @@ -2,6 +2,12 @@ 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** @@ -29,7 +35,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/----/_.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 | +| 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 | | 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` | @@ -88,6 +94,12 @@ 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 @@ -340,6 +352,7 @@ 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 ` (`-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. @@ -671,7 +684,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 | +| 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 | | 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 | diff --git a/docs/pi-integration.md b/docs/pi-integration.md index 08182ffe..00625f76 100644 --- a/docs/pi-integration.md +++ b/docs/pi-integration.md @@ -88,9 +88,15 @@ 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`.** 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`. +- **`--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. - **`--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. @@ -215,7 +221,7 @@ a Docker case only sees that container's own history. ## Remote SSH cases `pi` mode is routed through an interactive login shell -(`exec "$SHELL" -i -l -c 'pi'`), because sshd's remote-command PATH does not +(`remoteLoginShellCommand('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. diff --git a/docs/ralph-wiggum-guide.md b/docs/ralph-wiggum-guide.md index fd3cbcb7..545f33ba 100644 --- a/docs/ralph-wiggum-guide.md +++ b/docs/ralph-wiggum-guide.md @@ -837,8 +837,6 @@ POST /api/sessions/:id/auto-clear - `src/ralph-tracker.ts` - Core detection engine - `src/ralph-loop.ts` - Task orchestration - `src/respawn-controller.ts` - Session cycling -- `src/spawn-orchestrator.ts` - Autonomous agent lifecycle (uses RalphTracker for completion) -- `src/spawn-detector.ts` - Detects `` tags in terminal output - `src/types.ts` - Type definitions --- diff --git a/docs/releasing.md b/docs/releasing.md index b99092f4..a6aaa640 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -19,15 +19,95 @@ When user says "COM": 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 `). 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@ --notes-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 && 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 · 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`) 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 --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two). -8. **Publish the new version to 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 by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), 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. 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`. Maintainer only (it needs that GitHub account); 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 --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=@ -f title='Codeman : ' -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@/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@`. 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 .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@ --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 ` 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 -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@`, 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 --failed`. + +**If notarization fails.** The `Sign and notarize` job's log names the submission and prints Apple's log for it (`xcrun notarytool log ` 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 --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@ --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= packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app ` (a profile saved with `xcrun notarytool store-credentials`), then `gh release upload codeman@ /Codeman.dmg /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. diff --git a/docs/reliable-input-delivery.md b/docs/reliable-input-delivery.md index 99f65beb..37e154dd 100644 --- a/docs/reliable-input-delivery.md +++ b/docs/reliable-input-delivery.md @@ -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 (line-oriented, never + `PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (one paste plus its own Enter, 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 diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 22045cb8..9623fcd2 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`. | `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). | | `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. | | `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. | -| `RemoteCommandMode` | `Extract` — the modes that can run remotely. | +| `RemoteCommandMode` | `Extract` — the modes that can run remotely. | | `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. | Persistence is two flat JSON arrays in the instance data dir: @@ -91,12 +91,18 @@ builds the command that launches (or **reattaches** to) the remote session: ``` ssh -o BatchMode=yes -t user@host \ - 'tmux -L codeman-remote new-session -A -s codeman-ssh- -c "cd && exec " \; \ + 'tmux -L codeman-remote new-session -A -s codeman-ssh- -c /bin/sh -c \; \ set -t codeman-ssh- status off \; set -t codeman-ssh- mouse off \; \ set -t codeman-ssh- prefix C-q \; set -s escape-time 0 \; \ - set -t codeman-ssh- window-size latest' + set -t codeman-ssh- 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- remain-on-exit failed' ``` +`` is `cd && exec `, 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-`** = attach-if-exists-else-create, so a @@ -108,7 +114,14 @@ 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). Note the + 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 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`. @@ -123,6 +136,45 @@ 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 && { …; }` 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. @@ -166,6 +218,12 @@ 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: @@ -338,6 +396,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr | `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): @@ -407,8 +467,9 @@ 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 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 Edit history store's diffs being the one exception), 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. @@ -419,6 +480,14 @@ 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 diff --git a/docs/respawn-state-machine.md b/docs/respawn-state-machine.md index 07dbc06b..e808b8dd 100644 --- a/docs/respawn-state-machine.md +++ b/docs/respawn-state-machine.md @@ -79,5 +79,5 @@ Uses temp file for prompt to avoid E2BIG errors with large terminal buffers. - `test/respawn-scenarios.md` - Comprehensive test scenarios for edge cases - `test/respawn-test-plan.md` - Test environment architecture and strategies -- `test/respawn-test-utils.ts` - Mock utilities (MockSession, MockAiIdleChecker, MockAiPlanChecker) +- `test/mocks/mock-session.ts` - `MockSession`, the session double the respawn tests drive - `test/respawn-analysis.md` - Code coverage analysis and identified issues diff --git a/docs/security-architecture.md b/docs/security-architecture.md index bf349a55..f97ee389 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -105,7 +105,9 @@ 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. + 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). `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 @@ -149,7 +151,7 @@ by presence in a server‑side map, so they cannot be forged offline. ### Rate limiting / lockout recovery -Failed auth is tracked **per IP**: 10 failures → `429`, with a 15‑minute decay. +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. The QR path has its own separate limiter. The lockout check sits **after** the cookie/credential checks (step 4, not first). @@ -158,6 +160,22 @@ 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. --- @@ -193,8 +211,15 @@ 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 it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly -acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates +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 at the tailnet layer so untrusted clients never reach the loopback port at all. ### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense) @@ -317,6 +342,25 @@ 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 | @@ -458,6 +502,15 @@ 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. --- @@ -533,7 +586,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//cases`, checked BEFORE any disk write. -- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only. +- **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. - **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). @@ -557,8 +610,18 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **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 `/.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:` (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 @@ -569,7 +632,8 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom | `--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) | +| `--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 | | `--https` | Enable TLS (adds HSTS) | | `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation | | `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) | diff --git a/docs/tailscale-installer-plan.md b/docs/tailscale-installer-plan.md index aac7d4df..2322ed09 100644 --- a/docs/tailscale-installer-plan.md +++ b/docs/tailscale-installer-plan.md @@ -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` (`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. | +| 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. | | 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). | diff --git a/docs/tui.md b/docs/tui.md index 9907d5f1..92244f11 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -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: 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 +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 a paste cannot silently run a different command than the one you read. ## Answering approvals @@ -178,8 +178,11 @@ 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 chord still works; it is simply not what you are told to -press. +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`). You do not have to remember any of it. For as long as the attach lasts the pane wears a bar across the top: @@ -190,7 +193,10 @@ 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. With +`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 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. diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 6ec92a2f..7f2bab92 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -30,8 +30,13 @@ 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`, and the `--host` / `--port` / `--https` CLI flags. - Removing or changing the meaning of one of these is breaking. + `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. 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 @@ -62,6 +67,49 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: 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`, `/use`, `/cancel`, +`/resume`, `/runtime` and `/agent`, `DELETE /api/v1/local-model` and +`DELETE /api/v1/local-model/models` (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, its `library`, `store` and +`harnesses` lists, 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, +install, start, stop and remove one, keep a library of installed models to switch +between or delete, and install one more coding agent beside an installed model +([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. + ## Deprecation policy When we need to change a covered surface: diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index ea3da118..a9c5ed96 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -1,9 +1,9 @@ # Agent CLIs -Codeman drives ten run modes: nine agent CLIs plus a plain shell. This page covers picking +Codeman drives twelve run modes: eleven agent CLIs plus a plain shell. This page covers picking one, setting it up, and the differences that actually change how you work. -## The ten modes +## The twelve modes | Mode | CLI | Get it | | -------------------- | ---------------------------- | ---------------------------------------------------------------------- | @@ -16,6 +16,8 @@ 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) | +| **jcode** | `jcode` | [jcode.sh](https://jcode.sh) | | **Terminal / Shell** | your `$SHELL` | Already installed. | Any combination works, including all of them. The run mode is chosen per session from the @@ -50,10 +52,11 @@ 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` and `dsh` are additionally identity-probed rather than trusted by name: +`pi`, `grok`, `omp`, `copilot`, `dsh` and `jcode` 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`) that reports +`grok` has npm squatters, an unrelated npm package is also called `jcode`, 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`, `/api/jcode/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". @@ -69,16 +72,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 and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Antigravity and Grok: generic cues only, unreliable | +| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini, Antigravity and jcode: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable | | Auto-resume when a usage limit resets | Yes | No | -| Plan usage chip | 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 | | 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 | No | +| The bundled agent skill | Yes | Every CLI except Pi and Copilot, in `.agents/skills/` | 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 @@ -107,9 +110,10 @@ The defaults you will care about, all under **App Settings**: **Separate Claude accounts per session.** Set `CLAUDE_CONFIG_DIR` in a session's environment overrides to point it at a different Claude config directory, which is how you run one session on a client's subscription and another on your own. One caveat: a relocated config -directory writes transcripts outside `~/.claude/projects`, which blinds the response viewer, -subagent windows, ultracode panel, and Read My Mind for that session. Symlink `projects` -back into the shared tree to keep them working: +directory writes transcripts outside `~/.claude/projects`, which blinds subagent windows, +the ultracode panel and Read My Mind for that session (the response viewer and the Edit +history panel read the relocated directory). Symlink `projects` back into the shared tree +to keep them working: ```bash ln -s ~/.claude/projects /projects @@ -139,15 +143,31 @@ Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precede 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. -- **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. +- **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. - **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 @@ -184,6 +204,17 @@ Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of 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 `> ` 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. @@ -262,6 +293,95 @@ an OMP session that had started a turn showed as working for good. 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). + +### jcode + +A coding agent written in Rust, installed with `curl -fsSL https://jcode.sh/install | bash` +into `~/.local/bin`. jcode keeps its own logins and provider choice (`jcode login`, `/login`, +`~/.jcode/config.toml`); there is no Codeman-side login, key field, or bypass switch, and Run +sends no jcode config at all, because jcode never asks before running a tool. A session can +be started on a model (`--model`) or resume a jcode session by its id or short name +(`--resume`). + +Four things worth knowing: + +- **Run `jcode` once in a terminal first.** A fresh install opens an onboarding wizard + instead of the prompt for its first few launches, and a prompt typed into that wizard is + lost. Finish or skip it once (Esc), and Codeman sessions open straight at the prompt. +- **All jcode sessions share one background daemon**, which jcode starts the first time and + keeps running after the tab that started it is closed. jcode's tools run inside that + daemon, so they see the daemon's environment, not the tab's. For that reason Codeman does + not inject its agent skill into jcode, and accepts no per-session `JCODE_*` environment + variables: set those in `~/.jcode/config.toml` or in the server's own environment. +- **Read a jcode worker from its terminal.** Codeman cannot read jcode's answers back yet, + so the Response Viewer and `last-response` stay empty for it; `codeman agent read + --tail 3000` (or the terminal tail) shows what it said. +- **Idle detection is the precise kind**: Codeman reads jcode's spinner row above the + prompt, and the wheel or a swipe pages jcode's own transcript. + +Guide: [`docs/jcode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/jcode-integration.md). + ### Terminal / Shell A plain shell in a tmux session. No agent, no hooks, no idle detection. @@ -288,6 +408,8 @@ 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) | +| jcode | none (its tools run in a shared daemon, see [jcode](#jcode)) | 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 @@ -303,7 +425,7 @@ 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** when you prefer that agent or that +- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot, jcode** 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). diff --git a/docs/wiki/Autonomous-Loops.md b/docs/wiki/Autonomous-Loops.md index 54243e33..1823ad2f 100644 --- a/docs/wiki/Autonomous-Loops.md +++ b/docs/wiki/Autonomous-Loops.md @@ -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**, or from the wizard on the welcome screen. +Start it from **Session Options → Ralph / Todo**. | Setting | What it does | | ---------------------- | ------------------------------------------------------------------------ | diff --git a/docs/wiki/Codeman-Skill.md b/docs/wiki/Codeman-Skill.md index dcd57f08..1df61055 100644 --- a/docs/wiki/Codeman-Skill.md +++ b/docs/wiki/Codeman-Skill.md @@ -43,7 +43,11 @@ DeepSeek Harness worker on a local qwen model and a Claude Code worker power on ## Install it -Pick one route. The skill only acts inside a Codeman session, so a global install is safe. +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 | | ------------------ | ------------------------------------------------------------------------------- | ----- | @@ -51,10 +55,13 @@ Pick one route. The skill only acts inside a Codeman session, so a global instal | 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 [--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. Off by default. | +| 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`). +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` @@ -67,9 +74,16 @@ same verbs as shell commands. Codeman-managed copy in that case, whichever CLI's directory it sits in). It never touches a `skills/codeman` you wrote yourself. -Turning the web UI setting back off **does not remove copies it already injected**, because +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 `. +that directory. Remove them per case with `codeman skill uninstall --case `, which +also removes the copy's `.gitignore`. ## Then just ask @@ -83,6 +97,7 @@ These prompts work as written: | "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 @@ -118,13 +133,24 @@ so the skill can wait for "turn finished" and read the answer as clean text. Eve 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. +You can name the coding agent the worker runs with ("a local worker with Pi"), as long as +that agent is installed; the walkthrough is in [Local Models](Local-Models#cloud-and-local-together). + 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 to act and - never guesses an API URL, so a global install costs an unrelated agent session nothing. +- **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. @@ -137,12 +163,28 @@ message a worker directly instead of typing into its terminal. Two things to know: -- Codex's default sandbox blocks network access, even to this machine, so the skill needs - **Bypass approvals and sandbox** (App Settings, Codex). +- 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. + 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. -The skill checks for both 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 @@ -153,6 +195,8 @@ The skill checks for both and says so instead of acting. | [`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. diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md new file mode 100644 index 00000000..127a117a --- /dev/null +++ b/docs/wiki/Config-Backups.md @@ -0,0 +1,257 @@ +# 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` and `lastPickedCase`, `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 `/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 = `/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 `/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". diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 171203b7..bc7f3fc2 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -53,7 +53,9 @@ something other than the checked-out HEAD, or when the tree has uncommitted chan 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. It +`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 @@ -120,7 +122,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. Nine of the ten run modes are not +- Label Claude-only behaviour every time it appears. Eleven of the twelve run modes are not Claude. ## Conduct diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index a54a4720..756e0030 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -13,20 +13,53 @@ 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**): -| How | Result | -| ----------------- | ------------------------------------------------------------------------------------------------------ | -| **Create New** | A fresh `~/codeman-cases/` 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/` 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/` 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/` (or, with **Clone into a custom folder**, into `/`, 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. | 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 + `/projects/` 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/`) + 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`; @@ -59,10 +92,10 @@ 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`, or `shell`. It is chosen at start and does not change afterwards; to +`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `jcode`, 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 +Claude is the reference mode. Eleven of the twelve are not Claude, and a number of Codeman features are Claude-only for structural reasons rather than missing effort: they depend on Claude Code's hook system or on parsing its terminal output. Every such feature is labelled Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place. @@ -71,14 +104,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 ten run modes work in all three locations. A case is docker-backed or +This matters because it is a common source of confusion: Docker is **not** a thirteenth run +mode. All twelve 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 @@ -97,7 +130,10 @@ 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`, or plain `tmux -L codeman attach`. + 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. - **Secrets off the command line.** Environment overrides are injected with socket-scoped `tmux setenv` rather than being visible in the spawn command. @@ -106,14 +142,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 @@ -123,18 +159,19 @@ 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`. | +| 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)). | None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets Codeman to a fresh install without touching your code. @@ -176,21 +213,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, 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, copilot, jcode, 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 ten run modes in detail. +- [Agent CLIs](Agent-CLIs) - the twelve 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. diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index e45f694f..e26cbd01 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -51,10 +51,11 @@ 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. 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. +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. For anything longer than a sentence, put the instructions in a file and make the prompt tell the agent to read it: diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 4c3c503c..5ed206ae 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -27,14 +27,20 @@ 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.** 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. +**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. ## Running a session against one @@ -48,21 +54,26 @@ pointed at. **Which model it uses depends on how many the endpoint has discovered.** With exactly one, the session launches straight away on that model — nothing to choose. With two or more, a -small dialog asks which one to use for this launch before starting the session; the +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 endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately use a different one without changing the saved default. The list is not raw discovery order either: the model llama-swap reports loaded and ready is moved to the top and tagged -**Currently loaded**, and when nothing is loaded, the model you last launched on this harness -and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so -another device starts from its own history). The default model keeps its own **Default** pill -in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your -thumb. +**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. -**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches +**For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches straight onto the endpoint** — no restart, because the endpoint is applied before the session's process ever starts. **Claude still restarts the harness's process in place** — same tab, same conversation (`--resume`) — after a normal native launch, since that restart -is far less jarring for Claude than for the other seven, whose own TUI can fully +is far less jarring for Claude than for the others, 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. @@ -150,20 +161,99 @@ 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** | 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. | +| **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`. | | **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. The walkthrough, +with the coding agents and the cloud plus local setup, is on its own page: +[Local Models](Local-Models). + +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. +- More than one model can be on the Mac, and one of them runs at a time. Once a model is + installed the window lists the models with what each takes on disk: **Add a model** + downloads another one, **Use** switches to a model that is already there (nothing is + downloaded; close the sessions on the model first), and **Delete** removes one, after one + question. The model in use cannot be deleted: switch first, or use **Remove**. +- The coding agent is chosen per session. The window lists every agent the model can be run + with (OpenCode, Claude Code, Pi, DeepSeek Harness) under **Start a session with**, and + installs one that is missing after showing you the command. DeepSeek Harness is set up + from the Run menu first. +- **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 @@ -173,6 +263,10 @@ missing entry is the more current answer. - **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. diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md new file mode 100644 index 00000000..1694a04f --- /dev/null +++ b/docs/wiki/Discord-Bridge.md @@ -0,0 +1,105 @@ +# 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 + `/.codeman-discord/-` and the line the agent sees ends with + `[attached: ]`. 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 ` 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. diff --git a/docs/wiki/Docker-Cases.md b/docs/wiki/Docker-Cases.md index 30812cca..54ea1a62 100644 --- a/docs/wiki/Docker-Cases.md +++ b/docs/wiki/Docker-Cases.md @@ -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 ten run modes work inside a +A docker case is a **location overlay**, not a run mode. All twelve run modes work inside a container. See [Core Concepts](Core-Concepts). ## One-time setup: the base image @@ -26,7 +26,7 @@ A zero exit code proves the layers ran, not that the toolchain works. Verify: ```bash docker run --rm codeman/agent:base bash -lc \ - 'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' + 'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' ``` The image is secret-free. Credentials are delivered at runtime, never baked in, so exports @@ -98,6 +98,10 @@ 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 @@ -116,7 +120,15 @@ as whole directories, because those directories also hold sessions, extensions, installed packages, which can be gigabytes. So in-container Pi and Grok sessions are invisible from the host (`pi -c` and `grok -c` inside a docker case see only that container's history). OMP's `sessions/` is the exception and is shared read-write, because -Codeman reads it host-side for history and resume. +Codeman reads it host-side for history and resume. jcode's credentials are not seeded at all +(`~/.jcode` keeps its OAuth tokens beside its builds, sessions and caches): sign in to jcode +inside the container. + +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 @@ -219,7 +231,8 @@ 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. + the docker host's per-mode command override instead. A `$` in that override expands inside + the container, not on the Codeman machine. - 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 diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 01bcaded..7ad2be5e 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -14,6 +14,10 @@ A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, 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: +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: + ```bash codeman skill install # or: npx skills add Ark0N/Codeman --skill codeman -g ``` @@ -41,23 +45,94 @@ act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"oth ```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 # 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 …` 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`. + 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 --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 @@ -125,11 +200,14 @@ 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 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. +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. 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 diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index eba1037f..fe3ce234 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -23,14 +23,18 @@ stores, or refreshes your credentials. ### Which agent CLIs does it support? -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 +Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, +OMP, GitHub Copilot CLI and jcode, 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. The only network traffic -Codeman itself makes is between your browser and your server. +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)). 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 @@ -175,12 +179,13 @@ 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 -report as non-updatable. +and installs without git report as non-updatable. ### Will updating kill my running sessions? @@ -210,7 +215,9 @@ Yes. Codeman ships an agent skill that lets an agent inside a session drive the 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"*. The web UI setting that injects it into every new session's case is off by default. +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). 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. diff --git a/docs/wiki/Git-Worktrees.md b/docs/wiki/Git-Worktrees.md index b0bda451..da3a060f 100644 --- a/docs/wiki/Git-Worktrees.md +++ b/docs/wiki/Git-Worktrees.md @@ -17,7 +17,7 @@ lists mark a session that runs in one with a `⑂ ` 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 when **Codeman Agent Skill** is on) to the repository's local +`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. @@ -39,6 +39,36 @@ 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/` 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`: diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 0f940586..00d6cd93 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -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 | 37 | Create, link, clone, worktrees, remote and docker cases. | +| Cases | 39 | Create, link, clone, worktrees, remote and docker cases. | | Files | 17 | Preview, edit, raw, attachments, path picker. | | Orchestrator | 10 | Plans and phases. | | Ralph | 9 | Loop control and configuration. | @@ -173,6 +173,10 @@ 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) @@ -185,7 +189,7 @@ curl -s -X POST "$API/api/sessions/$ID/custom-model" -H 'Content-Type: applicati | Limit | Default | | --------------------- | ------------------------------------------ | -| Max sessions | 50 | +| Max sessions | 50 (`CODEMAN_MAX_SESSIONS`) | | Max agent windows | 500 | | Max SSE clients | 100 | | Terminal buffer | 32 MB per session | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index aba46bdf..8e9c74f1 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -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, or OMP inside persistent tmux sessions, streams the real terminal to the browser, and keeps +Grok, DeepSeek Harness, OMP, GitHub Copilot CLI, or jcode 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 ten run modes, their setup, and which features are Claude-only. +- [Agent CLIs](Agent-CLIs) - the twelve 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. @@ -42,7 +42,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. +- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives, reading saved Codex conversations offline. - [HTTP API](HTTP-API) - the envelope, auth, the endpoint map, SSE events. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing back into Codeman. @@ -66,6 +66,7 @@ codeman web # then open http://localhost:3000 | [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? | +| [Local Models](Local-Models) | How do I run a coding model on my own Mac, and use it next to a cloud model? | | [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? | @@ -79,6 +80,7 @@ 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? | @@ -113,6 +115,7 @@ 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? | @@ -127,7 +130,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. Plain shell sessions need none. | +| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI, jcode. 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 diff --git a/docs/wiki/Input-And-Voice.md b/docs/wiki/Input-And-Voice.md index a873a504..d15e7864 100644 --- a/docs/wiki/Input-And-Voice.md +++ b/docs/wiki/Input-And-Voice.md @@ -78,6 +78,26 @@ sequences, so word jumps and history keys keep working. | **Deepgram** | A Deepgram API key. | Nova-3, with automatic silence detection. | | **Web Speech** | Nothing. | Browser-provided, quality varies. | +### Thinking pauses + +In **App Settings → Voice → Stop after silence**, choose 3, 6, 15 or 30 seconds, +or **Manual** to keep listening until you tap the mic again (`Ctrl+Shift+V`). +**Provider default** uses 6 seconds for Claude and 3 seconds for Deepgram or Web +Speech. Final transcript events no longer stop Deepgram or Web Speech immediately: +they wait for the configured silence deadline. The timeout counts from the latest +transcript update. A browser can still end its native recognition run; extended +and Manual dictation restart it while preserving the text and silence deadline. +A silent run that lasts its full length never ends one, so 30 seconds and Manual +survive long pauses. Connection failures, microphone errors, or a recognizer that +keeps ending within a second of starting without hearing anything can still end +a recording. + +On Chrome for Android, live words replace earlier partial results without repeating +them in the input. The browser microphone bars indicate speech activity; streaming +providers show audio levels. After Stop, dictation briefly waits for the final +words before inserting text. Reconnecting preserves the available words and resets +the mic so another recording can start. + ### Dictating through your Claude login Off by default; enable it in **App Settings → Voice**. @@ -125,9 +145,10 @@ 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. -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. +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. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 051116be..ee6b76bb 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -9,10 +9,13 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it | **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. | | **Node.js 22+** | The installer offers to install it if missing. | | **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. | -| **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). | +| **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), [jcode](https://jcode.sh). 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. +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)). ## Route A: the installer (recommended) @@ -22,7 +25,8 @@ 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. +builds it. On a Mac, the [Codeman app](#on-a-mac-the-codeman-app) can run this installer +for you, with no Terminal. 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 @@ -75,6 +79,8 @@ install.sh uninstall # remove (offers to undo a rename it performed) install.sh tailscale # retrofit Tailscale access onto an existing install install.sh name [] # rename this machine on your tailnet (default codeman-) 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 # 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 --`: @@ -91,14 +97,74 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every `--port ` 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 ` 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. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never +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 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 @@ -141,18 +207,103 @@ 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. Copy `docker/.env.example` to `docker/.env`, set -`CODEMAN_PASSWORD`, then: +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: ```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 -storage and networking options, is +Unraid, 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** makes the logo show what your agents are doing: it breathes while +they work, blinks when one waits for you and flashes three times when one needs permission. +Each state can take another style under **View > Apple Logo Blink Styles**. It is off until +you turn it on, and the menu only exists on such a Mac. + +**Codeman > Keep This Mac Awake** stops the Mac from going to sleep while your agents work +on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as +the app is open (**Always**). It is off until you choose, only acts for a Codeman running +on this Mac, needs no password, and ends when the app quits. On a MacBook it pauses on +battery unless you untick **Only When Plugged In**, and **With the Lid Closed** keeps a +closed MacBook awake too, with no password and never on battery. Codeman 1.50.1 installed +a small helper for this behind your administrator password. It is no longer used: +**Remove the Old Lid Helper…** in the same menu takes it away. + +### 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://..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: @@ -168,6 +319,7 @@ Codeman drives CLIs, it does not bundle them. Install at least one: | **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. | | **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). | | **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. | +| **jcode** | `curl -fsSL https://jcode.sh/install \| bash` | Run it once by hand to finish (or skip) its onboarding wizard. | Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or stores your CLI credentials. @@ -191,6 +343,7 @@ 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. | @@ -214,9 +367,10 @@ 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 | 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 | `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. | 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 @@ -229,8 +383,12 @@ install.sh uninstall # installer route npm uninstall -g aicodeman # npm route ``` -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 +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 point at directories you already had, but cases created from scratch have their only copy there. diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 715d02c3..e484147b 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -28,14 +28,13 @@ There are several layers stacked on that: a completion message from the CLI, an output silence, and token stability. **For the other CLIs** it depends on what the CLI tells Codeman. Codex, OpenCode, Gemini -CLI, Pi and OMP 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. Antigravity -and Grok Build declare neither. Codeman calls a new pane of theirs 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 these CLIs may not draw, so their working and idle status is -unreliable. +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. ## The Respawn Controller @@ -102,7 +101,7 @@ 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 +## The prompt cache: readout and keep-warm **Claude only.** On the Respawn tab, above the loop controls. @@ -110,10 +109,19 @@ Claude Code bills a turn against the cached conversation while that cache is war 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 -**Claude only.** A header chip showing live subscription usage, on by default on desktop and -off on phones. +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. 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 @@ -121,7 +129,8 @@ 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. Known limit: sessions inside a Docker case do not feed the chip yet. +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. 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 diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 768085c1..8426daec 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -8,7 +8,7 @@ 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. | +| `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+Tab` | Next session. | | `Alt+[` / `Alt+]` | Previous / next tab. | | `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. | @@ -57,11 +57,12 @@ 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. | +| 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. | ## Rebinding diff --git a/docs/wiki/Local-Models.md b/docs/wiki/Local-Models.md new file mode 100644 index 00000000..c5f4b743 --- /dev/null +++ b/docs/wiki/Local-Models.md @@ -0,0 +1,115 @@ +# Local Models + +Run a coding model on your own Mac, next to the cloud models you already use. Codeman +installs the model, keeps it running, and lets you start sessions on it or hand it work from +a cloud session. No account and no API key: the model runs only on that Mac. + +This is **experimental**, and it needs a Mac with Apple silicon as the machine the Codeman +server runs on. On a machine that is not a Mac the button stays hidden. + +## Install a model + +1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model + on this Mac). +2. The window shows which models fit this Mac. The bar at the top is the memory the Mac can + give a model, filled to what the selected model needs at its peak. A nearly full bar is a + tight fit. +3. Pick a model, and under **Runs with** the coding agent to start it with. +4. Press **Install on this Mac**. The next screen lists every download with its size and + where it goes. Nothing is downloaded before you confirm there. +5. Codeman downloads a model runtime 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, and installs + the coding agent if it is missing. No administrator password is asked for. +6. Press **Start a session**. + +You can close the window while it downloads. **Cancel** pauses the install and **Continue** +picks it up where it stopped. After a Codeman restart an unfinished install waits for your +click. + +## Pick the coding agent + +A model is driven by a coding agent: the program that reads your prompt, calls tools and +edits files. You choose it **per session**. Open the window again and look at **Start a +session with**: one chip per agent the model can be run with. + +| Agent | Pick it when | +| ---------------- | ---------------------------------------------------------------------------------------------- | +| OpenCode | You have no reason to choose. It is the pair Codeman was tested with on a Mac | +| Pi | You want the first answer soon. Its fixed prompt is the smallest by far, which also leaves the most room for your work | +| Claude Code | You want Claude Code's own tools and habits. Its first answer on a local model takes minutes | +| DeepSeek Harness | You already use it and have it set up | + +Select a chip and press **Start a session**. The choice is remembered on that device. + +**A chip that says "Not installed"** is an agent Codeman can install for you. Select it: the +window shows the exact command it would run, and the main button becomes **Install** with +the agent's name. The model stays ready while the installer runs, and you can start a +session with another agent meanwhile. If the install fails, the window says so under the +chip and nothing else changes. + +**A chip that says "Needs setup"** is an agent that needs more than one command. DeepSeek +Harness is the one today: set it up from the **Run** menu first, then it can be started +here. A new DeepSeek Harness also opens its own setup guide in every session until you have +finished it once. + +## More than one model + +Once a model is installed, the window lists **Models on this Mac** with what each takes on +disk, and the total under the list. + +- **Add a model** opens the list of models again and downloads another one. When it is + ready it becomes the model in use; the earlier one stays on the Mac. +- **Use** switches to a model that is already there. Nothing is downloaded: Codeman loads it + and checks it again. +- **Delete** removes one model, after one question that says how much space it frees. + +One model runs at a time. Adding and switching are refused while sessions still use the +current model: close those sessions first. The model in use cannot be deleted: switch first, +or use **Remove**, which takes the runtime and the connection away (and the downloaded +models too, if you tick that). + +## Cloud and local together + +The reason to keep a local model next to a cloud one: the cloud model plans and reviews, the +Mac does the bulk. From a Claude Code session in Codeman, with the +[Codeman skill](Codeman-Skill) (on by default), you can just say it: + +| You say | What happens | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| "Is there a local model on this Mac?" | It reads what is installed, which agents can run it and which models are on the Mac. Read-only | +| "Use the local model to summarize every file in `docs/`, then check its summaries." | A worker on the local model does the reading; the cloud session reads its answer back and reviews it | +| "Spawn a local worker with Pi to draft tests for `src/parser.ts`." | The worker runs the local model through Pi | +| "Start two cloud workers for the API and the UI, and a local one for the changelog." | One mixed fleet: cloud workers side by side, the Mac as a queue | + +The worker sessions appear as tabs in the dashboard and are closed when the work is done. + +Good to know before you rely on it: + +- **One request at a time.** The local model answers one request at a time, so two local + workers take turns. Fan out to the cloud, and give the Mac a queue. +- **Slower than a cloud model**, and the first request after a start loads the model first. +- **Review what comes back.** A local model is good at bulk work and weaker at judgment. + Treat its answer as a first draft. +- **The skill never installs anything.** A model or an agent that is missing is yours to + install in the window; the skill tells you what is missing and stops. +- **While a local worker is open**, the window refuses Stop, Remove and a switch of models, + and names the sessions that are in the way. + +## Good to know + +- **What was tested.** One pair was run on a real Mac: OpenCode with Qwen3.6 35B-A3B on an + M5 Pro with 48 GB. The window marks that pair on a Mac like it. Everything else in the + list is calculated from each model's own figures, and the install checks the model on your + Mac before it lets you start a session. +- **Memory first.** A model that does not fit in memory is not offered. An estimate becomes + a measurement the first time the runtime starts, and the model is judged against that + before it is downloaded. +- **The developer tools.** Coding agents use git and Python. On a Mac without them, macOS + offers to install them the first time an agent needs one. +- **Stop** keeps the runtime from starting with Codeman next time. **Start** undoes that. +- **In multi-user mode** only an admin sees and uses this. + +More detail: [A model that runs on this Mac](Custom-Model-Endpoints#a-model-that-runs-on-this-mac) +for how it relates to your own model servers, and +[`docs/local-models.md`](https://github.com/Ark0N/Codeman/blob/master/docs/local-models.md) +for how it works inside. diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 11a78292..433f77ce 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -37,7 +37,9 @@ 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. +**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. ## The home screen @@ -64,8 +66,9 @@ 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. 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. 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, 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. @@ -131,11 +134,12 @@ 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 link in an agent's +- **Tap a link** in terminal output and it opens in a new tab. Same for a web 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 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 +- **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. - 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, @@ -150,6 +154,12 @@ 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. @@ -177,15 +187,19 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals). ## Reading long answers 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. +last answer as scrollable text instead, with a **More** button for additional context and a +**Copy Markdown** button for its raw source. 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. +enough to fix a typo an agent introduced while you are away from your desk. Its header button +is off on a phone until you turn on **File Viewer** under **App Settings → Header & Panels** +on that phone. ## What is deliberately not on phones - Extra header buttons. New header controls are kept off phones by policy, with a test that - enforces it. + enforces it. The one exception is File Viewer, which you can switch on (see above). - 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. diff --git a/docs/wiki/Multi-User-Mode.md b/docs/wiki/Multi-User-Mode.md index 65d66a10..9b4a6c38 100644 --- a/docs/wiki/Multi-User-Mode.md +++ b/docs/wiki/Multi-User-Mode.md @@ -85,8 +85,10 @@ In the browser this means: 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. **Revoke All** signs a regular user -out of their own logins only, and the tunnel panel lists only their own. +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. @@ -99,6 +101,7 @@ 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. | diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md index ca5976ec..50dad790 100644 --- a/docs/wiki/Notifications-And-Approvals.md +++ b/docs/wiki/Notifications-And-Approvals.md @@ -13,6 +13,7 @@ opening the session. | 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 | @@ -39,8 +40,8 @@ hooks, but its dialogs are distinctive enough to read off the screen, so a Codex 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 and OMP, and unreliable for Antigravity and Grok Build, which -declare nothing Codeman can read it from. +OpenCode, Gemini CLI, Pi, OMP and Antigravity, and unreliable for Grok Build, which +declares nothing Codeman can read it from. ## Window title and OS notifications @@ -66,6 +67,11 @@ 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.** diff --git a/docs/wiki/Quick-Start.md b/docs/wiki/Quick-Start.md index 7e7d7f0a..80579480 100644 --- a/docs/wiki/Quick-Start.md +++ b/docs/wiki/Quick-Start.md @@ -70,6 +70,7 @@ one: | **Grok Build** | xAI's CLI. | | **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. | | **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. | +| **jcode** | Configured through its own `~/.jcode`; no permission prompts. | | **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. | The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index 7627d7b4..49e577cb 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -115,8 +115,10 @@ There is also a toggle in **App Settings → System → Remote access**. **The tunnel refuses to start without a password.** That is on purpose: a public URL with no authentication is a terminal on your machine handed to the internet. Acknowledging the risk -explicitly is possible from the UI toggle, and only from there; the API will not do it for -you. +explicitly is possible from the UI toggle, which asks you every time, or for the whole server +with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` (or `codeman web --allow-unauthenticated-tunnel`). +`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`, which the installer sets for a LAN install without a +password, does not count. ## LAN plus password @@ -239,6 +241,12 @@ minute decay. A valid cookie or a correct password recovers immediately even while an attacker is hammering the same IP, which matters because all tunnel traffic arrives from one loopback address. +Multi-user mode is different: there the per-IP cap is 50, each account allows ten failed +guesses from one IP, and both are checked before the password. Behind a tunnel every client +shares the loopback address, so past 50 failures in total a correct password waits for the +decay for everyone, and past ten failures for one account it waits for that account (the +account still logs in from another IP). A valid cookie keeps working throughout. + ## Terminal alternatives You do not have to use a browser. `codeman tui` is a full-screen session dashboard that diff --git a/docs/wiki/Remote-SSH-Sessions.md b/docs/wiki/Remote-SSH-Sessions.md index d6e63b61..b0c3754a 100644 --- a/docs/wiki/Remote-SSH-Sessions.md +++ b/docs/wiki/Remote-SSH-Sessions.md @@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the remote host. -Like Docker, this is a **location overlay** on a case, not a run mode. All ten run modes +Like Docker, this is a **location overlay** on a case, not a run mode. All twelve run modes work remotely. See [Core Concepts](Core-Concepts). ## Why bother @@ -29,11 +29,18 @@ through a jump host. Your laptop can sleep, change networks, or close, and the r Hosts are saved and reusable, so a second case on the same machine is just a path. Host profiles can also carry per-run-mode launch command overrides, for when the binary lives -somewhere unusual on that host. +somewhere unusual on that host. An override runs on the remote in your login shell, so a `$` +in it expands there, not on the Codeman machine. The remote host needs **tmux**. Codeman probes for it when you link the host rather than failing later at launch. +The remote login shell can be bash, zsh, dash, fish, csh or tcsh. A tcsh session reads `.tcshrc` +and not `.login`, so set the PATH that finds `tmux` and your agent CLI in `.tcshrc`. The tmux check +also needs a `command` executable under tcsh: macOS and the BSDs ship one, Debian and Ubuntu do +not, so linking a tcsh host there stops at `could not verify tmux`. nushell and elvish cannot run a +remote session at all. + ## What actually runs The agent lives inside a dedicated tmux server on the **remote** host, and Codeman fronts it @@ -73,13 +80,19 @@ Attaching to someone else's session and closing your tab must not end their run, not. Several clients can attach the same remote session at different window sizes without clamping each other, and discovery shows a shared badge with the client count. +This list holds Codeman's own sessions. A tmux session someone started by hand on the host is +found by the home screen instead: see +[Opened outside Codeman](The-Dashboard#opened-outside-codeman). + ## Files Previews, downloads and text reads in a remote case go over the same ssh connection the session uses, so a clicked path opens the file on the machine the agent is on, `Range` -seeking included. Nothing is copied to the Codeman host. Editing, Office previews, -thumbnails, the file tree and the tail viewer are not available remotely and answer a clear -400 rather than a misleading 404. Details in [Working With Files](Working-With-Files). +seeking included. Nothing is copied to the Codeman host, apart from the diffs the +[Edit history](Working-With-Files#edit-history) panel keeps, which works for remote Claude +sessions too. Editing, Office previews, thumbnails, the file tree and the tail viewer are not +available remotely and answer a clear 400 rather than a misleading 404. Details in +[Working With Files](Working-With-Files). ## Security diff --git a/docs/wiki/Running-As-A-Service.md b/docs/wiki/Running-As-A-Service.md index 88cc5518..71cf1e80 100644 --- a/docs/wiki/Running-As-A-Service.md +++ b/docs/wiki/Running-As-A-Service.md @@ -145,15 +145,16 @@ log stream --predicate 'process == "node"' # macOS, noisy | Install route | Update with | | ------------- | ------------------------------------------------------------------------ | | Installer | Re-run the one-liner, or **App Settings → System → Updates**. | +| 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 | Re-run `Start-Codeman.sh`, or the in-app updater, which restarts the container in place. | +| Docker Compose | The in-app updater, which restarts the container in place, or `git pull`, then re-run `Start-Codeman.sh`. | ### The in-app updater **App Settings → System → Updates**, for git-clone installs supervised by systemd or -launchd. npm installs report as non-updatable, and an unsupervised install is told to -restart manually. +launchd. npm installs and installs without git report as non-updatable, and an unsupervised +install is told to restart manually. The interesting part is that the update restarts the very process running it. So the real work runs in a **detached script that outlives the restart** and writes progress to a status @@ -188,7 +189,13 @@ the bind-mounted directories with the right owner, honours a `docker-compose.ove and refreshes the build volumes when the checkout moved under them. The in-app updater applies code only and restarts by letting the container exit, so it refuses a release that changes the Dockerfile, the compose file, or adds a new `.env` key, until you re-run the -script. Guide: +script. + +With no `docker/.env` yet, the script first asks for a data folder, a port and a password and +writes that file. It then waits up to 180 seconds for Codeman to answer and exits 1 if the +container crash-loops or never answers; pass `--no-wait` for scripted use. `Start-Codeman.sh` +and `Update-Codeman.sh` both refuse to run while `CODEMAN_PASSWORD` is still the example +`changeme`. Guide: [`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md). ## The tunnel as a service diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index 8dbaaa38..3e631a91 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -20,7 +20,8 @@ In order of how much they matter: 1. **Do not expose it without `CODEMAN_PASSWORD`.** Binding a non-loopback host without one starts, but warns loudly. A tunnel refuses outright unless you acknowledge the exposure - in the UI. + in the UI, which asks every time, or set `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`. The + installer's `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers the LAN bind only. 2. **Prefer Tailscale over a public tunnel.** Keeping the loopback bind and putting a private network in front of it removes the public attack surface entirely, and gives you real HTTPS. See [Remote Access](Remote-Access). @@ -53,7 +54,7 @@ When authentication is enabled: | ------------------- | ------------------------------------------------------------------------------------------------ | | **HTTP Basic** | `CODEMAN_USERNAME` (default `admin`) and `CODEMAN_PASSWORD`. | | **Session cookie** | A 256-bit opaque token validated server side, so it cannot be forged offline. 24 hours, extended on activity, with a device-context audit trail. | -| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. | +| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. In multi-user mode the per-IP cap is 50, and each account allows ten failed guesses from any one IP. Both are checked before the password, so past either a correct password from that IP also waits for the decay (the account still logs in from another IP, and a valid cookie keeps working). From the tenth failed guess on, an account's wrong guesses get a `429` from every IP, which never refuses a correct password. | | **QR auth** | Single-use 60-second tokens with their own separate rate limiter, so a mistyped password cannot lock out QR login. | | **Hook endpoints** | The hook and telemetry endpoints skip Basic auth because they are called from localhost by the CLI, but when auth is on, that bypass additionally requires a per-instance hook secret. | @@ -67,7 +68,7 @@ be wrong for at least one of them: | **File Viewer** | Real path resolution before boundary checks, so symlinks cannot escape. Sensitive trees blocked. Edit mode adds an extension allowlist, a size cap, `.git` denial, and optimistic concurrency. It never creates files. | | **Attachments** | An id-based registry, so browser requests never carry absolute paths. The magic-link scanner is prompt-injectable by nature and is therefore force-confined to the session's workspace. Extension allowlist, not a blocklist. | | **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode a non-admin gets only their own user space, because per-user spaces live inside the home directory. | -| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host; writes, Office previews and thumbnails are refused. | +| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host, apart from the diffs the Edit history panel reads from a remote Claude session's transcripts while it is open, kept in a private bounded store (0600) that is deleted with the session's record. Writes, Office previews and thumbnails are refused. | Downloads block sensitive paths outright (`.env`, credentials files, `~/.ssh`, AWS credentials), and SVG and HTML are served as downloads with `nosniff` so they cannot execute @@ -98,15 +99,33 @@ Stated plainly, because a security page that only lists strengths is not useful: bypass requires a secret unconditionally when auth is on. - **The agent CLIs have their own trust models.** Pi's project trust executes repo-local TypeScript, for instance. See [Agent CLIs](Agent-CLIs). +- **Allow network access takes Codex's sandbox out of the picture.** The setting (App + Settings, Codex, off by default) lets commands in Codex's sandbox call Codeman's API, + which can start a session outside the sandbox with no approval prompt. A password does + not stop this, because a session inherits the server's password. It applies to every new + local Codex session, so a prompt-injected command in any of them can run anything you + can. Leave it off unless you trust what those sessions run. ## Privacy -No telemetry, no analytics, no phone-home. Codeman's only network traffic is between your -browser and your server. Your agent CLI's traffic is its own, on your account. +No telemetry, no analytics, no phone-home. Your agent CLI's traffic is its own, on your +account. Beyond the traffic between your browser and your server, the server itself makes a few +requests for features you use: the Codex usage poll, web push, and webhooks if you set one up. + +The GitHub Copilot quota read behind the plan usage chip's **Copilot** row is **on by default +while Copilot CLI is installed and signed in** (an absent plan-usage setting reads as on, even +for a phone-only user whose chip is hidden). Every 10 minutes the server asks `api.github.com`, +and nothing else, for your premium-request quota, with Copilot's own sign-in token. The token is +never logged and redirects are refused. Turn off **Plan Usage Limits** in App Settings to stop it. Two features send data outward, both off by default and both stated where they appear: voice dictation through your Claude login, and the Read My Mind prediction call. +[Config Backups](Config-Backups) copies some credential-bearing files (`.env`, `users.json`, +`custom-model-hosts.json`, `push-keys.json`, `webhook.json`) into a backup folder. The default folder is inside +the data directory, and every backup is mode `0700`/`0600`, so nothing new is exposed. Only a +folder you choose yourself can move them somewhere less private; admin only in multi-user mode. + ## Reporting a vulnerability **Never in a public issue.** diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 5f657a46..8ffd2532 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -21,7 +21,7 @@ desktop. | Category | Examples | | ----------------------- | ------------------------------------------------------------------------------- | -| **Per-device, local** | Skin, WebGL renderer, local echo, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | +| **Per-device, local** | Skin, WebGL renderer, local echo, keep size under keyboard, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | | **Per-device policy** | Most `show*` toggles, plan usage chip, language. Stored server-side, but a device only takes the server value when it has no local one of its own. | | **Synced** | Models, effort, CLI options, notification preferences, voice settings, display name, the agent skill and approvals toggles. | @@ -38,17 +38,18 @@ as usual and tells them that server-wide settings can only be changed by an admi ### Updates Current version, a manual check, and the in-app updater. Covers git-clone installs -supervised by systemd or launchd; npm installs report as non-updatable. See -[Running As A Service](Running-As-A-Service). +supervised by systemd or launchd; npm installs and installs without git report as +non-updatable. See [Running As A Service](Running-As-A-Service). ### Terminal & Input | Setting | Default | Notes | | ----------------------------- | -------------------- | --------------------------------------------------------------------- | | Local Echo | On for touch devices | Paints keystrokes locally and flushes on Enter. See [Input And Voice](Input-And-Voice). | +| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). | | CJK Input | Off | IME composition through a dedicated text field. | | Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. | -| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. | +| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. Fullscreen pi leaves that buffer empty and has no paging fallback, so with this on the wheel does nothing there. | | Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. | | Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. | | Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. | @@ -61,17 +62,31 @@ supervised by systemd or launchd; npm installs report as non-updatable. See Chips for every optional header control, with a live preview of the resulting header: Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Story Mode, Away Digest, Session -Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor, +Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor, Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents, Ultracode Windows, Cron. +**Host readings** (opt-in, both off by default and per device): **GPU Stats** adds three items after CPU and MEM: GPU load, graphics memory and the GPU clock, +and **CPU MHz** adds the live CPU clock. Both sets of items live inside **System Stats**, so they show only +while that chip is on, and nothing is fetched while it is off. An item appears only when the +host reports it, so a machine with no readable GPU shows nothing extra. Sources: `nvidia-smi` or the amdgpu +sysfs files for the GPU; inside WSL, the Windows performance counters through `typeperf.exe`, which cover +any vendor (an Intel GPU gives load and memory in use, but Windows publishes no clock for it, and its memory +total is taken as half of Windows' RAM, the limit Windows applies to integrated GPUs); and +`scaling_cur_freq`, `/proc/cpuinfo` or, inside WSL, the Windows `Processor Frequency` counter for the CPU +clock. Nothing is read, and `nvidia-smi` and `typeperf.exe` are never run, while both are off. + **Story Mode** (off by default, per device, desktop and tablet) adds a header button that draws the active Claude Code session as a comic: one page per prompt, panels for what Claude did (reading, planning, helpers, edits, tests, saving), and plain captions anyone can follow. -The **Director's cut** switch names every tool call instead. Click a file to open it, the code -page for the exact diff, a test board for the output, a helper for what it was asked and what -it did. An unfinished turn redraws as Claude works, and **Save image** downloads the page as a -PNG to share. On phones, open it from the session's Session Options (the Story button there). +Three reading levels sit in its bar: **Simple** (plain words, for anyone), **Advanced** (the +story with file names) and **Director's cut** (every step and tool, with a list of every event +beside the page), which is the default; your pick is remembered on that device. Click a file to +open it, the code page for the exact diff, a test board for the output, a helper for what it was +asked and what it did. An unfinished turn redraws as Claude works, and **Save image** downloads +the page as a PNG to share. The prompt bar under the page holds the turn picker and a field that +sends Claude your next prompt (Enter sends, Shift+Enter adds a line). On phones, open it from the +session's Session Options (the Story button there). **Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not @@ -82,8 +97,32 @@ by folder** (per device, on by default) shows changed files under collapsed fold window; off lists every file by its full path. **Git status: max repositories** (per device, 1 to 50, default 12) is how many repositories the window lists when a session's folder holds several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how -long one git command may run before that repository is reported as unreadable; raise it for -repositories on a slow network share. +long one git command may run before that repository is reported as unreadable, and the same value +bounds the Git window's Fetch button; raise it for repositories on a slow network share. + +**Git fetch on an interval** (synced, off by default) runs `git fetch` on a timer for the repositories the +Git window lists, so the `behind` number is current without a click, and **Git fetch: how often** (synced, +30 to 3600 seconds, default 300) is the gap between runs for a session that has no override of its own, timed from +the start of a run, so a slow repository does not push the next one out. These are the only Git settings +that are synced rather than per device, because the server's scheduler is what acts on them: kept per +device, the scheduler would read nobody's number. **Session Options → Git** overrides both the switch and +the interval for one session, and that override wins even while the app switch is off; a session that follows +the app shows the app's values and its rows go inert until you turn **Follow the app setting** off. Nothing is +ever fetched for a Docker or remote (SSH) session, and a repository a container can write to is refused +before any git runs. Sessions open on the same folder share one run, and a folder is fetched when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins. Both default to off because `git fetch` +is the one git command the Git window runs that writes (cloning and worktree management write too, but only +when you asked for them at the moment you asked), and the one that can run a repository's own hooks +(`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, and config commands such as +`credential.helper`), and the one that can run later by itself, on a timer. The ssh it runs is BatchMode so +nothing waits for a login, and that is a default, never an override: your own `GIT_SSH_COMMAND`, or the +repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new` +records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. + +**Edit history** (per device, off by default, never on phones) adds a header button for the +active session's edit history: every edit the agent and its subagents made, with its diff, and +images it produced. Claude sessions, local and remote (SSH). Nothing is read until you open the window, and it +stores those diffs (secrets included) under `~/.codeman/edit-history/`; see +[Working With Files](Working-With-Files#edit-history). Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear. **Header Stats Style** picks how the system stats and plan usage are drawn: *Compact* @@ -97,6 +136,21 @@ way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See This section also holds background-agent tracking, including whether to track agents for every session or only the active tab. +**Keep agent inbox across restarts** (under **Cross-session features**, synced, off by +default) saves the pending `codeman agent post` messages to `agent-inbox.json` in the data +dir, readable by you only, so they survive a server restart. Off, the agent inbox lives in +memory and a restart empties it; switching it off deletes the file and keeps what is in +memory. Messages expire 12 hours after they were posted either way. See +[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). + +**Experimental agent coordination** (under **Cross-session features**, synced, off by +default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded, +paged reads of another session's output, and typing a prompt into a local Codex composer +that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP +routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are +experimental and may change in any release. See +[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). + ### Appearance | Setting | Notes | @@ -112,6 +166,7 @@ every session or only the active tab. | Tall Tabs | Taller tab strip. | | CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. | | Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. | +| New Session Button on Tabs | Adds a **+** beside each tab's gear: pick a CLI the tab's case can start and one session starts in that case (or worktree) and named group, leaving the Run button alone. The row menu offers it as *New session here…*. Per device, off by default. See [The Dashboard](The-Dashboard#session-tabs). | | Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. | | Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). | | Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. | @@ -150,6 +205,26 @@ attached. section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints). +**Model on this Mac** is not a setting: it is the state of a model Codeman can run for you +on the Mac the server runs on, with a button that opens the local model window. The same +window opens from the **Run a local model** button on the welcome screen. It shows that +Mac's chip, memory and free disk space, a bar for how much of the memory the selected model +takes, and the models that fit, each with one tag (Best fit, Tight, Slow, Small model, or +"Tested on a Mac like this"), a speed and a download size. **Install on this Mac** asks you +to confirm the sizes once, then downloads a model runtime and the model into +`~/.codeman/local-model`, checks that the model loads and can call a tool, installs the +coding agent if it is missing, and ends on **Start a session**. No account, no API key and +no administrator password. You can close the window while it downloads, cancel and continue +later; after a Codeman restart an unfinished install waits for you to press Continue. Once a +model is installed the window offers Start a session, Stop or Start, and Remove. Installing +switches **Custom model endpoints** on and saves one endpoint named "This Mac"; Remove takes +the endpoint away again and switches the setting back off if nothing else uses it. A Mac +that can only run a small model is told so in plain words, and a Mac with too little memory, +too little free disk space or an Intel processor is told why nothing is offered. The group +is not shown when the server runs on Linux or Windows, and in multi-user mode only an admin +sees it. The "Mac" is always the machine the server runs on, not the device you are reading +on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-on-this-mac). + ### Agents & CLIs | Setting | Notes | @@ -158,12 +233,14 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E | Allowed Tools | The list used by the explicit mode. | | Ralph / Todo Tracker | Enables the Ralph loop surfaces. | | Agent Teams | Experimental teams. Also needs the CLI's own environment flag. | -| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). Off by default. See [The Codeman Skill](Codeman-Skill). | +| Pin tab names on the CLI | Launches every Claude session with `--name` set to its tab name, so Claude Code's prompt label and `/resume` list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Synced, off by default; applies from the next session start or respawn. | +| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi, GitHub Copilot and a plain shell). On by default since 1.50.0; existing installs that stored off are switched on once at the first 1.50.0 start, and turning it off afterwards sticks. The copy ignores itself in git, so your repository never lists it. Off stops new copies but removes none (`codeman skill uninstall --case ` does). See [The Codeman Skill](Codeman-Skill). | | Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. | | Nice priority / value | Runs agent processes at a lower CPU priority. | | Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. | | Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. | | Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. | +| Allow network access | Starts new local Codex sessions (Run menu, Resume and the API; not scheduled jobs) with `sandbox_workspace_write.network_access=true`, so commands in Codex's workspace-write sandbox can use the network. The [Codeman Skill](Codeman-Skill) needs it to reach this server from a Codex session. It applies to every such session, and those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set: see [Security](Security#what-codeman-does-not-protect-against). It applies where Codex runs the folder in workspace-write (a project you trusted in Codex) and does not change Codex's sandbox mode: a folder Codex runs read-only keeps the network closed, and Codex asks for approval per command. Synced, off by default; forced off for users without the privileged-commands grant in multi-user mode. | | Animated status effects | Cosmetic. | | MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. | @@ -175,14 +252,16 @@ how long a desktop notification stays up before Codeman closes it (**Browser notification display time**, default 8; both per device, and your OS may close a desktop notification sooner), the idle threshold that decides when a quiet session counts as needing you, and the server-wide -webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See -[Notifications And Approvals](Notifications-And-Approvals). +webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the +Discord group for the two-way bot (token, IDs, thread and ping options; see +[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals). ### Voice Active provider and the engine behind it, insert mode, language, domain keywords to bias -recognition, the Deepgram API key, and the opt-in switch for transcribing through this -server's Claude login, with its live credential status. See +recognition, **Stop after silence** (provider default, 3, 6, 15 or 30 seconds, or Manual), +the Deepgram API key, and the opt-in switch for transcribing through this server's Claude +login, with its live credential status. See [Input And Voice](Input-And-Voice). ### Shortcuts @@ -191,19 +270,27 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts ### System -`CLAUDE.md` template for new cases, default working directory, the image watcher, and -Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs +`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and +Cloudflare tunnel controls including the tunnel URL. **Sync cases across devices** (under Paths, off by default): Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device only until the next sync, which brings the server's copy back, and says so. Only a case picked in a case picker is followed, not one a quick-start or an agent's spawned worker used. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. A device that already has options for a case keeps them until a device changes that case's options while this is on. The **Diagnostics** group runs `codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office tools with their versions and install hints (admin only in multi-user mode). In multi-user mode, the **Users** administration entry is injected here. +The **Config backups** group controls timestamped copies of your configuration files: an +on/off switch (on by default), the backup folder (default `~/.codeman/backups/config`), how +many backups to keep (2 to 500, default 20) and how many days to keep them (0 to 3650, default +30; 0 means no age limit). A value that is not saved in `settings.json` (a reset file) is whatever +the newest backup in that folder recorded, and the default only when none did, plus **Back up now** and a list with **Restore**. These are +server-side settings, read fresh. Details, naming and restore behaviour are on +[Config Backups](Config-Backups). + ## Session Options Per session, from the tab. | Panel | Contains | | ---------------- | ------------------------------------------------------------------------------------------- | -| **Respawn** | Auto-resume on usage limit, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). | +| **Respawn** | Auto-resume on usage limit, the prompt cache readout and keep-warm switch, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). | | **Session** | Name, working directory, environment overrides, per-tab pop-out override. | | **Ralph / Todo** | Loop configuration, iteration and todo caps, circuit breaker reset. See [Autonomous Loops](Autonomous-Loops). | | **Summary** | What this session has done: tokens, activity, run summary. | @@ -226,8 +313,10 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_GESTURE` | Makes gesture control available to be enabled. | | `CODEMAN_DOCKER_BRIDGE_HOOKS` | Lets in-container hooks reach the host on a loopback bind. | | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | -| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | +| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges binding a non-loopback address with no password. Does not cover the tunnel. | +| `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Lets the Cloudflare tunnel start with no password (`--allow-unauthenticated-tunnel` on `codeman web`). The browser still asks first. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | +| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | | `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. | | `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. | @@ -250,3 +339,4 @@ Some things are configured before the server starts, not in the UI: - [The Dashboard](The-Dashboard) - what each control does once visible. - [Keeping Agents Running](Keeping-Agents-Running) - the Respawn panel in depth. - [Agent CLIs](Agent-CLIs) - model, effort, and permission modes. +- [Config Backups](Config-Backups) - copies of your settings, and how to restore them. diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index e621e14b..59d6e22c 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -77,6 +77,14 @@ An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split headers and the Run menus keep theirs. +**New Session Button on Tabs** (same place, per device, off by default) adds a **+** beside +the tab's gear. It lists the CLIs that tab's case can start, each with its logo and name (a +Docker case lists what its container has), and a pick starts one session in that tab's case, +or the worktree it runs in, and in its named group. The Run button keeps its own mode and case. +Wherever a tab has a row menu (the vertical rail, the sidebar, `Shift+F10` on a grouped row), +the same list is its **New session here…** entry. From a tab outside any named group, the new +session lands in the case's default group if the case has one, as a Run from the toolbar does. + **Status is carried by the dot and the tab's own styling:** | Look | Meaning | @@ -146,9 +154,9 @@ The right side of the header. Almost all of these are off until you enable them | Connection dot | Always on | SSE connection health. Green is connected. | | Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. | | CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. | -| File Viewer | On | Toggles the file browser panel. | +| File Viewer | On, off on phones | Toggles the file browser panel. | | Settings gear | Always on | App Settings. | -| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. | +| Plan usage chip | On, desktop only | Live subscription usage: Claude (needs its telemetry exporter, which the same setting installs), Codex limits and the Copilot monthly quota. | | Session Manager | Off | The full session list, live and historical. | | Approvals bell | Off | Cross-session queue of prompts waiting on a human. Appears only when the count is above zero. Never shown on phones. | | Read My Mind 🧠 | Off | Predicts your next prompt for this case. Claude-only. | @@ -178,6 +186,14 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w Hiding System Stats or Plan Usage still hides them in every style. +The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar +(it wraps onto a second line and the usage readings shed their labels, then their provider names, +then, in the Compact style, their percentages), left to widen it. The GPU, VRAM and clock readings +keep their labels, since their values look like CPU's and MEM's; at the narrowest Compact step a +clock, which has no ring, is hidden. Arrow keys resize it, and +double-click or Home resets it. It stops at the widest reading, the width is kept per device, and it +is not shown on phones, in the sidebar layout, in the vertical rail or in a solo window. + New header controls never appear on phones. Phone layout is deliberately minimal and is covered in [Mobile Guide](Mobile-Guide). @@ -202,6 +218,15 @@ Both wait about 2.5 seconds before appearing, so a deploy that restarts the serv flash a warning at you every time. If the browser reports itself offline, the grace period is skipped. +**The transport readout.** With a session open, the header shows how its terminal is +connected: `WS` (WebSocket, the normal case), `WS…` (connecting or reconnecting) or `HTTP` +(the WebSocket is unavailable and input goes over HTTP). If typed or pasted input is backing +up faster than it is acknowledged, the readout grows a suffix such as `WS · 1.2KB queued`. +That suffix appears only after the backlog has stayed up for about a second, so a paste or a +fast burst that clears at once does not make the pill widen and shrink. (It is part of the +text line, so you see it in the Compact and As before header styles; Tiles hides that line and +shows `WS live`, and phones hide it too.) + There is also a watchdog for the case where the connection stops delivering without erroring. If the server's heartbeat stops arriving, Codeman reconnects on its own rather than sitting on a green dot showing frozen data. @@ -226,10 +251,12 @@ Worth knowing: - **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal. - Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is + Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise + it pages its transcript or scrolls locally. Claude's default inline view keeps its history + in the terminal and scrolls locally. `Shift+Wheel` is always local scrollback. OpenCode's wheel and swipes page its own conversation - (PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen - and leave the terminal no history of its own (an older Codex that draws inline keeps + (PageUp/PageDown), as do untracked fullscreen Codex versions that leave the terminal + no history of its own (an older Codex that draws inline keeps its history in the terminal and scrolls locally). The first small movement of a gesture pages at once, then one page per half screen of travel; one finger on the screen is one gesture however slowly it moves. In a grid tile or the split view's @@ -260,6 +287,39 @@ Two extras depending on the device: - **Phones**: tapping the "C" logo gives a session overview instead: NEEDS YOU first, then current sessions, then past ones. On by default. +### Opened outside Codeman + +A tmux session you started by hand (`tmux new -s work`, then `claude`, or just a shell) shows +up on the home screen under **Opened outside Codeman**, on the desktop and in the phone +overview. **Open** wraps it in a tab, and **Go to tab** on a row means it already has one. + +| Where it runs | How it is found | +| -------------------- | ---------------------------------------------------------------------------- | +| **This machine** | Automatically, every few seconds while the home screen is up. | +| **A container** | The `⟳` button in the block's header scans the containers behind your Docker cases. | +| **Over SSH** | The same button scans your saved remote hosts. | + +The container and SSH scan costs one connection per target, so it runs when you press the +button, and **Rescan** runs it again. + +- **Closing the tab only detaches.** The session keeps running, and the close dialog offers no + kill. Opening one types nothing into it. +- **Codeman did not start it,** so it will not drive it: respawn, Ralph, auto-clear, + auto-compact, auto-resume, custom model endpoints, prompt uploads and the image watcher are + refused, the git panel shows nothing, and Edit history says it is not available. +- **The tab opens on the session's first window,** and the row describes that same window. +- **It joins like a second tmux client.** While the tab is open it can resize a session + someone else is viewing, the way any second client does, and that reverses when it closes. +- **A short line in the block says why a session is missing.** One whose name holds unusual + characters is skipped, and so is one named so that tmux reads the name as something else (it + starts with `=`, `@` or `%`, or is a bare `~`). Rename it with `tmux rename-session`. A tmux + server that stopped answering costs only its own sessions. One case gets no line: a session + named `codeman-*` or `claudeman-*`, or on a Codeman socket, counts as Codeman's own. + +Only sockets in tmux's own folder are scanned, so `tmux -L ` is found and +`tmux -S /custom/path` is not. In multi-user mode the block is for admins, since it shows +everyone's processes. + ## Panels | Panel | Opened from | Covered in | diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index fdfb22e6..6f33d983 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -116,12 +116,14 @@ seconds in. Current versions confirm against the actual screen before believing ### A session is stuck showing busy -For Antigravity and Grok Build the busy and idle status is unreliable: those CLIs expose no -hooks and declare no working line, so after launch Codeman has only generic cues to go on -(Claude's `❯` prompt marker, a braille spinner glyph), which they may not draw. Codex, -OpenCode, Gemini CLI, Pi and OMP get the same screen check Claude does, so a session of -theirs that has genuinely gone quiet will settle. If it never does, interrupt it (`Ctrl+C` -with nothing selected). +For Grok Build the busy and idle status is unreliable: it exposes no hooks and declares no +working line, so after launch Codeman has only generic cues to go on (Claude's `❯` prompt +marker, a braille spinner glyph), which it may not draw. Codex, OpenCode, Gemini CLI, Pi, +OMP and Antigravity get the same screen check Claude does, so a session of theirs that has +genuinely gone quiet will settle. An Antigravity session waiting on a shell-command approval +("Run this command?") shows busy until you answer it: `agy` draws nothing Codeman reads as +"at rest" while that list is up. If a session never settles, interrupt it (`Ctrl+C` with +nothing selected). ### The agent asks about bypass permissions every time @@ -129,6 +131,32 @@ That prompt comes from Claude Code, not Codeman. Codeman's default is to start w permission prompts skipped, which is what the security model is built around. If you would rather it prompted, change **App Settings → Agents & CLIs → Claude → Startup Mode**. +### My settings were reset, or a CLI I enabled is gone + +Something rewrote a file in `~/.codeman/` (a hand edit, a restart that loaded a bad file, a +test run pointed at the real data directory). If [Config Backups](Config-Backups) was on, +which is the default, you have copies: + +1. Open **Settings → System → Config backups** and pick the newest backup from before the + change. The list shows when each was made and what it holds. +2. **Restore** it, reload the page, and restart Codeman if the response's `restartRequired` + list is not empty. + +A reset `settings.json` also drops the backup folder setting. If you use a custom folder, enter +it again and save before looking for the list; until then the list shows the default folder. +After a reinstall of the data directory the old backups are unlisted because the instance has a +new id; see [Config Backups](Config-Backups) for how to make them visible again. + +A backup is never dated earlier than the newest one, so if the clock was once set to a far +future date, every later backup keeps that date. Delete the future-dated backup folder to +return to normal dating. + +Without the UI: the backups are plain folders in `~/.codeman/backups/config/` +(`cfg-YYYYMMDD-HHMMSS`); copy the file you need back over the one in `~/.codeman/`. + +Not covered: `state.json` (cron jobs and respawn presets live there). A backup only exists +from the moment the feature first ran, so a reset that happened before that cannot be undone. + ### Sessions vanished after a reboot Expected. tmux does not survive a reboot, so the sessions are gone. Conversations are not: @@ -159,6 +187,20 @@ CODEMAN_INSTANCE=beta CODEMAN_PORT=5000 codeman web `codeman web -d` and `codeman service install` both refuse to start a second server on one data directory for exactly this reason. +### `Context size has been exceeded` from a local llama.cpp model + +Sessions pointed at a llama.cpp or llama-swap endpoint (see [Custom Model +Endpoints](Custom-Model-Endpoints)) fail with `500 Context size has been exceeded`, usually +when Claude Code runs subagents, or an agent fails with `Prompt is too long` while the main +session carries on. The server runs several parallel slots that share one pool of context, +and every Claude Code request carries about 40K tokens of its own system prompt and tools, so +a few concurrent requests use the pool up. Start the model with a single slot by adding +`--parallel 1` to its launch command, or give it a context large enough that each slot holds +40K tokens. Check the current count with `GET /props` on the server (on llama-swap, +`GET /props?model=` for a loaded model): `total_slots` is the number of slots. `n_ctx` is the +shared context only when the slot count is automatic; with an explicit `--parallel N` it is the +size of each slot. + ## The terminal ### I cannot scroll back through history @@ -176,8 +218,19 @@ Things to try: ### The wheel does nothing in a Codex session -Codex ignores the mouse reports that forwarding would send, so Codeman does not forward -there. Scrolling is local, and `Shift+Wheel` behaves the same way. +Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise +it pages its transcript or scrolls locally, while `Shift+Wheel` always uses local scrollback. + +### The wheel does nothing in a pi session + +Fullscreen pi, the default since pi 1.0.0, scrolls its own transcript, so the wheel is +forwarded to it while it has mouse tracking on. If the wheel does nothing, turn off +**Wheel Scrolls Local History**: that setting keeps the wheel on a local buffer pi leaves +empty, and pi has no paging fallback. `PageUp` and `PageDown` on a keyboard still reach pi. + +Codeman strips pi's mouse enables, so a drag selects text in the browser rather than using +pi's own selection or scrollbar drag. With `tuiMode: regular` pi is inline and the wheel +scrolls local history. ### Selected text is invisible on a light skin @@ -204,6 +257,20 @@ If you are sending input over the API instead, your payload must end with `\r` o is ever sent. The request still succeeds and the text sits unsubmitted in the composer. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). +### Persian, Arabic or Hebrew looks wrong in the terminal + +The terminal is xterm.js, which does not support right-to-left text yet +([xtermjs/xterm.js#701](https://github.com/xtermjs/xterm.js/issues/701)). It puts each +character in its own cell from left to right, so a sentence comes out reversed, and the +default WebGL renderer also draws the letters unjoined. Codeman cannot fix that without +replacing the terminal. + +Read the answer in **Last Response** instead, where it lays out right to left, or open a +Markdown file in the [File Viewer](Working-With-Files). On desktop, turning off **WebGL +Renderer** in **App Settings → Terminal & Input** and reloading joins the letters, so each +word reads correctly, but the words of a sentence still run left to right. Phones already +use that renderer. + ## Mobile ### The keyboard covers the terminal, or scroll position jumps @@ -287,6 +354,21 @@ Start the stack with `bash docker/Start-Codeman.sh` rather than a plain `docker and update: since 1.29.0 the entrypoint corrects a root-owned bind mount before dropping privileges. See [Running As A Service](Running-As-A-Service). +### Compose: `CODEMAN_PASSWORD is still the example value "changeme"` + +`Start-Codeman.sh` and `Update-Codeman.sh` refuse that password before they build or stop +anything, because the container publishes its port on every interface and holds the Docker +socket, so whoever signs in controls Docker on the host. Set a real password in `docker/.env` +and run the script again. + +### Compose: `Codeman did not come up`, or no answer after 180 seconds + +`Start-Codeman.sh` waits for the server after `up -d`. `did not come up` means the container +exited, restarted or went unhealthy, and the script prints its last log lines. `has not +answered after 180 seconds` means it is still running but silent, so on a slow host it may +just need longer: follow it with `docker compose logs -f codeman` from `docker/`. Either way +the script exits 1, and `--no-wait` skips the wait for scripts that check on their own. + ### A remote SSH session dropped and did not come back A bounded-backoff watcher reattaches dropped sessions, and it is on by default. Intentional @@ -311,7 +393,7 @@ On an HTTPS install, add `-k` to the curl commands and use the `https://` URL. Open an [issue](https://github.com/Ark0N/Codeman/issues) with: - OS and version. -- Install method: installer, npm, or git clone. +- Install method: installer (with or without git), npm, or git clone. - `codeman --version`. - Browser and version, if the problem is in the UI. - Which CLI the session was running, and its version. diff --git a/docs/wiki/Watching-Agents-Work.md b/docs/wiki/Watching-Agents-Work.md index e746acf9..c855fe92 100644 --- a/docs/wiki/Watching-Agents-Work.md +++ b/docs/wiki/Watching-Agents-Work.md @@ -77,7 +77,11 @@ Turning on either starts the watcher. **Last Response** (header button, opt-in) renders the agent's last answer as scrollable text rather than terminal output. It exists mostly for phones, where reading a long answer in a -terminal viewport is painful. **More** loads additional context. +terminal viewport is painful. **More** loads additional context. **Copy Markdown** copies the +newest answer's raw Markdown, every part of its turn, so headings, lists and code fences paste +intact (the same answer in both views; tool and status blocks are left out). Persian, Arabic +and Hebrew answers read right to left here, which the terminal cannot do, while code and the +file paths it links stay left to right. ## After the fact @@ -104,9 +108,9 @@ are not reading. - **A session pointed at a relocated Claude config directory goes blind here.** Transcripts written outside `~/.claude/projects` are invisible to the watchers, so subagent windows, - the ultracode panel, the response viewer, and Read My Mind all stop working for that - session. Symlink `projects` back into the shared tree to fix it. See - [Agent CLIs](Agent-CLIs). + the ultracode panel and Read My Mind stop working for that session. Symlink `projects` + back into the shared tree to fix it. The response viewer reads the session's own + directory and keeps working. See [Agent CLIs](Agent-CLIs). - **Closing a window does not cancel the agent.** Nothing on this page controls agents; it observes them. - **Windows are opt-in for ultracode, automatic for subagents.** diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 033e79e0..10aeff5d 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -6,15 +6,33 @@ until you get home. ## The File Viewer -A panel that browses the active session's working directory. Its header button is on by -default; if it is missing, re-enable it in **App Settings → Header & Panels**. +A panel that browses the active session's working directory, or the git worktree its agent +moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header +button is on by default on a desktop and off on a phone. The setting is per device: switch it +in **App Settings → Header & Panels** on the device you want it on. + +The panel and its search list up to 5,000 files and folders per load, shallow levels first and +taking turns between folders, so one huge folder cannot use up that limit before the ones after it, +or the folders inside it, get a turn. They also stop opening folders after reading 250,000 entries, +so a workspace with several enormous folders can still leave later ones unread. When a workspace is +bigger than either limit the status line says `(truncated)`, and an expanded folder the scan did +not finish shows **Not fully scanned** rather than looking empty. A folder that does not answer, +such as a network mount that went away, is skipped rather than waited on and is marked the same way, +so a search can miss what is inside it. The panel's Refresh button tries it again. + +Each file row has a download arrow and a × that deletes the file after you confirm. Both show +when you point at the row, and always on a touch screen. Delete removes one file at a time, +never a folder, and refuses a symbolic link (deleting one from here would remove the file it +points to), anything under `.git`, and the sensitive files the viewer will not serve. It is not +offered for remote (SSH) sessions or for tmux sessions opened from outside Codeman. Deleting +from search results takes the row out of the results. It renders what it can: | Kind | Behaviour | | ------------------------ | ------------------------------------------------------------------------- | | Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. | -| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. | +| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub; for a file in a worktree beside the workspace that the agent is in, they resolve from the top of that worktree). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. Persian, Arabic and Hebrew read right to left, each block following its own first letter, while code stays left to right. The MD pill in the header flips to source. | | Images | Inline. | | Audio and video | Inline with a working scrub bar, because range requests are supported. | | Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. | @@ -27,6 +45,11 @@ stream rather than server memory). Sensitive paths (`.env`, anything matching credentials, `~/.ssh`, AWS credentials) are blocked from download, and SVG and HTML are served as downloads rather than rendered, so they cannot execute in the page. +The preview window has **Back** and **Forward** buttons (also `Alt+←` and `Alt+→`) once you +have opened a second file, so a link into another document is no longer one way. **Expand** +fills the screen and is remembered per device, and the corner grip resizes the window. On a +phone the window is always full screen, so Expand and the grip are hidden there. + Closing the preview pauses and unloads any playing media. A video that keeps playing after you close the panel means you are on an old version. @@ -54,6 +77,11 @@ convenient: - **`.git/` is denied outright.** Hooks are executable code, and a corrupted index looks unrecoverable to someone who wanted to fix a typo. - **Non-UTF-8 content is refused**, verified by a round-trip comparison. +- **Dotenv files are editable, other credential files are not.** `.env`, `.env.local` and + `prod.env` open in the editor like any config file, and a save keeps the file's mode (a + `0600` file stays `0600`) and, where your account may set it, its group, so a `.env` a web + server reads through its group stays readable. `.npmrc`, `credentials.json`, anything under + `.ssh/` and Codeman's own `~/.codeman/.env` stay read-only here. ## Attachments @@ -82,9 +110,13 @@ detected at once would fork ten multi-minute converter processes. File paths in a session are links. That works in two places: -- **In the terminal**, on any absolute path an agent prints. +- **In the terminal**, on any path an agent or a command prints. A relative path (`src/app.ts`, + `../notes.md`) resolves against the directory the session's pane is in now, so after a `cd` + in a shell it opens the file there; until the pane reports a directory, and always for remote + SSH and Docker cases, it resolves against the session's working directory. - **In the response viewer**, where paths are usually written as prose or in backticks. They - render as underlined monospace links. + render as underlined monospace links. A Markdown link whose target is a path + (`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way. Clicking one opens it in the preview: images and PDFs render, video and audio play with a working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a @@ -98,6 +130,8 @@ another checkout. Those are served through the attachment routes rather than the ones, so the same rules apply as to any other attachment: secret trees are blocked, the extension allowlist decides what can be opened, and symlinks are resolved before either check. +One exception: while the agent works in a worktree of the session's repository beside it (see [Git Worktrees](Git-Worktrees)), a file in that worktree opens through the workspace routes like a workspace file, and you can edit it there. Two things differ from a workspace file: a text file there that you click in the terminal opens in the preview, not the live tail viewer, and secret files such as `.env` there are not served. + Outside the workspace the allowlist is images, video, audio, PDF, Office documents, and text files, where "text" is the same list the viewer will let you edit: code, config, logs, csv, markdown. The reasoning is that a session can already `cat` any of those, so the file suffix @@ -117,9 +151,11 @@ can send what it can read to any host, open HTML only from sources you trust, as your own desktop. The **Page** pill in the viewer header switches to the source view (where **Edit** works as for any text file), and the viewer falls back to source on its own when a page cannot be rendered: a remote -(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such -as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it -into a folder of its own). The raw file routes still serve HTML as a download only. +(SSH) case, an HTML file inside a hidden folder (which includes `.claude/worktrees/` after +an agent [moved into a worktree](Git-Worktrees#when-an-agent-moves-into-a-worktree)), or one +sitting directly in a broad folder such as your home folder or `/tmp` (the page may read the +files in its own folder tree, so move it into a folder of its own). The raw file routes still +serve HTML as a download only. Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a one-gigabyte log does not try to paint one. @@ -138,8 +174,8 @@ session uses: one `realpath` plus `stat` probe for the file and the workspace ro streamed `cat` (or a slice of it, so video seeking works). Symlinks are resolved on the host that can resolve them, the size cap applies to the remote size before a byte is requested, and an unreachable host answers 502 rather than pretending the file is missing. Nothing is -ever copied onto the Codeman host, and a same-named local file is never served under a -remote name. +ever copied onto the Codeman host, apart from the diffs the [edit history](#edit-history) +stores, and a same-named local file is never served under a remote name. Not available over ssh, and said so with a 400 instead of a misleading 404: editing in place, Office previews and generated thumbnails (both need the bytes on the server's disk), @@ -148,25 +184,38 @@ their workspace is bind-mounted at the same path. ## The path picker -For choosing a path rather than typing one. It appears in two places: +For choosing a path rather than typing one. It appears in these places: - **Browse** in **Add Case → Link Existing**. +- **Browse** in **Add Case → Create New**, under **Create in a custom folder**. +- **Browse** in **Add Case → Clone Repo**, under **Clone into a custom folder**. +- **Browse** on the folder fields of **Add Case → Docker**. - The **📁 Path** key on the mobile keyboard bar. -It browses one directory at a time and can show hidden entries on request. The current -folder is an editable field: type or paste a path and press Enter (or **Go**) to jump -straight there, and a full file path lands in its folder with that file selected. The -**Sort** control orders each listing by name or by modified time (newest first is the -quick way to the file an agent just wrote), with folders always ahead of files; the -choice is remembered per device. The picker inserts the path into your prompt -**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All** -key clears the unsent prompt, and never sends the agent's `/clear` command. +It browses one directory at a time and can show hidden entries on request (the Docker +**Container Workdir** picker always lists them). The current folder is an editable +field: type or paste a path and press Enter (or **Go**) to jump straight there, and a +full file path lands in its folder with that file selected. The **Sort** control orders +each listing by name or by modified time (newest first is the quick way to the file an +agent just wrote), with folders always ahead of files; the choice is remembered per +device. A **Browse** button fills the field beside it. The **📁 Path** key inserts the +path into your prompt **without** pressing Enter, so nothing is submitted by accident. +Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear` +command. + +In the **Link Existing**, **Create New** (custom folder) and **Clone Repo** (custom folder) +pickers, **+📁** (New folder) makes a folder inside the folder you are viewing and selects +it. Names use letters, numbers, spaces and `_ - . ~` (not starting with a dot or `~`). The +cases folder is refused, since every folder there is already a case: pick a folder outside +it, or make the case with **Add Case → Create New** or **Clone Repo** (no custom folder). This is a separate file-serving surface from the viewer, with its own rules: it allowlists your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a root, because per-user spaces live inside the home directory and a home-directory root would -expose everyone. +expose everyone. The Docker **Container Workdir** picker lists the inside of the container +instead, read-only, so these host rules do not apply to it; it is admin-only in multi-user +mode. ## Files into a session @@ -184,13 +233,15 @@ through server memory. Documents are the office formats (pdf, docx, pptx, xlsx, text family (txt, md, csv, tsv, json, log, yaml, yml, xml). Each is checked on the way in, against its file signature for the binary formats and against its encoding for text (UTF-8, no NUL bytes), so a script renamed to `.pdf` is refused while a `.txt` can hold any text at -all. Files with script or page extensions (sh, py, js, html) are not accepted, since a prompt -upload is something for the agent to read, not something for Codeman to store and serve. A -document keeps a trimmed copy of its original name, letters, digits, dot, dash and underscore -with anything else turned into a dash, so `Q3 report.pdf` is findable as -`paste-