mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-08 16:29:33 +01:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236fecf6b9 | ||
|
|
8a54ded78b | ||
|
|
04c4e43913 | ||
|
|
8fa2884f48 | ||
|
|
9c9245028b | ||
|
|
e63721e945 | ||
|
|
97f5208e0b | ||
|
|
83fb523979 | ||
|
|
ae392a6c85 | ||
|
|
e6ed86771e | ||
|
|
2cd3cbd6a0 | ||
|
|
235db69c1e | ||
|
|
cda4184e42 | ||
|
|
f1cfe61950 | ||
|
|
bc9c3c1639 | ||
|
|
97f10a1033 | ||
|
|
edfc2ed667 | ||
|
|
dd83d34b8e | ||
|
|
46fa954b2e | ||
|
|
989fcd778a | ||
|
|
f60b63c005 | ||
|
|
0c0ada7cdb | ||
|
|
55e0f0e7e4 | ||
|
|
a9e08732c0 | ||
|
|
54c02990ba | ||
|
|
14cf95e6ee | ||
|
|
d358e611d9 | ||
|
|
0a0ebfd274 | ||
|
|
938c213552 | ||
|
|
3d61402250 | ||
|
|
cd2265f8d0 | ||
|
|
df98912596 | ||
|
|
ddcdd5d974 | ||
|
|
5a9de1bc4c | ||
|
|
0aa34da80f | ||
|
|
83576153d0 | ||
|
|
d12bef0833 | ||
|
|
91ca7e54de | ||
|
|
28e8029ce5 | ||
|
|
b913092be3 | ||
|
|
9c64fc128d | ||
|
|
b3f0ba9e29 | ||
|
|
ba9d311cb7 | ||
|
|
569eed2b3e | ||
|
|
ac59cfa9ca | ||
|
|
89d18eb9a2 | ||
|
|
b497748e31 | ||
|
|
34a7812008 | ||
|
|
0c63f76d31 | ||
|
|
8f5d2d9bb0 | ||
|
|
ea23445ad7 | ||
|
|
5c61fc1ea8 | ||
|
|
028bd87494 | ||
|
|
2e61ac9534 | ||
|
|
e19b2c3a54 | ||
|
|
31ce0c88cd | ||
|
|
e8a63ede00 | ||
|
|
2522ac1727 | ||
|
|
2f9cc7aabc | ||
|
|
10db8a4bd9 | ||
|
|
e6c79c8e8c | ||
|
|
98c4a3d386 | ||
|
|
4b349b57f6 | ||
|
|
a7343fe087 | ||
|
|
49e1dda23b | ||
|
|
a192fb7253 | ||
|
|
d5273347c3 | ||
|
|
94912241bc | ||
|
|
1e262b04d1 | ||
|
|
baf9dfa2eb | ||
|
|
7edf1a1c27 | ||
|
|
59af305e84 | ||
|
|
e2359aa0e8 | ||
|
|
8d33f53fd0 | ||
|
|
c9b79097b0 | ||
|
|
91cea50d9c | ||
|
|
5cdd34bddf |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -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
|
||||
@@ -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@<version>,
|
||||
# and it builds the release commit. By hand, once this file is on master:
|
||||
# gh workflow run desktop-dmg.yml # dry run of master: artifacts only
|
||||
# gh workflow run desktop-dmg.yml --ref <branch> # dry run of a branch that carries this file
|
||||
# gh workflow run desktop-dmg.yml -f tag=codeman@1.50.0 # rebuild that tag, replace its Codeman.dmg
|
||||
# Only a run on master (or Release itself) attaches anything; every run leaves
|
||||
# the signed Codeman.dmg as an artifact on its run page. A rebuild takes only a
|
||||
# Codeman release tag whose commit is on master ("Check the tag", in build and
|
||||
# in sign).
|
||||
#
|
||||
# A dispatch runs its ref's copy of this file with the secrets, as every
|
||||
# repository secret reaches anyone with write access. A GitHub Environment
|
||||
# limited to master would not narrow the rebuild path (the job's ref is master
|
||||
# while it runs the tag's scripts) and would refuse the branch dry run above;
|
||||
# the tag check is what keeps a rebuild on master's history.
|
||||
#
|
||||
# Three jobs, so no job that holds a secret runs code from a crate:
|
||||
# - build runs cargo, and with it the build script of every crate in
|
||||
# Cargo.lock. It has a read-only token and nothing else: no secret, no cache.
|
||||
# - sign gets the built app as an artifact and runs only Apple's tools and this
|
||||
# repository's scripts on it. It alone holds the signing and notary secrets.
|
||||
# - attach has the one token that can write to a release, and no secret.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release to attach Codeman.dmg to (codeman@x.y.z); empty attaches nothing
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
secrets:
|
||||
MACOS_CERT_P12:
|
||||
description: The Developer ID Application certificate with its private key, a base64 .p12
|
||||
required: true
|
||||
MACOS_CERT_PASSWORD:
|
||||
description: The .p12's password
|
||||
required: true
|
||||
APPLE_ID:
|
||||
description: Apple ID of the developer team, for the notary service
|
||||
required: true
|
||||
APPLE_APP_PASSWORD:
|
||||
description: An app-specific password for that Apple ID
|
||||
required: true
|
||||
APPLE_TEAM_ID:
|
||||
description: The team ID the certificate belongs to
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to rebuild and attach to, e.g. codeman@1.50.0. Empty: dry run, artifacts only'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# What the macos-26 image and the Mac mini that built every tested DMG both ran
|
||||
# on 2026-11-08. Move it deliberately, together with desktop-ci.yml; Cargo.toml's
|
||||
# rust-version is the floor.
|
||||
RUST_TOOLCHAIN: '1.99.1'
|
||||
# The commit to build: a rebuild by hand builds the tag it attaches to; Release
|
||||
# (a push to master) and a dry run build the commit they ran on.
|
||||
BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' && format('refs/tags/{0}', inputs.tag) || github.sha }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Codeman.app
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.BUILD_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
# A rebuild by hand builds the tag it names, and sign then runs that tag's
|
||||
# scripts with the Developer ID key unlocked. So the tag must be a Codeman
|
||||
# release (another package's tag would put Codeman.dmg on its release), and
|
||||
# its commit must be on master's history, where those scripts were reviewed.
|
||||
# aicodeman@ is the tag a failed rename leaves (Release's retag step).
|
||||
# A tag can move between jobs, so build and sign each check their own
|
||||
# checkout. Inline on purpose: a script from the checkout would be the
|
||||
# tag's own code. Kept the same as the step in sign.
|
||||
- name: Check the tag
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
if ! [[ $TAG =~ $TAG_RE ]]; then
|
||||
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
|
||||
exit 1
|
||||
fi
|
||||
SHA=$(git rev-parse HEAD)
|
||||
# behind: an ancestor of master's tip; identical: the tip itself.
|
||||
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
|
||||
case "$STATUS" in
|
||||
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
|
||||
*)
|
||||
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Rust toolchain
|
||||
run: |
|
||||
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal \
|
||||
--target aarch64-apple-darwin --target x86_64-apple-darwin
|
||||
rustup default "$RUST_TOOLCHAIN"
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
# A release installs Codeman from getcodeman.com/install and master. A test
|
||||
# build bakes another installer in at compile time (option_env! in setup.rs),
|
||||
# so neither variable may reach cargo here, and setup.rs's tests pin the default.
|
||||
- name: Unit tests
|
||||
working-directory: packages/desktop
|
||||
run: |
|
||||
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
|
||||
cargo test --locked
|
||||
|
||||
- name: Bundle Codeman.app (signed ad hoc)
|
||||
working-directory: packages/desktop
|
||||
run: |
|
||||
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH SIGN_IDENTITY
|
||||
./scripts/bundle-macos.sh --universal
|
||||
|
||||
- name: Check the build
|
||||
working-directory: packages/desktop
|
||||
run: |
|
||||
BIN=dist/Codeman.app/Contents/MacOS/codeman-desktop
|
||||
lipo "$BIN" -verify_arch arm64 x86_64
|
||||
codesign --verify --strict dist/Codeman.app
|
||||
strings -a "$BIN" > "$RUNNER_TEMP/strings.txt"
|
||||
if grep -E 'raw\.githubusercontent\.com|test/desktop-' "$RUNNER_TEMP/strings.txt"; then
|
||||
echo "::error::A test installer URL or branch is baked into the app"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'https://getcodeman.com/install' "$RUNNER_TEMP/strings.txt"
|
||||
|
||||
# ditto keeps the bundle's modes, symlinks and extended attributes; an
|
||||
# artifact of the bare folder would not (upload-artifact sets every file to
|
||||
# 644). The artifact is a zip around this zip: download-artifact unpacks the
|
||||
# outer one and leaves the ditto zip as it was.
|
||||
- name: Archive Codeman.app
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/unsigned"
|
||||
ditto -c -k --sequesterRsrc --keepParent packages/desktop/dist/Codeman.app "$RUNNER_TEMP/unsigned/Codeman.app.zip"
|
||||
|
||||
- name: Upload the unsigned app
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codeman-app-unsigned
|
||||
path: ${{ runner.temp }}/unsigned/Codeman.app.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
|
||||
sign:
|
||||
name: Sign and notarize
|
||||
needs: build
|
||||
runs-on: macos-26
|
||||
# Two notary submissions; each usually takes minutes, and sign-macos.sh
|
||||
# gives each at most 45.
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
# For sign-macos.sh, make-dmg.sh and macos/entitlements.plist only. Nothing
|
||||
# here builds or runs code from the app or its crates.
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.BUILD_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
# The same check as in build, on this job's own checkout, before anything
|
||||
# here touches a secret. Kept the same as the step in build.
|
||||
- name: Check the tag
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
if ! [[ $TAG =~ $TAG_RE ]]; then
|
||||
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
|
||||
exit 1
|
||||
fi
|
||||
SHA=$(git rev-parse HEAD)
|
||||
# behind: an ancestor of master's tip; identical: the tip itself.
|
||||
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
|
||||
case "$STATUS" in
|
||||
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
|
||||
*)
|
||||
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Download the unsigned app
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: codeman-app-unsigned
|
||||
path: ${{ runner.temp }}/unsigned
|
||||
|
||||
# The build job ran every crate's build script, so its zip is checked before
|
||||
# this job, which holds the secrets, unpacks it: entries only under
|
||||
# Codeman.app/ (and the __MACOSX/ folder where ditto keeps attributes), no
|
||||
# "..", and no symlink, which the app never has and which could point an
|
||||
# unpack or a later write outside the app.
|
||||
- name: Unpack Codeman.app
|
||||
run: |
|
||||
ZIP="$RUNNER_TEMP/unsigned/Codeman.app.zip"
|
||||
ENTRIES=$(zipinfo -1 "$ZIP")
|
||||
LISTING=$(zipinfo "$ZIP")
|
||||
if printf '%s\n' "$ENTRIES" | grep -vE '^(Codeman\.app|__MACOSX)/' \
|
||||
|| printf '%s\n' "$ENTRIES" | grep -E '(^|/)\.\.(/|$)' \
|
||||
|| printf '%s\n' "$LISTING" | grep '^l'; then
|
||||
echo "::error::The build artifact holds an entry outside Codeman.app, a '..' or a symlink"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$RUNNER_TEMP/app"
|
||||
ditto -x -k "$ZIP" "$RUNNER_TEMP/app"
|
||||
if [ -n "$(find "$RUNNER_TEMP/app" -type l)" ]; then
|
||||
echo "::error::The unpacked app holds a symlink"
|
||||
exit 1
|
||||
fi
|
||||
test -x "$RUNNER_TEMP/app/Codeman.app/Contents/MacOS/codeman-desktop"
|
||||
|
||||
# A keychain of its own under RUNNER_TEMP, with a random password, holds the
|
||||
# Developer ID key for the length of this job. The last step deletes it and
|
||||
# the decoded .p12, whatever happened before. All five secrets are checked
|
||||
# here, so a missing one stops the job before any work.
|
||||
- name: Signing keychain
|
||||
env:
|
||||
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
|
||||
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
for name in MACOS_CERT_P12 MACOS_CERT_PASSWORD APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::The repository secret $name is empty or not set"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
|
||||
P12="$RUNNER_TEMP/codeman-signing.p12"
|
||||
trap 'rm -f "$P12"' EXIT
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -hex 32)
|
||||
echo "::add-mask::$KEYCHAIN_PASSWORD"
|
||||
|
||||
# Line breaks out first: an encoder that wraps its output (GNU base64
|
||||
# without -w0) leaves some that macOS's base64 refuses.
|
||||
printf '%s' "$MACOS_CERT_P12" | tr -d '\r\n ' | base64 --decode > "$P12"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
# Locks after six hours, not after the default five idle minutes, which
|
||||
# a notarization wait can outlast.
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security import "$P12" -k "$KEYCHAIN" -f pkcs12 -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
|
||||
rm -f "$P12"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
|
||||
# First on the search list, ahead of the runner's own keychains.
|
||||
SEARCH=()
|
||||
while IFS= read -r line; do
|
||||
line=${line#*\"}
|
||||
line=${line%\"*}
|
||||
if [ -n "$line" ]; then SEARCH+=("$line"); fi
|
||||
done < <(security list-keychains -d user)
|
||||
security list-keychains -d user -s "$KEYCHAIN" "${SEARCH[@]}"
|
||||
|
||||
# The identity by its SHA-1 hash, which no other certificate can match.
|
||||
IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN")
|
||||
MATCHES=$(printf '%s\n' "$IDENTITIES" | grep '"Developer ID Application: ' || true)
|
||||
COUNT=$(printf '%s' "$MATCHES" | grep -c . || true)
|
||||
if [ "$COUNT" != 1 ]; then
|
||||
printf '%s\n' "$IDENTITIES"
|
||||
echo "::error::MACOS_CERT_P12 holds $COUNT valid Developer ID Application identities; it needs exactly one"
|
||||
exit 1
|
||||
fi
|
||||
HASH=$(printf '%s\n' "$MATCHES" | awk '{ print $2 }')
|
||||
IDENTITY=$(printf '%s\n' "$MATCHES" | sed 's/^[^"]*"\(.*\)"$/\1/')
|
||||
case "$IDENTITY" in
|
||||
*"($APPLE_TEAM_ID)") ;;
|
||||
*)
|
||||
echo "::error::The certificate in MACOS_CERT_P12 ($IDENTITY) is not one of team APPLE_TEAM_ID"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Signing as $IDENTITY ($HASH)"
|
||||
echo "SIGN_IDENTITY=$HASH" >> "$GITHUB_ENV"
|
||||
|
||||
# The notary login goes to notarytool on its command line (sign-macos.sh's
|
||||
# APPLE_ID path), in this job only, which already holds the signing key.
|
||||
- name: Sign, notarize and staple
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packages/desktop/scripts/sign-macos.sh "$RUNNER_TEMP/app/Codeman.app" "$RUNNER_TEMP/out"
|
||||
|
||||
# A dry run's result: codeman-dmg on the run page (gh run download <run-id>
|
||||
# -n codeman-dmg). attach takes it from here too.
|
||||
- name: Upload Codeman.dmg
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codeman-dmg
|
||||
path: |
|
||||
${{ runner.temp }}/out/Codeman.dmg
|
||||
${{ runner.temp }}/out/Codeman.dmg.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
|
||||
- name: Remove the signing keychain
|
||||
if: always()
|
||||
run: |
|
||||
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
|
||||
if [ -e "$KEYCHAIN" ]; then
|
||||
security delete-keychain "$KEYCHAIN" || true
|
||||
fi
|
||||
rm -f "$KEYCHAIN" "$RUNNER_TEMP/codeman-signing.p12"
|
||||
|
||||
attach:
|
||||
name: Attach to the release
|
||||
needs: sign
|
||||
# Release runs on pushes to master, so github.ref is master both there and for
|
||||
# a rebuild started by hand on master. A dry run from any other branch, or a
|
||||
# fork, attaches nothing.
|
||||
if: inputs.tag != '' && github.ref == 'refs/heads/master' && github.repository == 'Ark0N/Codeman'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download Codeman.dmg
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: codeman-dmg
|
||||
path: out
|
||||
|
||||
- name: Upload to the release
|
||||
id: upload
|
||||
working-directory: out
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REQUESTED_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
sha256sum -c Codeman.dmg.sha256
|
||||
TAG=$REQUESTED_TAG
|
||||
# Release's retag step ignores its own errors, so a release whose rename to
|
||||
# codeman@ failed still carries the tag changesets gave it.
|
||||
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
|
||||
case "$TAG" in
|
||||
codeman@*) TAG="aicodeman@${TAG#codeman@}" ;;
|
||||
esac
|
||||
gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null
|
||||
echo "::warning::Attaching to $TAG: there is no release $REQUESTED_TAG"
|
||||
fi
|
||||
gh release upload "$TAG" Codeman.dmg Codeman.dmg.sha256 --clobber -R "$GITHUB_REPOSITORY"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check the download links
|
||||
working-directory: out
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.upload.outputs.tag }}
|
||||
run: |
|
||||
EXPECTED=$(cut -d' ' -f1 Codeman.dmg.sha256)
|
||||
check() {
|
||||
curl -fsSL --retry 6 --retry-delay 10 --retry-all-errors -o "$RUNNER_TEMP/served.dmg" "$1"
|
||||
echo "$EXPECTED $RUNNER_TEMP/served.dmg" | sha256sum -c --quiet -
|
||||
echo "$1 serves this build"
|
||||
}
|
||||
check "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/Codeman.dmg"
|
||||
LATEST=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
|
||||
if [ "$LATEST" = "$TAG" ]; then
|
||||
check "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/Codeman.dmg"
|
||||
else
|
||||
echo "::warning::Latest is $LATEST, so releases/latest/download/Codeman.dmg does not serve $TAG"
|
||||
fi
|
||||
@@ -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
|
||||
@@ -16,6 +16,9 @@ jobs:
|
||||
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
|
||||
@@ -89,6 +92,38 @@ jobs:
|
||||
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:
|
||||
@@ -99,3 +134,29 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/publish-mcp.yml
|
||||
|
||||
# Builds, signs and notarizes the Mac app and attaches Codeman.dmg to the
|
||||
# release (.github/workflows/desktop-dmg.yml), only when Codeman itself was
|
||||
# published. Its own job, so a failed DMG shows as itself and never touches
|
||||
# npm, the tag or the MCP publish; `gh workflow run desktop-dmg.yml -f
|
||||
# tag=codeman@<version>` reruns it. It gets the five secrets it uses and no
|
||||
# other: never `secrets: inherit`, which would hand it NPM_TOKEN.
|
||||
macos-app:
|
||||
name: macOS app
|
||||
needs: release
|
||||
if: needs.release.outputs.codeman_version != ''
|
||||
# The ceiling for the called jobs; only its attach job asks for write. A job in
|
||||
# desktop-dmg.yml that asks for a permission not granted here (id-token or
|
||||
# attestations for a provenance attestation, say) makes GitHub refuse this
|
||||
# whole Release run at startup, npm publish included: widen both together.
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/desktop-dmg.yml
|
||||
with:
|
||||
tag: codeman@${{ needs.release.outputs.codeman_version }}
|
||||
secrets:
|
||||
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
|
||||
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
@@ -1,5 +1,42 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.50.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ### Thanks
|
||||
- @irisitymichaelgrundberg for the Files panel following an agent into a worktree outside the session's folder, and for the rules that keep that second folder narrow (#780).
|
||||
- @Randalix for keeping two tests independent of the host's locale and git ignore file (#776).
|
||||
|
||||
**Codeman for Mac (#763).** A small native macOS app opens Codeman in its own window: it opens the Codeman running on this Mac, starts it when it is stopped, installs it when it is missing, or connects to Codeman on another machine, with that password kept in the Keychain. It is one universal app for Apple Silicon and Intel on macOS 11.3 or later, and a client of the web UI like a browser tab, so the server and the API do not change for it. Every release from this one carries it as `Codeman.dmg`, signed with a Developer ID and notarized, so it opens after macOS's usual question about a downloaded app ([1.50.0's copy](https://github.com/Ark0N/Codeman/releases/download/codeman@1.50.0/Codeman.dmg)). The app keeps its own version (0.1.0) and does not update itself yet.
|
||||
|
||||
**Your MacBook's Apple logo blinks with your agents (#781).** On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, _View > Blink Apple Logo_ gives each agent state its own rhythm: one for _While Working_, one for _Waiting for You_ (a yellow tab) and one for _Needs Permission_ (a red tab), each picked from Calm, Slow, Wink, Heartbeat, Fast and Alarm. Set Heartbeat for work, Wink for a finished turn and Alarm (three sharp flashes, then a rest) for a permission dialog, and the lid tells you from across the room which one it is. All three are off until you choose one, the menu shows only on a Mac with that logo, and no style flashes more than three times a second. It uses a private macOS setting, so a macOS update can take the menu away.
|
||||
|
||||
**Install from the app, with no Terminal and no password (#773).** On a Mac without Codeman, the app runs the installer's no-admin route in its own window, shows the steps as a checklist and opens Codeman once it answers. If the Mac has no coding agent it offers one, and _Add a coding agent_ (also in the app menu) adds one later without touching a running Codeman. The This Mac tab now lists every Codeman of this user already running on the Mac. The install needs macOS 15 or a tmux that is already there; on an older Mac the app offers Terminal instead. For this, `install.sh` gained `preflight`, `agent <id>`, `--ai-cli` and `--progress`.
|
||||
|
||||
**The Files panel follows a worktree outside the session's folder (#780).** Since 1.49.0 the panel follows an agent into a worktree inside the session's folder. It now also follows one of the same repository beside it, such as `git worktree add ../feature`, when the session's own repository vouches for it, and files there preview, edit and open from terminal links like workspace files. It follows none while attachment workspace confinement is on, and none at or around a blocked or sensitive folder, in a Docker case workspace, at the home folder or above, or outside the owner's space in multi-user mode. The API stays compatible: `liveWorktree` and `root=live` keep their 1.49.0 meaning, and an outside worktree comes as the new `outsideWorktree` field, listed by `root=live-any` with absolute paths.
|
||||
|
||||
**A coding model that runs on your Mac (#784, experimental).** On an Apple silicon Mac, **Run a local model** on the welcome screen opens a window that shows which local coding model fits the machine and with which agent, installs a pinned Ollama runtime and the model, checks it, and ends on an agent session that runs on it through a Custom Model Endpoint. The catalogue covers every Apple silicon memory size from 8 to 512 GB, and every model carries memory figures of its own: a 9B coding model (labelled limited) from 18 GB, Qwen3.8 27B or Qwen3.6 35B-A3B from 36 GB, Ornith 1.5 122B from 128 GB, while 8 and 16 GB get a plain sentence on why nothing fits. _Recommended_ appears only for a pairing run on a Mac like yours, so far Qwen3.6 35B-A3B with OpenCode on an M5 Pro with 48 GB; every other figure is computed from the model's architecture and shown as an estimate. No account or API key is needed, and the session's prompts go to the model on this Mac rather than a cloud provider. On any other machine nothing is offered, and in multi-user mode only an admin can install. A weekly check in CI compares every catalogue model and the pinned runtime with what a Mac would download and opens one issue when something moved. The routes and their payloads may change in any release; the guide is `docs/local-models.md`.
|
||||
|
||||
**Claude in the cloud, a model on your Mac (#784, experimental).** The codeman skill can hand work to that local model: `local_model` says what is installed and whether it runs, `spawn_worker <name> local` starts a worker on it (and the runtime, when it is stopped), and `sendmark` gives the worker a task and reads back the answer it writes. A Claude Code session plans and reviews while the Mac does the bulk: summaries, first drafts, files that should stay on the machine.
|
||||
|
||||
**Ralph starts from Session Options (#783).** The start screen no longer has a Start Ralph Loop button. Behaviour change: start a Ralph loop from Session Options, Ralph / Todo. The wizard that button opened can no longer be reached.
|
||||
|
||||
**The Codeman agent skill is on by default (#787).** New sessions get the skill where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; none for Pi, GitHub Copilot or a plain shell), so an agent can start and drive workers without an install step. The copy ignores itself in git (a `.gitignore` of `*` inside it, written once and never overwritten), so it never shows up as a new file in your repository, and a symlinked `reference/` folder inside it is refused. **Behaviour change:** every App Settings save on 1.49.0 or earlier stored this switch as off whether or not you touched it, so the first start of 1.50.0 switches a stored off on, once per data directory, and the startup log says when it did. Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**: an off set after that first start stays off. Reload any tab left open from before the upgrade before you save App Settings there. Turning it off removes no copy already written; `codeman skill uninstall --case <name>` does.
|
||||
|
||||
**Fixes.**
|
||||
- **No developer tools window on a Mac without Xcode (#779).** There `/usr/bin/git` is Apple's placeholder, and the server ran it on the first page load, so a 1.49.0 no-admin install opened macOS's "install the command line developer tools" window right after it finished, and again after every server start. Every git the server starts now checks first and treats that placeholder as no git, and installing the tools later needs no restart.
|
||||
- **A new agent shows up as soon as the installer adds it (#778).** A running Codeman remembered a missing CLI for up to 5 minutes, so Run still said it was not installed after `install.sh agent <id>` or the app had added it. The installer now touches `~/.codeman/cli-installed`, and the server looks again when that file changes.
|
||||
- **The approvals list leaves a brand-new dialog alone (#782).** In a dialog's first 3 seconds, before Claude Code has drawn it, `GET /api/approvals` could resolve it, which cleared its red tab on every device and left the push notification's Approve button with nothing to answer. The list now waits as long as the inbox does.
|
||||
- **Custom model endpoints survive concurrent writes.** The endpoint routes and the 5-minute discovery sweep wrote `custom-model-hosts.json` through one shared temp file, so two at once could tear it or drop a record. Every write is serialized now, and a file that does not parse is refused with 409 instead of replaced. Behaviour change: the endpoint id `local-ollama` is reserved for the model Codeman runs on this Mac.
|
||||
- **Clearing a custom endpoint relaunches on the session's own model.** A selection no longer writes its launch model into the session's stored CLI config, so a cleared session starts on the model it was created with, not on the endpoint's model id against the CLI's own backend. Only API callers could reach this.
|
||||
|
||||
**Fixes applied while landing.** An adopted session never follows a worktree outside its folder, like a remote or Docker session (#780). The worktree test also keeps the system gitconfig away from the code under test (#776).
|
||||
|
||||
The Release workflow now builds, signs, notarizes and attaches `Codeman.dmg` (`.github/workflows/desktop-dmg.yml`, #785), publishes each version to the official MCP registry (`io.github.Ark0N/codeman`) with GitHub's OIDC token, and rebuilds the GitHub release page from this changelog. Pull requests that touch `packages/desktop` run `cargo fmt`, clippy and `cargo test` on macOS.
|
||||
|
||||
**Docs and tests.** The README, the Chinese README and the wiki's Installation page gained a section on the Mac app (#786), the Autonomous Loops page no longer points at the removed wizard, and `docs/local-models.md` covers the local model install. Two tests no longer depend on the host's locale or git ignore file (#776).
|
||||
|
||||
## 1.49.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
|
||||
|
||||
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
|
||||
|
||||
**Version**: 1.49.0 (must match `package.json`)
|
||||
**Version**: 1.50.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -128,7 +128,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case <name>` removes copies). ⚠️ Existing installs that stored off are switched on once at the first 1.50.0 start (every Save up to 1.49.0 wrote `false`); turning it off afterwards sticks. That is `migrateAgentSkillDefault()` (`web/agent-skill-default-migration.ts`, from `WebServer.start()` before the first settings read and before listen, skipped in test mode, through `mutateSettingsFile()` only): it stores `true`, never deletes the key (the page's merge keeps a device's own `false` for a key the server lacks, and the next Save would write it back; the page also drops its copy when the server has no key), and its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir, never a settings key (so no Save sends or drops it, the `.strict()` schema never sees it, and a config-backup restore leaves it alone). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
@@ -192,7 +192,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
||||
|
||||
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaveLine truncated
|
||||
|
||||
@@ -235,17 +235,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, or against a followed worktree for an absolute path inside it, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. The one exception is an absolute path inside a followed worktree outside `workingDir` (`SessionState.outsideWorktree`), which the file routes serve as a second root (see Files panel in a worktree). An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
|
||||
|
||||
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ The security argument has two halves. The lookup only ever answers a folder strictly below `workingDir`, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#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's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. That is the lookup for a pane inside `workingDir`; a pane outside it goes to `findOutsideWorktree()`, described below. ⚠️ The security argument has two halves. For a worktree inside `workingDir` the lookup only answers a folder strictly below it, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make an INSIDE `liveWorktree` a confinement root. ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. The one exception is a listing without `root=live-any`, which omits an outside worktree; while the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `outside: true` and published on the wire as `SessionState.outsideWorktree`, never in `liveWorktree`, which stays inside-only: the session's own repository must vouch for it by FILE READS (`workingDir` must be the top of its repository or worktree, so a session in a subfolder follows nothing outside it; the candidate's `.git` FILE names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back, or its `.git` FOLDER is the session's common dir itself, which is the main worktree for a session started in a linked one, and a symlinked `.git` is refused; never spawn git from the pane watcher). It is refused at `/`, home or above, around `workingDir` and at/inside/around a Docker case workspace (an unreadable Docker list follows nothing). It is refused while attachment workspace confinement is on. It is also refused at, inside or around an attachment blocked tree, and at a root that is itself a sensitive folder such as `~/.ssh` (a child probe, since the sensitive patterns describe file locations). These rules are judged when the pane moves like the Docker rule, and multi-user mode applies `folderAllowedForUser`. The routes also re-check confinement and the blocked trees on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. ⚠️ `validateLocalSessionFilePath()` also refuses any file there that `isSensitivePath()` blocklists, so a root such as a credential folder can never serve its keys. Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it (`validateLocalSessionFilePath()`, route-helpers.ts), while a relative path still resolves against `workingDir` alone. ⚠️ It is recorded as a REAL path, and every request re-checks that the folder still resolves to exactly that path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), or a symlink swapped in would re-root the routes anywhere. Tests: `test/live-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
@@ -328,7 +328,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ codeman web
|
||||
|
||||
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
||||
|
||||
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
|
||||
|
||||
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
|
||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||
@@ -97,6 +99,21 @@ bash docker/Start-Codeman.sh
|
||||
|
||||
The first run asks three questions (data folder, port, password; Enter takes the default, including a generated password), writes `docker/.env` for you, builds the image and ends on the URL once Codeman answers. The image already includes Claude Code, Codex, Gemini CLI and OpenCode. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. To update, use **App Settings → Updates**, or `git pull` and run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for Unraid, direct Compose commands, storage and networking options.
|
||||
|
||||
### On a Mac: the Codeman app
|
||||
|
||||
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
|
||||
|
||||
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
||||
|
||||
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
|
||||
|
||||
- **Quitting the app doesn't stop Codeman.** An install from the app runs Codeman as a LaunchAgent that starts at login, so your agents keep working with the window closed, and your browser and phone reach it as before.
|
||||
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later installed. On an older WebKit the app says so and does not connect.
|
||||
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
|
||||
- **Updating.** The app doesn't update itself yet: download the new `Codeman.dmg` and drag it over the old copy.
|
||||
|
||||
First launch, known limits and how to build it yourself: [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app) in the wiki and [packages/desktop/README.md](packages/desktop/README.md).
|
||||
|
||||
<details>
|
||||
<summary><strong>Keep it running in the background</strong></summary>
|
||||
|
||||
@@ -475,6 +492,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
|
||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
|
||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
@@ -775,13 +793,15 @@ Everything in this section also ships as an **agent skill**, [`skills/codeman`](
|
||||
|
||||
#### Step 1: install it
|
||||
|
||||
Usually there is nothing to install: since 1.50.0 every new session gets the skill in its folder, where its CLI looks for skills (the web UI row below, on by default). The copy ignores itself in git, so your repository never lists it as a new file. The other routes are for pi, or for a copy that does not depend on that setting.
|
||||
|
||||
| How | Command | Scope |
|
||||
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`). |
|
||||
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, default off) |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, **on by default** since 1.50.0; a Claude Code that also has the plugin lists the skill twice, both work, so drop one if the duplicate bothers you) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every Codeman-managed copy in that case, whichever CLI's directory it sits in), and never touches a `skills/codeman` you wrote yourself.
|
||||
|
||||
@@ -795,6 +815,7 @@ That is the entire interface. No curl, no endpoint names, no session ids. These
|
||||
| _"Start a shell worker on the `myapp` case, run the test suite, tell me if it passes."_ | Spawns, waits on a split completion marker, reads back the exit code, cleans up. |
|
||||
| _"Spin up 3 workers for lint, typecheck and tests. Run them in parallel, report failures."_ | The fan-out flow: one session per task, all started first, then gathered as each finishes. |
|
||||
| _"Have a claude worker on `refactor-auth` summarize `src/session.ts`, then close it."_ | Spawns, runs the readiness ladder (first-run trust dialog included), send-and-wait, reads the clean transcript answer, deletes. |
|
||||
| _"Use the local model to summarize every file in `docs/`, then check its summaries."_ | Starts a worker on the model Codeman installed on this Mac, gives it the files, reads its answer back and reviews it in the cloud. Says why instead when no local model is installed. |
|
||||
| _"Watch session w4 and tell me if it gets stuck on a permission prompt."_ | Blocks on the `blocked` signal and surfaces the question to **you**. It never answers another session's prompt itself. |
|
||||
|
||||
One short line is enough. Here a Claude Code lead gets `use codeman to start 5 claude workers, one per TODO item`, and five real Claude Code workers power on, shown side by side in the tile grid:
|
||||
@@ -848,7 +869,7 @@ Every recipe in there was verified against a live server, and the comments recor
|
||||
- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
|
||||
|
||||
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
⚠️ The web UI injection is **on by default** since 1.50.0. A `settings.json` without the `agentSkillEnabled` key means on. Existing installs that stored off are switched on once at the first 1.50.0 start (every App Settings save on 1.49.0 or earlier wrote the old default, `false`, whether or not you touched this switch); turning it off afterwards sticks. Turning it off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+21
-2
@@ -45,6 +45,8 @@ codeman web
|
||||
|
||||
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
||||
|
||||
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
|
||||
|
||||
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
||||
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
||||
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
||||
@@ -99,6 +101,21 @@ bash docker/Start-Codeman.sh
|
||||
|
||||
详见下文[多用户模式](#多用户模式可选启用)。
|
||||
|
||||
### 在 Mac 上:Codeman 应用
|
||||
|
||||
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
|
||||
|
||||
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
||||
|
||||
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
|
||||
|
||||
- **退出应用不会停止 Codeman。** 通过应用安装的 Codeman 以 LaunchAgent 的形式运行,登录时自动启动,所以关掉窗口后智能体照样工作,浏览器和手机也和以前一样能访问它。
|
||||
- **Codeman 的页面需要 Safari 16.4 的 WebKit。** 应用本身在 macOS 11.3 上就能启动,但 Codeman 需要 macOS 13.3 或更高版本,或者装有 Safari 16.4 或更高版本的 macOS 11 或 12。WebKit 更旧时,应用会说明原因,并且不会连接。
|
||||
- **在应用里安装需要 macOS 15,或者机器上已有 tmux。** tmux 来自 tmux 项目自己的构建,而这些构建需要 macOS 15。在没有 tmux 的旧 Mac 上,应用会改为提供终端安装方式,由 Homebrew 在终端里安装 tmux(这需要管理员操作一次)。
|
||||
- **更新。** 应用暂时不会自我更新:下载新的 `Codeman.dmg`,拖过去覆盖旧的那份即可。
|
||||
|
||||
首次启动、已知限制以及如何自行构建:见 wiki 的 [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app)(英文)和 [packages/desktop/README.md](packages/desktop/README.md)(英文)。
|
||||
|
||||
<details>
|
||||
<summary><strong>让它在后台一直运行</strong></summary>
|
||||
|
||||
@@ -775,13 +792,15 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
|
||||
#### 第 1 步:安装
|
||||
|
||||
通常什么都不用装:从 1.50.0 起,每个新会话都会在自己的文件夹里、在它的 CLI 查找技能的位置拿到这个技能(即下表的 Web UI 一行,默认开启)。这份副本会让 git 忽略它自己,所以你的仓库永远不会把它列为新文件。其他方式适用于 pi,或者你想要一份不依赖这个设置的副本的情况。
|
||||
|
||||
| 方式 | 命令 | 范围 |
|
||||
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用([skills.sh](https://skills.sh));给 pi 装技能就用这个方式 |
|
||||
| Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) |
|
||||
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
|
||||
| 内置 CLI | `codeman skill install --case <name> [--mode <mode>]` | 仅一个 case;`--mode codex`(或任何其他运行模式)会把它写到那个 CLI 查找技能的位置 |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,默认关闭) |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,从 1.50.0 起**默认开启**;同时装了插件的 Claude Code 会把技能列出两次,两个都能用,嫌重复就去掉其中一个) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装(带 `--case` 时,删除该 case 里所有由 Codeman 管理的副本,不论它在哪个 CLI 的目录里),并且绝不会碰你自己写的 `skills/codeman`。
|
||||
|
||||
@@ -848,7 +867,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||
- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
|
||||
|
||||
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
⚠️ 从 1.50.0 起,Web UI 注入**默认开启**。`settings.json` 里没有 `agentSkillEnabled` 这个键就表示开启。已经存了关闭的旧安装,会在第一次启动 1.50.0 时被打开一次(1.49.0 及更早版本每次保存 App Settings 都会写入旧默认值 `false`,不管你有没有动过这个开关);之后你再把它关掉,就会一直保持关闭。把它关掉**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,17 @@ see [§7 Build log](#7-build-log-what-actually-happened) for what shipped, what
|
||||
verification round found, and the two items that genuinely remain open (§2.4's footgun
|
||||
guard and the Part 3 deferrals).
|
||||
|
||||
**Update 2026-11-08 (1.50.0)**: `agentSkillEnabled` is now **on by default** (owner decision:
|
||||
"enable the codeman skill by default"), the flip §2.2 planned for after dogfooding. An absent
|
||||
key means on in both readers (server `getAgentSkillEnabled()` and the App Settings switch, both
|
||||
`!== false`); an explicit false is off. Existing installs that stored off are switched on once at
|
||||
the first 1.50.0 start (every App Settings Save up to 1.49.0 wrote `false`); turning it off
|
||||
afterwards sticks (`src/web/agent-skill-default-migration.ts`, a "Behaviour change" in the
|
||||
release notes). To keep users' repositories clean, every managed copy
|
||||
now carries a `.gitignore` of `*` (written once with `wx`, removed by uninstall only when it is
|
||||
the last file left). Turning the setting off still sweeps nothing. The sections below record the
|
||||
original default-off decision as it was made.
|
||||
|
||||
**Date**: 2026-08-08
|
||||
**Scope**: Part 1 (agent skill) and Part 2 (wait primitives) were specified and built.
|
||||
Parts 3 to 5 are captured so they are not lost, but remain deliberately deferred.
|
||||
@@ -119,7 +130,7 @@ Install paths, in order of how a user gets it:
|
||||
|
||||
Setting name: `agentSkillEnabled`. Synced (not per-device), since it changes on-disk case
|
||||
content rather than display. Default: **ON after the dogfooding phase, OFF in the first
|
||||
release**. Rationale for starting OFF: Claude Code loads every skill's name and description
|
||||
release** (flipped to ON in 1.50.0, see the Status update at the top). Rationale for starting OFF: Claude Code loads every skill's name and description
|
||||
into context on every turn, so an always-on skill has a small permanent token cost, and we
|
||||
should measure that we are buying something with it first.
|
||||
|
||||
@@ -543,6 +554,7 @@ without the wait endpoints, so the wait work goes first.
|
||||
1. ✅ `skills/` at the repo root: accepted (built that way; the install one-liner depends on it).
|
||||
2. ✅ `agentSkillEnabled` default: **OFF** for the first release, per §2.2's rationale (skills
|
||||
cost context on every turn; measure before defaulting on). Flip later if dogfooding earns it.
|
||||
**Flipped to ON in 1.50.0** (2026-11-08), with the copy made self-ignoring in git.
|
||||
3. ✅ Both: global install via `npx skills add` / `codeman skill install`, AND per-case
|
||||
auto-injection behind the (default-off) setting. Injection is add-only at session create and
|
||||
marker-guarded, so a user-authored copy is never touched.
|
||||
@@ -681,9 +693,9 @@ Built to the §2.6 file list, mirroring the statusLine mechanism throughout:
|
||||
| ----- | ----- |
|
||||
| `applyAgentSkill(casePath, enabled)` + `installAgentSkillInto` / `removeAgentSkillFrom` | `src/hooks-config.ts` |
|
||||
| `codeman skill install` / `skill uninstall` (`--global` default, `--case <name>`) | `src/cli.ts` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF; ON since 1.50.0) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js`; the one-time 1.50.0 boot migration of a stored false in `src/web/agent-skill-default-migration.ts` |
|
||||
| Injection call sites (Claude mode only) | `POST /api/sessions` next to `refreshStaleCodemanHooks`; `POST /api/quick-start` after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) |
|
||||
| Tests | `test/agent-skill.test.ts` (10 unit), `test/quick-start.test.ts` (real server: default-off, PUT accepts key, injection on create, shell-mode skipped) |
|
||||
| Tests | `test/agent-skill.test.ts` (unit, incl. the self-ignoring `.gitignore` against real git since 1.50.0), `test/quick-start.test.ts` (real server: default-off at first, default-on since 1.50.0 incl. a partial PUT and an explicit false, injection on create, shell-mode skipped), `test/agent-skill-setting-ui.test.ts` (the App Settings switch reads an absent key as on, and a device blob holding the old false follows the server), `test/agent-skill-default-migration.test.ts` (the boot migration: once per data dir, marker outside settings.json) |
|
||||
|
||||
Decisions worth keeping:
|
||||
|
||||
|
||||
+48
-10
@@ -988,11 +988,12 @@ the web UI uses as the base for a relative path printed in the terminal. It is
|
||||
never restored after a restart (it is written to `state.json` with the rest of the
|
||||
session state but not read back), absent for remote,
|
||||
Docker, adopted, direct-PTY and rediscovered sessions and for a session split into several panes,
|
||||
and never a confinement root: file routes stay confined to `workingDir`.
|
||||
and never a confinement root. File routes stay confined to `workingDir`, except for an
|
||||
absolute path inside an `outsideWorktree`, described in the next section.
|
||||
tmux reports the resolved directory, so for a workspace reached through a symlink a
|
||||
directory at or under the resolved workspace is reported in `workingDir`'s spelling.
|
||||
|
||||
## Live worktree (`liveWorktree`)
|
||||
## Live worktree (`liveWorktree`, `outsideWorktree`)
|
||||
|
||||
Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `liveWorktree`,
|
||||
the linked git worktree the session's pane is in now, for example after Claude Code's
|
||||
@@ -1003,17 +1004,54 @@ EnterWorktree moved the running session:
|
||||
```
|
||||
|
||||
It is derived from `paneCwd`, so it is absent wherever that is, and it is only ever a folder
|
||||
strictly below `workingDir`: a worktree elsewhere is never reported. `path` is in `workingDir`'s
|
||||
spelling. Like `paneCwd` it is never restored after a restart and never a confinement root.
|
||||
strictly below `workingDir`: a worktree elsewhere is never reported in it. `path` is in
|
||||
`workingDir`'s spelling. Like `paneCwd` it is never restored after a restart and never a
|
||||
confinement root.
|
||||
|
||||
Session state may also carry the optional `outsideWorktree`: a
|
||||
worktree of the session's repository OUTSIDE `workingDir`, such as `git worktree add ../feature`.
|
||||
It can be a linked worktree, or the repository's main worktree for a session started in a linked
|
||||
one. It carries `"outside": true`, and `path` is the folder's real path. A session has at most one
|
||||
of `liveWorktree` and `outsideWorktree`:
|
||||
|
||||
```json
|
||||
"outsideWorktree": { "name": "repo-feature", "path": "/home/me/repo-feature", "outside": true }
|
||||
```
|
||||
|
||||
Like `liveWorktree`, it is never restored after a restart.
|
||||
|
||||
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
|
||||
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download` and
|
||||
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
|
||||
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
|
||||
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
|
||||
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
|
||||
|
||||
- `/`, the home folder or above, or a folder holding `workingDir`;
|
||||
- a folder at, inside or around a Docker case workspace;
|
||||
- in multi-user mode, a worktree outside the owner's folder rule (their own space or the session
|
||||
folder; an admin owner is not limited);
|
||||
- any outside worktree while attachment workspace confinement is on;
|
||||
- a folder at, inside or around a blocked attachment tree, or a folder that is itself a
|
||||
sensitive folder such as `.ssh`.
|
||||
|
||||
The file routes also apply confinement and the blocked trees on every request, so a setting changed later takes effect at once, and a listing then walks `workingDir`. `html-view` also applies the attachment guard to such a path on every request.
|
||||
|
||||
`GET /api/v1/sessions/:id/files` takes an optional `root=live`, which starts the listing, or a
|
||||
`q` search, at that worktree instead of at `workingDir`. The response's `root` names the folder
|
||||
`q` search, at a worktree inside `workingDir` instead of at `workingDir`. `root=live` never walks
|
||||
an outside worktree. `root=live-any` does the same and also walks an outside worktree, whose
|
||||
listing carries ABSOLUTE `path`s. The response's `root` names the folder
|
||||
that was walked: it stays `workingDir` when the session is in no worktree, when the worktree has
|
||||
gone, and when the folder no longer resolves inside `workingDir`. A `path` in the response stays
|
||||
relative to `workingDir` either way, so it works unchanged on `file-content` and `file-raw`,
|
||||
while a `q` containing `/` is matched against paths relative to the folder walked. The response
|
||||
also carries `worktree`, the same `{ name, path }`, whenever the session has a `liveWorktree`,
|
||||
whichever folder was walked and with or without `root=live`.
|
||||
gone, and when the folder no longer resolves inside `workingDir` (for an outside worktree, when it
|
||||
no longer resolves to its recorded `path`). A `path` in the response stays relative to
|
||||
`workingDir` either way, except in a `root=live-any` listing of an outside worktree, where every `path` is
|
||||
absolute. A relative `path` and an absolute `path` inside an outside worktree both work on
|
||||
`file-content`, `file-raw`, `file-preview`, `file-thumbnail`, `/api/v1/download` and `html-view`.
|
||||
A `q` containing `/` is matched against paths relative to the folder walked. The response
|
||||
also carries `worktree`, the session's `liveWorktree` object (`{ name, path }`) whenever the
|
||||
session has one, whichever folder was walked and with or without `root=live` or `root=live-any`.
|
||||
An `outsideWorktree` (`{ name, path, outside: true }`) is reported in `worktree` only for
|
||||
`root=live-any`.
|
||||
|
||||
The walk lists at most 5,000 files and folders and opens no more folders once it has read 250,000
|
||||
directory entries (the folder that crosses that line is read whole). It sets `truncated` when it
|
||||
|
||||
@@ -167,7 +167,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
|
||||
|
||||
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex).
|
||||
|
||||
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
|
||||
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again (and switches on an off saved between the fix and that start, since the migration has not run yet). It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so once the migration has run, a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `<copy>/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `referenLine truncated
|
||||
|
||||
Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused).
|
||||
|
||||
@@ -229,7 +229,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
|
||||
|
||||
**Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in.
|
||||
|
||||
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
|
||||
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
|
||||
|
||||
**`nameSource` also decides what claude is told to call the conversation.** `--name` is the prompt-box label, the `/resume` picker entry and the terminal title at once, and a pinned title stops Claude generating its own, so by default only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry, because `_nextCaseSessionStartNumber()` numbers from the highest OPEN tab and restarts once a case's tabs are closed. The opt-in synced `pinCliSessionName` (default OFF) pins every non-blank tab name for users who want the CLI labels to match their tabs; it is read fresh at every claude create and respawn (`readPinCliSessionNameEnabled()`, hooks-config.ts, like `readPlanUsageTelemetryEnabled()`), so a toggle applies from the next spawn with no restart. ⚠️ Its cost: Claude stops generating its own titles for those sessions, and a rename holds only until the next turn, because a pane spawned with `--name` re-appends its in-memory title after every turn (`claude-session-title.ts`); a server restart re-attaches the live pane without respawning it. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it.
|
||||
|
||||
@@ -425,13 +425,13 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. The anchors the walk builds carry `dir="ltr"`, so a path at the head of a right-to-left sentence does not decide that sentence's direction; a rewritten link keeps no `dir`, since its label is prose (#715). ⚠️ `_applyBlockDirection()` runs after the linkifier in both consumers, never before it: a block already carrying `dir="auto"` re-resolves its direction on every text node replaced under it. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite, `test/response-viewer-rtl.test.ts` the direction and the order.
|
||||
|
||||
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
|
||||
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, except an absolute path inside a followed worktree outside `workingDir` (see Files panel in a worktree), which they serve as a second root. For every other path that is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
|
||||
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
|
||||
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
|
||||
|
||||
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
|
||||
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, except that `_isExternalPreviewPath` and `file-content`/`file-raw` also admit an absolute path inside a followed outside worktree (`outsideWorktree` in session state), so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
|
||||
|
||||
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
|
||||
|
||||
@@ -702,7 +702,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
|
||||
|
||||
**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:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`, `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`, `OLLAMA_MAX_LOADED_MODELS=1`): 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.
|
||||
**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:<port>`, `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.
|
||||
|
||||
@@ -800,23 +800,37 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
|
||||
|
||||
⚠️ **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 path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` listing; the returned `path`s stay relative to `workingDir` either way), 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` on `GET /api/sessions/:id/files`): Claude Code's EnterWorktree moves the running process into `<repo>/.claude/worktrees/<name>`, 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.
|
||||
**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 `<repo>/.claude/worktrees/<name>`, 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/<id>`). 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` checks 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.
|
||||
⚠️ **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.
|
||||
|
||||
⚠️ `liveWorktree` 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. Serving a worktree outside the folder (`git worktree add ../feature`) would need a second root, so those stay unfollowed, and so does a worktree of a session started below the repository's top, because Claude Code puts it at the top, outside the session's folder.
|
||||
⚠️ 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 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 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:
|
||||
|
||||
Two surfaces do not move with it. 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/<name>` is refused and opens as source (a session started in the worktree renders it). Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`.
|
||||
- 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 `<common dir>/worktrees/<id>`, 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/<name>` 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
|
||||
|
||||
|
||||
@@ -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 <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
|
||||
automatic per-case injection on session create, into the directory each CLI scans for
|
||||
skills). 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
|
||||
|
||||
+182
-7
@@ -1,7 +1,8 @@
|
||||
# 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, no
|
||||
API key, nothing leaves the machine. One button on the welcome screen opens a window that
|
||||
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.
|
||||
|
||||
@@ -13,6 +14,92 @@ This page is the reference for how it works. The HTTP contract is in
|
||||
[api-reference.md](api-reference.md) ("Local model"). The rules that must not be broken are
|
||||
in [architecture-invariants.md](architecture-invariants.md#local-model-install).
|
||||
|
||||
## Which model fits which Mac
|
||||
|
||||
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
|
||||
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
|
||||
the catalogue answers today, before the runtime measured anything (the budget is then an
|
||||
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
|
||||
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
|
||||
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
|
||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
|
||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
|
||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||
|
||||
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
|
||||
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
|
||||
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
|
||||
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
|
||||
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
|
||||
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
|
||||
|
||||
### How much memory a model needs
|
||||
|
||||
The need of a model is its peak: the weights, what stays held per token of context, what the
|
||||
runner holds beside both, and what it takes for a moment while a long prompt is processed. The
|
||||
runtime runs a model with one of two runners, which spend memory differently, so every row
|
||||
names its runner (`runner`) and its figures follow that runner's rule:
|
||||
|
||||
- **llama.cpp** (GGUF builds, on Metal) allocates the K/V cache for the whole context when the
|
||||
model loads and does not grow after that. The runtime is started with
|
||||
`OLLAMA_KV_CACHE_TYPE=q8_0` (the cache in 8 bits, 17/32 of the default f16; the runtime's
|
||||
own documentation calls the loss very small), `OLLAMA_FLASH_ATTENTION=1` (an 8-bit cache
|
||||
needs it), `LLAMA_ARG_CACHE_RAM=1024` (llama-server keeps copies of earlier prompts, and
|
||||
this bounds them to 1 GiB) and `LLAMA_ARG_CTX_CHECKPOINTS=8` (llama-server keeps copies of
|
||||
the state it cannot roll back, two or three per request, up to 32 by default: 5 GB for the
|
||||
27B; 8 keeps the last few requests, which is what an agent's next request restores from). A
|
||||
row's figures are computed from its model's `config.json`: the K/V cache of its attention
|
||||
layers, its recurrent or sliding-window state, live and in each of the 8 checkpoints, 1.0
|
||||
GB for the runner and the 1 GiB prompt cache. A build with a multi-token-prediction layer
|
||||
(the two `-mtp` tags and `qwen3.8:27b-q8_0`) costs more: the runtime turns prediction on,
|
||||
so llama-server keeps that layer's cache in f16 and one more copy of the recurrent state for
|
||||
rollback per drafted token (the tag's `draft_num_predict`: 2 for the 35B-A3B, 4 for the
|
||||
27B). No peak: the buffers are allocated at load. The checkpoints and the
|
||||
prediction costs were read in the llama.cpp source the runtime bundles (b12111), not
|
||||
measured.
|
||||
- **MLX** grows its cache with the tokens actually used, keeps snapshots of the recurrent
|
||||
state, and may keep up to 8 GiB of snapshots of earlier prompts, which no setting bounds. A
|
||||
Mac always takes the MLX build of a tag that has one. Below 48 GB that 8 GiB does not fit
|
||||
beside a useful model, so the catalogue lists GGUF builds there.
|
||||
|
||||
Where every figure comes from:
|
||||
|
||||
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
|
||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
|
||||
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
|
||||
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
|
||||
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
|
||||
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
|
||||
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
|
||||
| `qwen3.8:27b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 2.95: a published run without prediction (a floor) |
|
||||
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
|
||||
|
||||
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
|
||||
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
|
||||
calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
|
||||
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
|
||||
below). The measured row's figures come from single long prompts, not a session of many
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
|
||||
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
|
||||
|
||||
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
|
||||
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
||||
`test/local-model-catalog.test.ts`, which recomputes every computed figure from it and fails
|
||||
for a row it does not know. Its answer for each memory size is then pinned in
|
||||
`test/local-model-recommend.test.ts`.
|
||||
|
||||
## What an install does, in order
|
||||
|
||||
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
|
||||
@@ -48,8 +135,10 @@ Every step can be repeated: Resume and "Try again" enter the step the job stoppe
|
||||
copied from the server. `HOME` is a private folder. Exactly these are set:
|
||||
`OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`,
|
||||
`OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`,
|
||||
`OLLAMA_MAX_LOADED_MODELS=1`. No `CODEMAN_*` variable and none of the user's own
|
||||
`OLLAMA_*` settings reach it.
|
||||
`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.
|
||||
@@ -68,8 +157,7 @@ judges the chosen model against it before a byte of the model is downloaded. A m
|
||||
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: 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 need of a model is its peak (see "How much memory a model needs" above).
|
||||
|
||||
### The model download
|
||||
|
||||
@@ -85,7 +173,10 @@ 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.
|
||||
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.
|
||||
|
||||
@@ -169,6 +260,19 @@ A different digest refuses the launch: "The model on this Mac changed since it w
|
||||
installed. Remove it and install it again." The digest is also read again at boot and when
|
||||
the runtime is started.
|
||||
|
||||
### From a Claude Code session: cloud plus local
|
||||
|
||||
The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac))
|
||||
lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and
|
||||
reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and
|
||||
files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what
|
||||
is installed or why nothing can run; `spawn_worker <name> local` starts the runtime when it was
|
||||
stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same
|
||||
quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its
|
||||
answer back from a file it writes in its own folder. The skill never installs a model and never
|
||||
answers a confirmation, the runtime still answers one request at a time, and while a worker it
|
||||
opened is live, Stop and Remove are refused until it is closed.
|
||||
|
||||
## Stop, Start, Remove
|
||||
|
||||
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
|
||||
@@ -247,9 +351,79 @@ or `python3`.
|
||||
a session uses the model: close those sessions first.
|
||||
- **The runtime does not enforce the context length.** A prompt longer than the context was
|
||||
accepted in testing. Do not rely on the runtime to stop one.
|
||||
- **Most memory figures are calculated, not measured.** One model was run on one Mac; the
|
||||
others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
||||
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
|
||||
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
||||
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
||||
unloads it.
|
||||
|
||||
## Keeping the catalogue current
|
||||
|
||||
The catalogue (`src/config/local-model-catalog.ts`) and the runtime pin are compiled into
|
||||
Codeman on purpose: no file or download decides what is recommended or run. What the
|
||||
registry serves can still move away from them: a tag is pushed again or removed, the build
|
||||
a Mac downloads changes size, Ollama ships a new version. The workflow
|
||||
`.github/workflows/local-model-catalog.yml` looks for that every Monday at 06:00 UTC. It
|
||||
runs `scripts/check-local-model-catalog.mjs`, which also runs locally:
|
||||
`npm run check:local-model-catalog` prints the report and exits 0 (clean), 1 (drift) or 2
|
||||
(the check itself failed).
|
||||
|
||||
For every model row it checks:
|
||||
|
||||
- **The tag still exists** in the registry.
|
||||
- **The size of what a Mac downloads** is within 0.1% of the row's `bytes`. A tag can be a
|
||||
manifest list with one build per runner, and Ollama on Apple silicon takes the `mlx` build
|
||||
when there is one, else `llamacpp`, else `ggml`. So the check asks for the tag the way
|
||||
Ollama asks, picks the build a Mac picks and sizes it the way a pull counts it (every layer
|
||||
plus the config). The plain manifest other tools get for the same tag is the `llamacpp`
|
||||
build, which is not what a Mac downloads.
|
||||
- **The runner of that build**, once a row names its `runner`: a Mac still pulls an `mlx`
|
||||
build for an `mlx` row and a GGUF build (`llamacpp`, or a manifest that names no runner)
|
||||
for a `llamacpp` row. A row's memory figures follow its runner, so a tag that gains or
|
||||
loses an mlx build is drift even at about the same size.
|
||||
- **The build a Mac pulls runs on the pinned runtime**: its config declares no newer
|
||||
Ollama (`requires`) than `OLLAMA_RUNTIME_PIN`.
|
||||
- **The pinned digest**, once a row carries one: a Mac would still pull that manifest. The
|
||||
field is `digest` and its value is what `/api/tags` reports after the pull. The catalogue
|
||||
schema does not have it yet: `LocalModelEntrySchema` must gain an optional `digest` under
|
||||
exactly that name, or the check never compares it. Until then the report lists today's
|
||||
digests, rows with measured figures first, because without a digest a rebuild of about the
|
||||
same size goes unnoticed (a switch between the mlx and the gguf build does not: their sizes
|
||||
differ by 2% or more). Pinning digests is a decision, not a quick fix: today the catalogue
|
||||
holds no digest on purpose, and `test/local-model-catalog.test.ts` ("holds no download
|
||||
data") refuses a `sha256` anywhere in it, so that rule changes with it.
|
||||
|
||||
For the runtime pin it checks whether a newer Ollama release exists, and whether the pinned
|
||||
`ollama-darwin.tgz` is still on GitHub with the pinned size and sha256.
|
||||
|
||||
It reads manifests and configs only, never a model layer. It does not check the memory
|
||||
figures, the speeds or whether a coding agent works with a model: no manifest says anything
|
||||
about those, and they change only when somebody measures on a Mac.
|
||||
|
||||
**The issue.** Drift opens one issue, "Local model catalogue: weekly check", or, while that
|
||||
issue is open, replaces its description with the new report and adds a comment. A clean week
|
||||
comments on the open issue and does nothing when there is none. A check that failed (the
|
||||
network, a rate limit, an answer that did not parse) fails the run and leaves the issue
|
||||
alone; what it found is in the run's summary. The schedule runs the copy of the workflow on
|
||||
`master`, and so does `gh workflow run local-model-catalog.yml`.
|
||||
|
||||
**What a maintainer does with it**, in a pull request each time:
|
||||
|
||||
- **Size drift or a changed digest**: correct the row. When the build itself changed (another
|
||||
runner or quantization), its memory figures and speed no longer describe it: mark the row
|
||||
`estimated` until somebody measures it again.
|
||||
- **A tag that is gone, or a build that needs a newer runtime**: replace or drop the row, or
|
||||
move the runtime pin first.
|
||||
- **A newer Ollama**: read its release notes, then move `OLLAMA_RUNTIME_PIN` (version, URL,
|
||||
size and sha256, checked against the release's `sha256sum.txt`) and run an install on a
|
||||
Mac before merging.
|
||||
- **A pinned archive that changed**: a security question first. Every install refuses it at
|
||||
the hash compare, so nobody gets it, but find out why before moving the pin.
|
||||
|
||||
Close the issue once the fix is merged. A catalogue or pin change reaches users only in a
|
||||
release: an installed Codeman keeps the catalogue it was built with until it is updated.
|
||||
|
||||
## Source
|
||||
|
||||
| What | Where |
|
||||
@@ -265,3 +439,4 @@ or `python3`.
|
||||
| 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` |
|
||||
+34
-1
@@ -42,7 +42,10 @@ When user says "COM":
|
||||
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
|
||||
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release and the MCP registry publish). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
A site change that links the Mac app (`Codeman.dmg`, `/mac`) deploys only after step 7 showed the `macOS app` job green and the [DMG check](#the-mac-app-codemandmg) answered 200: the DMG reaches the release well after the npm publish, and the download link answers 404 until then.
|
||||
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release, the MCP registry publish and, when Codeman itself was published, the `macOS app` job that attaches `Codeman.dmg`). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them.
|
||||
|
||||
@@ -50,6 +53,36 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
## The Mac app (Codeman.dmg)
|
||||
|
||||
Every Codeman release carries the native macOS app (`packages/desktop`) as `Codeman.dmg` plus `Codeman.dmg.sha256`. The name never changes, so https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always serves the newest app, while each release keeps its own copy at `https://github.com/Ark0N/Codeman/releases/download/codeman@<x.y.z>/Codeman.dmg` (link that one in release notes). The app keeps its own version (`packages/desktop/Cargo.toml`), so the DMG of Codeman 1.50.0 holds app 0.1.0.
|
||||
|
||||
**How it gets there.** When npm took a Codeman release (not one that moved only xterm-zerolag-input), the `Release` run's `macOS app` job calls `.github/workflows/desktop-dmg.yml` with the tag `codeman@<x.y.z>`. It has three jobs, so no job that holds a secret runs code from a crate: `build` runs `cargo test --locked` and `bundle-macos.sh --universal` on the release commit with a read-only token and no secret; `sign` imports the Developer ID certificate into a throwaway keychain and runs `packages/desktop/scripts/sign-macos.sh`, which signs the app with the hardened runtime, has Apple notarize it, staples the ticket, builds the DMG around it and signs, notarizes and staples that too; `attach` uploads both files to the release (`--clobber`) and checks that the download links serve those bytes. The release job's `Keep Latest on the Codeman release` step puts the Latest badge back on the newest `codeman@` release after every publish, since the stable link follows that badge.
|
||||
|
||||
**The secrets** are repository secrets, passed to the job by name (never `secrets: inherit`, which would hand it `NPM_TOKEN`): `MACOS_CERT_P12` (the Developer ID Application certificate WITH its private key, exported from Keychain Access as a .p12, then `base64 -i <file>.p12`), `MACOS_CERT_PASSWORD` (the export password), `APPLE_ID`, `APPLE_APP_PASSWORD` (an app-specific password for that Apple ID, made at account.apple.com) and `APPLE_TEAM_ID`. A Developer ID certificate lasts five years; its successor replaces the first two secrets and nothing else.
|
||||
|
||||
**Check it before anything links to it.** The DMG lands after a universal build and two notary submissions, so well after the npm publish, and until then the stable link answers 404:
|
||||
|
||||
```bash
|
||||
gh release view codeman@<x.y.z> --json assets -q '.assets[].name' # Codeman.dmg and Codeman.dmg.sha256
|
||||
curl -sIL -o /dev/null -w '%{http_code}\n' https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg # 200
|
||||
```
|
||||
|
||||
**Dry run.** `gh workflow run desktop-dmg.yml` builds, signs and notarizes master and attaches nothing; `gh workflow run desktop-dmg.yml --ref <branch>` does the same for a branch that carries the workflow (GitHub starts a manual run only for a workflow file that is also on master). The signed DMG is the run's `codeman-dmg` artifact (`gh run download <run-id> -n codeman-dmg`). For a first-launch test, download it on a Mac through the browser from the run page instead, so the copy is quarantined like one from a release.
|
||||
|
||||
**Rebuild a release's DMG.** `gh workflow run desktop-dmg.yml -f tag=codeman@<x.y.z>`, started from master (the only ref allowed to attach), builds that tag and replaces its `Codeman.dmg`. It works for any release that carries `packages/desktop/scripts/sign-macos.sh`. The tag must be a Codeman release tag (`codeman@x.y.z`, or the `aicodeman@x.y.z` a failed rename leaves) whose commit is on master's history: `build` and `sign` both refuse anything else before they run its scripts, since `sign` runs them with the signing key unlocked. A failed job is also retried in place with `gh run rerun <run-id> --failed`.
|
||||
|
||||
**If notarization fails.** The `Sign and notarize` job's log names the submission and prints Apple's log for it (`xcrun notarytool log <id>` with the same login shows it again).
|
||||
|
||||
- `HTTP status code: 401` (invalid credentials) or `403` (a required agreement is missing or has expired) at the first submission: a new Apple ID password revokes every app-specific password, so make a new one and update `APPLE_APP_PASSWORD`, or accept the new agreement at developer.apple.com, then `gh run rerun <run-id> --failed`.
|
||||
- `0 valid Developer ID Application identities` in the `Signing keychain` step: the .p12 lacks its private key or holds another certificate type. A team mismatch there means `APPLE_TEAM_ID` names another team than the certificate.
|
||||
- `In Progress` at the 45-minute limit, or a network error: Apple's service was slow. Rerun the failed job.
|
||||
- `Invalid`: Apple's log names the file and the reason (a binary without the hardened runtime or a secure timestamp, an entitlement it refuses). That is a code fix and a new release. Until then keep the stable link on the last app that passed, `gh release edit codeman@<previous> --latest`, and hold any site change that links the new one.
|
||||
|
||||
**By hand on a Mac**, only when the workflow cannot be fixed in time: in a clean checkout of the tag, `env -u CODEMAN_DESKTOP_INSTALLER_URL -u CODEMAN_DESKTOP_INSTALL_BRANCH packages/desktop/scripts/bundle-macos.sh --universal`, then `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app <out-dir>` (a profile saved with `xcrun notarytool store-credentials`), then `gh release upload codeman@<x.y.z> <out-dir>/Codeman.dmg <out-dir>/Codeman.dmg.sha256 --clobber`. Never upload a build made with either of those two variables set: it installs Codeman from the test branch it names.
|
||||
|
||||
**Toolchain and runner.** `desktop-dmg.yml` and the pull-request check `desktop-ci.yml` (`cargo fmt --check`, clippy with `-D warnings` and `cargo test --locked` on macOS, for changes under `packages/desktop`) both pin `RUST_TOOLCHAIN` and run on `macos-26`. Move them together.
|
||||
|
||||
## Security notices
|
||||
|
||||
A security notice is a sticky warning card about something outside Codeman that its users should act on (the first: OpenCode's Console breach, October 2026). On a browser that gets the release's update toast it appears inside that toast instead, so nobody sees it twice. Notices live in `src/web/public/security-notices.json`, which is maintained BY HAND; never put one in `whats-new.json`, which `npm run version-packages` rewrites. A notice reaches users only with a release, so add it before the COM that should carry it.
|
||||
|
||||
@@ -342,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 |
|
||||
|
||||
@@ -79,7 +79,7 @@ output. The other CLIs expose no equivalent.
|
||||
| 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
|
||||
|
||||
@@ -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 |
|
||||
| ---------------------- | ------------------------------------------------------------------------ |
|
||||
|
||||
@@ -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 <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
|
||||
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. 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 <name>`.
|
||||
that directory. Remove them per case with `codeman skill uninstall --case <name>`, 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,6 +133,12 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+3
-1
@@ -215,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.
|
||||
|
||||
@@ -17,7 +17,7 @@ lists mark a session that runs in one with a `⑂ <name>` pill.
|
||||
|
||||
The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/`
|
||||
(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the
|
||||
`codeman` folder it writes 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.
|
||||
@@ -53,11 +53,21 @@ Only the Files panel moves. The **Git** panel stays on the session's folder, so
|
||||
folder, so an `.html` file in `.claude/worktrees/<name>` opens as source rather than as a
|
||||
rendered page. A session started in the worktree does not have that limit.
|
||||
|
||||
A worktree outside the session's folder (`git worktree add ../feature`) is not followed: Codeman
|
||||
only serves files inside the session's folder. The same goes for a session started in a subfolder
|
||||
of the repository, because Claude Code puts the worktree at the top of the repository, which is
|
||||
outside that session's folder. Open such a worktree as a case of its own with Add Case > Link
|
||||
Existing.
|
||||
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
|
||||
|
||||
|
||||
@@ -25,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
|
||||
@@ -226,6 +227,73 @@ image, the refreshed volumes and the entrypoint arrive together. The full guide,
|
||||
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** blinks the logo by what your agents are doing, with a style of
|
||||
your own for working, for waiting for you and for needing permission. All three are off
|
||||
until you choose, and the menu only exists on such a Mac.
|
||||
|
||||
### First launch
|
||||
|
||||
1. Open `Codeman.dmg` and drag Codeman into Applications.
|
||||
2. Open Codeman from Applications. macOS asks once whether to open an app downloaded from
|
||||
the internet: click **Open**.
|
||||
3. On macOS 15 and later, the first connection to an address on your local network (a LAN
|
||||
IP, a `.local` name) makes macOS ask whether Codeman may use the local network, and that
|
||||
first attempt can fail while the question is open: connect again after answering. If
|
||||
you denied it, turn Codeman on in **System Settings > Privacy & Security > Local
|
||||
Network**.
|
||||
|
||||
What to know about it:
|
||||
|
||||
- **Quitting the app does not stop Codeman.** An install from the app runs Codeman as a
|
||||
LaunchAgent that starts at login, so agents keep working with the window closed, and a
|
||||
browser or phone reaches it as before.
|
||||
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but
|
||||
Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later
|
||||
installed. On an older WebKit the app says so and does not connect.
|
||||
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes
|
||||
from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the
|
||||
app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
|
||||
- **The install from the app reaches as far as the local network.** Tailscale needs its own
|
||||
login and setup, which the installer does at a terminal: choose *Install in Terminal
|
||||
instead*, or run `install.sh tailscale` afterwards.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a
|
||||
self-signed certificate, which macOS's WebKit rejects. Use `http://127.0.0.1:3000` for a
|
||||
server on this Mac. For another machine, serve Codeman through `tailscale serve`
|
||||
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse proxy with a
|
||||
real certificate.
|
||||
- **The Codeman it installs is an ordinary installer install,** made on the
|
||||
[route without administrator rights](#without-administrator-rights), so
|
||||
[Updating](#updating) and [Uninstalling](#uninstalling) below apply to it.
|
||||
- **The app does not update itself yet.** Download the new `Codeman.dmg` and drag it over
|
||||
the old copy.
|
||||
- **Deleting the app leaves Codeman running.** The app's own settings are in
|
||||
`~/Library/Application Support/Codeman/`, and its saved passwords in the Keychain under
|
||||
*Codeman Desktop*.
|
||||
|
||||
Everything else, including how to build it yourself:
|
||||
[`packages/desktop/README.md`](https://github.com/Ark0N/Codeman/blob/master/packages/desktop/README.md).
|
||||
|
||||
## Installing an agent CLI
|
||||
|
||||
Codeman drives CLIs, it does not bundle them. Install at least one:
|
||||
|
||||
@@ -222,7 +222,7 @@ on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-o
|
||||
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
|
||||
| Agent Teams | Experimental teams. Also needs the CLI's own environment flag. |
|
||||
| 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 and a plain shell). Off by default. See [The Codeman Skill](Codeman-Skill). |
|
||||
| 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 <name>` 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. |
|
||||
|
||||
@@ -24,7 +24,7 @@ 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. 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. |
|
||||
| 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. |
|
||||
@@ -117,6 +117,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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
@@ -30,6 +30,7 @@
|
||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
||||
"check:local-model-catalog": "tsx scripts/check-local-model-catalog.mjs",
|
||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
|
||||
@@ -341,10 +341,12 @@ crosses, so the login is never on a command line.
|
||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||
copy in Applications works too. Shipping it to other people needs a Developer
|
||||
ID signature and notarization:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`,
|
||||
then `xcrun notarytool submit` and `xcrun stapler staple`.
|
||||
copy in Applications works too. The `Codeman.dmg` on each GitHub release is
|
||||
signed with a Developer ID and notarized by CI
|
||||
(`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the
|
||||
same by hand:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
|
||||
where `scripts/sign-macos.sh` lists what it needs.
|
||||
- **Local Network permission (macOS 15 and later).** The first connection to an
|
||||
address on the local network (a LAN IP, a `.local` name) makes macOS ask
|
||||
whether Codeman may use the local network, and that first attempt can fail
|
||||
@@ -393,9 +395,10 @@ crosses, so the login is never on a command line.
|
||||
- **Who can reach it stops at 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` later.
|
||||
- **The agent is chosen during the install.** With Codeman already installed,
|
||||
or after *Skip for now*, an agent is added by hand: its commands are in the
|
||||
installer's closing notes and in [Installing an agent CLI](../../docs/wiki/Installation.md#installing-an-agent-cli).
|
||||
- **An agent is chosen during the install, or added later.** With Codeman
|
||||
already installed, or after *Skip for now*, *Add a coding agent* on the This
|
||||
Mac tab (*Add a Coding Agent to This Mac…* in the app menu) installs one. The
|
||||
commands are also in [Installing an agent CLI](../../docs/wiki/Installation.md#installing-an-agent-cli).
|
||||
- **An agent only your interactive profile finds counts as missing.** The app
|
||||
reads the login shell's environment, without `.zshrc`, so a CLI on an nvm
|
||||
PATH set up there is offered again as if it were not installed. Installing
|
||||
@@ -420,4 +423,4 @@ crosses, so the login is never on a command line.
|
||||
- Trusting a self-signed server certificate by its fingerprint.
|
||||
- Asking before a download that a rendered HTML file or a web tab starts.
|
||||
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
|
||||
- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update.
|
||||
- Windows and Linux builds, auto-update.
|
||||
@@ -4,6 +4,8 @@
|
||||
# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture
|
||||
# packages/desktop/scripts/bundle-macos.sh --universal --dmg
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> \
|
||||
# packages/desktop/scripts/bundle-macos.sh --universal --notarize
|
||||
#
|
||||
# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built
|
||||
# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15
|
||||
@@ -11,6 +13,15 @@
|
||||
# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open.
|
||||
# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy
|
||||
# in /Applications. A Developer ID signature + notarization avoids all of this.
|
||||
#
|
||||
# --dmg writes dist/Codeman-<version>.dmg, signed with SIGN_IDENTITY when it is
|
||||
# set. --notarize hands the built app to scripts/sign-macos.sh, which signs it
|
||||
# with SIGN_IDENTITY, has Apple notarize it, staples the ticket to it and does
|
||||
# the same for dist/Codeman.dmg, the DMG built around it (so --dmg is implied).
|
||||
# sign-macos.sh's header lists what it needs: SIGN_IDENTITY, and NOTARY_PROFILE
|
||||
# or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID. The release workflow
|
||||
# (.github/workflows/desktop-dmg.yml) runs this script ad hoc in one job and
|
||||
# sign-macos.sh in another, so the job that runs cargo holds no secret.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
@@ -18,14 +29,21 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
UNIVERSAL=0
|
||||
DMG=0
|
||||
NOTARIZE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--universal) UNIVERSAL=1 ;;
|
||||
--dmg) DMG=1 ;;
|
||||
--notarize) NOTARIZE=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
# Checked before the build, so a missing secret costs seconds, not a build.
|
||||
./scripts/sign-macos.sh --check
|
||||
fi
|
||||
|
||||
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
@@ -98,13 +116,13 @@ codesign --verify --strict "$APP"
|
||||
|
||||
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
|
||||
|
||||
if [ "$DMG" = 1 ]; then
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
./scripts/sign-macos.sh "$APP" dist
|
||||
elif [ "$DMG" = 1 ]; then
|
||||
DMG_PATH="dist/Codeman-$VERSION.dmg"
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
|
||||
./scripts/make-dmg.sh "$APP" "$DMG_PATH"
|
||||
if [ -n "${SIGN_IDENTITY:-}" ]; then
|
||||
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
|
||||
fi
|
||||
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
|
||||
fi
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Put Codeman.app in a compressed disk image next to a link to /Applications,
|
||||
# so opening the image shows the app and the folder to drag it into.
|
||||
#
|
||||
# packages/desktop/scripts/make-dmg.sh <Codeman.app> <out.dmg>
|
||||
#
|
||||
# bundle-macos.sh (--dmg) and sign-macos.sh both call it, so a local test DMG
|
||||
# and the release DMG are laid out the same way. It signs nothing.
|
||||
set -euo pipefail
|
||||
|
||||
[ "$(uname -s)" = Darwin ] || { echo "make-dmg.sh runs on macOS only" >&2; exit 1; }
|
||||
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out.dmg>" >&2; exit 2; }
|
||||
|
||||
APP=$1
|
||||
DMG=$2
|
||||
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
# ditto keeps what cp -R can lose: extended attributes, and with them a
|
||||
# stapled notarization ticket.
|
||||
ditto "$APP" "$STAGE/$(basename "$APP")"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
|
||||
# Hosted macOS runners now and then fail hdiutil with "Resource busy"; the
|
||||
# same command a few seconds later passes.
|
||||
rm -f "$DMG"
|
||||
tries=1
|
||||
until hdiutil create -quiet -volname Codeman -srcfolder "$STAGE" -ov -format UDZO "$DMG"; do
|
||||
if [ "$tries" -ge 3 ]; then
|
||||
echo "hdiutil create failed $tries times" >&2
|
||||
exit 1
|
||||
fi
|
||||
tries=$((tries + 1))
|
||||
echo "hdiutil create failed, trying again in 10 seconds" >&2
|
||||
rm -f "$DMG"
|
||||
sleep 10
|
||||
done
|
||||
hdiutil verify -quiet "$DMG"
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sign Codeman.app with a Developer ID, have Apple notarize it and staple the
|
||||
# ticket to it, then build Codeman.dmg around the stapled app and do the same
|
||||
# for the DMG. On macOS.
|
||||
#
|
||||
# packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>
|
||||
# packages/desktop/scripts/sign-macos.sh --check # only check the environment below
|
||||
#
|
||||
# <Codeman.app> is re-signed and stapled in place. <out-dir> receives
|
||||
# Codeman.dmg and Codeman.dmg.sha256, the latter with the bare file name in it,
|
||||
# so `shasum -a 256 -c Codeman.dmg.sha256` works next to a downloaded copy. Both
|
||||
# appear only once every check passed; a failed run leaves neither, not even an
|
||||
# older pair, so nothing half-done can be uploaded by mistake.
|
||||
#
|
||||
# The environment names who signs:
|
||||
# SIGN_IDENTITY a "Developer ID Application: ..." identity in a keychain
|
||||
# on the search list, by name or by the SHA-1 hash that
|
||||
# `security find-identity -v -p codesigning` prints
|
||||
# and how to reach Apple's notary service, either through a profile saved with
|
||||
# `xcrun notarytool store-credentials <profile> --apple-id ... --team-id ...`:
|
||||
# NOTARY_PROFILE the profile's name
|
||||
# NOTARY_KEYCHAIN optional, the keychain that holds it (default: login)
|
||||
# or with the Apple ID itself:
|
||||
# APPLE_ID, APPLE_APP_PASSWORD (an app-specific password), APPLE_TEAM_ID
|
||||
#
|
||||
# By hand, on a Mac with the certificate in the login keychain:
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=codeman-notary \
|
||||
# packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app packages/desktop/dist
|
||||
#
|
||||
# The release workflow (.github/workflows/desktop-dmg.yml) runs it in a job that
|
||||
# builds nothing: the job that runs cargo, and with it the build script of every
|
||||
# crate in Cargo.lock, never holds a signing secret.
|
||||
set -euo pipefail
|
||||
|
||||
[ "$(uname -s)" = Darwin ] || { echo "sign-macos.sh runs on macOS only" >&2; exit 1; }
|
||||
|
||||
DESKTOP=$(cd "$(dirname "$0")/.." && pwd)
|
||||
ENTITLEMENTS="$DESKTOP/macos/entitlements.plist"
|
||||
|
||||
# Fails with the name of what is missing, before any work, so a missing secret
|
||||
# costs seconds rather than a build.
|
||||
check_env() {
|
||||
if [ -z "${SIGN_IDENTITY:-}" ]; then
|
||||
echo "sign-macos.sh needs SIGN_IDENTITY" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
local name
|
||||
for name in APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
|
||||
if [ -z "$(eval "printf '%s' \"\${$name:-}\"")" ]; then
|
||||
echo "sign-macos.sh needs NOTARY_PROFILE, or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID ($name is empty)" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${1:-}" = --check ] && [ $# -eq 1 ]; then
|
||||
check_env
|
||||
exit 0
|
||||
fi
|
||||
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out-dir> | --check" >&2; exit 2; }
|
||||
check_env
|
||||
|
||||
APP=$1
|
||||
OUT=$2
|
||||
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
|
||||
mkdir -p "$OUT"
|
||||
OUT=$(cd "$OUT" && pwd)
|
||||
rm -f "$OUT/Codeman.dmg" "$OUT/Codeman.dmg.sha256"
|
||||
NAME=$(basename "$APP" .app)
|
||||
|
||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||
NOTARY_AUTH=(--keychain-profile "$NOTARY_PROFILE")
|
||||
if [ -n "${NOTARY_KEYCHAIN:-}" ]; then
|
||||
NOTARY_AUTH=("${NOTARY_AUTH[@]}" --keychain "$NOTARY_KEYCHAIN")
|
||||
fi
|
||||
else
|
||||
# On notarytool's command line, which the job's log never shows.
|
||||
NOTARY_AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID")
|
||||
fi
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Submit a file to the notary service and wait. Anything but Accepted stops the
|
||||
# script with the submission id and Apple's log, which names the file and the
|
||||
# reason (an unsigned binary, a missing timestamp, no hardened runtime...).
|
||||
notarize() {
|
||||
local file=$1 result="$WORK/notary.plist" rc=0 status id
|
||||
echo "Submitting $(basename "$file") to Apple's notary service"
|
||||
xcrun notarytool submit "$file" "${NOTARY_AUTH[@]}" --wait --timeout 45m \
|
||||
--output-format plist > "$result" || rc=$?
|
||||
status=$(/usr/libexec/PlistBuddy -c 'Print :status' "$result" 2>/dev/null || true)
|
||||
id=$(/usr/libexec/PlistBuddy -c 'Print :id' "$result" 2>/dev/null || true)
|
||||
if [ "$status" != Accepted ]; then
|
||||
echo "Notarization of $(basename "$file") ended as '${status:-unknown}' (submission ${id:-none}, notarytool exit $rc)" >&2
|
||||
cat "$result" >&2 || true
|
||||
if [ -n "$id" ]; then
|
||||
xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "Notarized $(basename "$file") (submission $id)"
|
||||
}
|
||||
|
||||
# The app: hardened runtime (which notarization requires), a secure timestamp,
|
||||
# and the entitlements the hardened runtime needs for microphone and camera.
|
||||
codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \
|
||||
--sign "$SIGN_IDENTITY" "$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
DETAILS=$(codesign --display --verbose=2 "$APP" 2>&1)
|
||||
printf '%s\n' "$DETAILS" | grep -E '^(Authority|TeamIdentifier|Timestamp)=' || true
|
||||
case "$DETAILS" in
|
||||
*"Authority=Developer ID Application: "*) ;;
|
||||
*) echo "$APP is not signed with a Developer ID Application certificate" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$DETAILS" in
|
||||
*"(runtime)"*) ;;
|
||||
*) echo "$APP is signed without the hardened runtime" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
ditto -c -k --keepParent "$APP" "$WORK/$NAME.zip"
|
||||
notarize "$WORK/$NAME.zip"
|
||||
xcrun stapler staple "$APP"
|
||||
xcrun stapler validate "$APP"
|
||||
spctl --assess --type execute --verbose=2 "$APP"
|
||||
|
||||
# The DMG around the stapled app, signed and notarized in its own right, so the
|
||||
# image opens without a warning and the app inside opens offline.
|
||||
DMG="$WORK/Codeman.dmg"
|
||||
"$DESKTOP/scripts/make-dmg.sh" "$APP" "$DMG"
|
||||
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG"
|
||||
codesign --verify --strict --verbose=2 "$DMG"
|
||||
notarize "$DMG"
|
||||
xcrun stapler staple "$DMG"
|
||||
xcrun stapler validate "$DMG"
|
||||
spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG"
|
||||
|
||||
(cd "$WORK" && shasum -a 256 Codeman.dmg > Codeman.dmg.sha256)
|
||||
mv "$DMG" "$WORK/Codeman.dmg.sha256" "$OUT/"
|
||||
echo "Signed and notarized $APP"
|
||||
echo "Built $OUT/Codeman.dmg ($(du -h "$OUT/Codeman.dmg" | cut -f1)), sha256 $(cut -d' ' -f1 "$OUT/Codeman.dmg.sha256")"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -9,6 +9,6 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c
|
||||
|
||||
The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions.
|
||||
|
||||
Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise.
|
||||
Pick one install route. Codeman injects the skill into each new session's folder itself, on by default since 1.50.0 (App Settings, Agents & CLIs, Codeman Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise: turn that setting off, or skip the plugin, if it bothers you.
|
||||
|
||||
This directory is a mirror of [`skills/codeman`](../../skills/codeman) in the main repository, kept byte-identical by `scripts/sync-plugin.mjs` and pinned by a test. Edit the source there, never here. `npm run check:plugin` (repo root, needs the `claude` CLI) checks the mirror and validates both manifests. Source, issues and the rest of Codeman: https://github.com/Ark0N/Codeman
|
||||
@@ -7,7 +7,8 @@ description: >-
|
||||
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
|
||||
read their output, and clean up; where available, message claude workers directly
|
||||
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, watch
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, hand
|
||||
work to the local model Codeman installed on this Mac ("use the local model"), watch
|
||||
another session, start and manage workers, or retrieve saved local Codex history
|
||||
by file/native ID or root and descendants with topic/date filters. Live API
|
||||
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
|
||||
@@ -106,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -134,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -186,11 +187,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
|
||||
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||
}
|
||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
@@ -250,21 +255,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
|
||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
|
||||
# a person's decision, made in Codeman's "Run a local model" window.
|
||||
local_model() {
|
||||
local r code body out
|
||||
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
|
||||
code=${r##*$'\n'}; body=${r%$'\n'*}
|
||||
case "$code" in
|
||||
200) out=$(jq -c '
|
||||
def say: (.text // "") as $t
|
||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||
elif $i.state == "installed" then
|
||||
# `start` alone over-promises: it is offered too while another program holds the
|
||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||
if ($i.cli and $i.endpointId and $i.model.id)
|
||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||
then {spawnable:true} + $base
|
||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||
message:(if $i.error then ($i.error.message | say)
|
||||
else "The model is installed, but the server offers no session on it right now." end)} + $base
|
||||
end
|
||||
elif $d.recommendation.reason == "not-macos" then
|
||||
{spawnable:false, reason:"not-mac",
|
||||
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
|
||||
elif $i.state == "idle" then
|
||||
{spawnable:false, reason:"not-installed",
|
||||
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
|
||||
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
|
||||
else
|
||||
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
|
||||
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
|
||||
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
|
||||
+ " Only the Run a local model window continues it.")} + $base
|
||||
end
|
||||
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
|
||||
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
|
||||
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
|
||||
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
|
||||
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
|
||||
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
|
||||
esac
|
||||
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
|
||||
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
|
||||
printf '%s\n' "$out"
|
||||
[ "$(jq -r .spawnable <<<"$out")" = true ]
|
||||
}
|
||||
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
|
||||
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
|
||||
# with exactly what the local model window's Start button sends, and read again until it
|
||||
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
|
||||
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
|
||||
# runs one runtime verb at a time and a second start keeps the runtime that runs.
|
||||
_local_ready_model() {
|
||||
local lm r i=0
|
||||
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
|
||||
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
|
||||
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
|
||||
return 1; }
|
||||
while :; do
|
||||
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
||||
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
|
||||
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
|
||||
# signal unless its harness is claude: drive that one with sendmark.
|
||||
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
||||
# never confirms on anyone's behalf.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
||||
if [ "$mode" = local ]; then
|
||||
lm=$(_local_ready_model) || return 1
|
||||
mode=$(jq -r .cli <<<"$lm")
|
||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
mark=${LOCAL_READY_MARK:-$mark}
|
||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||
return 1; }
|
||||
fi
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||
# deepseek: ask for the same permission posture the Run button sends, because the
|
||||
@@ -272,21 +382,36 @@ spawn_worker() {
|
||||
# an approval row is a worker no fan-out can finish. It is not an escalation --
|
||||
# claude workers already spawn with permissions skipped, and in multi-user mode the
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||
# so a claude worker on the local model never gets one.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||
# The answers that want a PERSON to decide come back as success with no session id.
|
||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
|
||||
return 1
|
||||
fi
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
r=$(_mark_up "$sid" "$mark" 45000)
|
||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"; return 0
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -332,10 +457,10 @@ spawn_worker() {
|
||||
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
|
||||
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
|
||||
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
|
||||
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
|
||||
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
|
||||
# (the mode never disambiguates two workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d spec n m i=0
|
||||
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
||||
@@ -369,7 +494,7 @@ spawn_workers() {
|
||||
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
|
||||
# implement the status contract is the one case that LOOKS like claude but is not:
|
||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
@@ -434,6 +559,108 @@ sendwait() {
|
||||
fi
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
|
||||
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
|
||||
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
|
||||
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
|
||||
# rc 0: the answer file was read, after the marker or once the file held still (below).
|
||||
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
|
||||
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
|
||||
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
|
||||
# (`detail` says why).
|
||||
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
|
||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
|
||||
# full-screen agent can print the marker without it ever matching: opencode redraws its
|
||||
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
|
||||
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
|
||||
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
|
||||
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
|
||||
# read and `matched` says false. It is read from this machine's disk, only when it is a
|
||||
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
|
||||
# (§5.14).
|
||||
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
|
||||
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
|
||||
# worker on another host): rc 0 then means only "the marker printed", and the answer is
|
||||
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
|
||||
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
|
||||
sendmark() {
|
||||
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||
file="$wd/codeman-answer-$tok.md"
|
||||
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
else
|
||||
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
fi
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
|
||||
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
|
||||
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
|
||||
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
|
||||
return "$rc"
|
||||
fi
|
||||
start=$(date +%s); end=$((start + t))
|
||||
while :; do # bounded waits of at most 10 s each, until the timeout
|
||||
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
|
||||
[ "$rem" -le 10 ] || rem=10
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
|
||||
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
|
||||
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
|
||||
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
|
||||
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
|
||||
return 1; }
|
||||
# No marker this round. The answer file is the other way out (see above): it counts once
|
||||
# it is a non-empty regular file whose checksum did not change since the last round.
|
||||
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
|
||||
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
|
||||
else
|
||||
prev=''
|
||||
fi
|
||||
done
|
||||
if [ "$matched" = true ] && [ -n "$file" ]; then
|
||||
while :; do
|
||||
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
|
||||
[ -f "$file" ] && { got=1; break; }
|
||||
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
|
||||
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
|
||||
# once then. A wait that did not answer (server gone) still costs its two seconds.
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
|
||||
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
|
||||
i=$((i + 2))
|
||||
done
|
||||
fi
|
||||
if [ "$got" = 1 ]; then
|
||||
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
|
||||
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
|
||||
return 0
|
||||
fi
|
||||
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
|
||||
if [ "$matched" = true ]; then
|
||||
[ -z "$file" ] && return 0
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 4
|
||||
fi
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 2
|
||||
}
|
||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||
# deepseek write a real transcript; the other modes have none, so read the terminal
|
||||
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
|
||||
@@ -456,10 +683,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
CODEMAN_PREAMBLE=1.50.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -510,7 +737,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
@@ -557,8 +784,8 @@ the time went into deliberation, not the API. The four things that actually cost
|
||||
polled pid for nothing, and shipped its workers without lineage.
|
||||
- **Verifying what is already checked for you.** Two verifications specifically are not
|
||||
worth a call here, because `spawn_worker` carries them: the hooks check (it refuses a
|
||||
name that resolved to a hook-less directory with one local grep, so a worker it hands
|
||||
back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
|
||||
name that resolved to a hook-less directory with one local grep, so a claude worker it
|
||||
hands back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
|
||||
which is dead weight because `wait-output` already blocks on the composer.
|
||||
|
||||
Four things this block leans on, each one link away, no detour needed to run it:
|
||||
@@ -618,6 +845,71 @@ Three things to know before you spawn one:
|
||||
worker whose pane clearly finished: that profile is one of them, so drive it with
|
||||
markers instead.
|
||||
|
||||
### Claude in the cloud, a local model on this Mac
|
||||
|
||||
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
|
||||
on its welcome screen), you can hand work to that model from here. You stay the planner and
|
||||
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
|
||||
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
|
||||
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
|
||||
other mode: `spawn_workers plan review:deepseek summ:local`.
|
||||
|
||||
Reach for it when:
|
||||
|
||||
- **The work is bulk, not judgment**: summarizing a folder, classifying, first drafts of
|
||||
tests, boilerplate. A local turn has no per-token bill.
|
||||
- **The files should stay on this machine**: the worker reads them here, and only what you
|
||||
read back from it enters your own context.
|
||||
- **You want a second opinion from a different model**, with different blind spots.
|
||||
- **The worker should not depend on the network**: the model's runtime listens on this Mac
|
||||
only, started with its cloud features off. You, the lead, still need a connection.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
|
||||
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
|
||||
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
|
||||
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
|
||||
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
|
||||
jq -r '.answer // "(no answer)"' <<<"$R"
|
||||
if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null
|
||||
else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi
|
||||
```
|
||||
|
||||
`sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for
|
||||
the answer in `codeman-answer-<token>.md` in the worker's folder and then a split marker,
|
||||
waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the
|
||||
marker in pieces that never match, so a file that holds still between two checks ends the
|
||||
wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`,
|
||||
not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model
|
||||
printed a token, not that the work is done or right. Review what comes back the way you
|
||||
would review any first draft.
|
||||
|
||||
The limits, before you promise the user anything:
|
||||
|
||||
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
|
||||
window. This skill never installs one: that is a download of many gigabytes the person
|
||||
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
|
||||
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
|
||||
instead of guessing.
|
||||
- **One model, one request at a time.** The runtime loads one model and answers one request
|
||||
at a time, so two local workers take turns rather than work side by side. Fan out to the
|
||||
cloud; give the Mac a queue.
|
||||
- **Slower than a cloud model.** The one measured pair, OpenCode with Qwen3.6 35B-A3B on an
|
||||
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
|
||||
loads the model first. A local worker run by claude spends minutes on its first answer
|
||||
(its fixed prompt is about 36,000 tokens).
|
||||
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
|
||||
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
|
||||
has hooks and `sendwait` works as for any claude worker.
|
||||
- **Spawning can change what the person set.** A stopped runtime is started, which also
|
||||
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
|
||||
open, the window refuses Stop and Remove and names your workers, so delete them as soon
|
||||
as they are done.
|
||||
- **Admin only in multi-user mode, and experimental.** The local model routes answer 403
|
||||
for anyone else (`admin-only`), and they may change in any release.
|
||||
|
||||
## 2. What do you want to do?
|
||||
|
||||
One row per job. Acting on this table alone is correct; the §5 links are the detail.
|
||||
@@ -627,7 +919,8 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
|
||||
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
|
||||
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead. For an agent worker, `sendmark <sid> "<prompt>"` does all of it and reads the answer back | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
|
||||
| hand work to the local model on this Mac (cheap bulk work, files that stay here, a second opinion) | `local_model` says what is installed; `spawn_worker <name> local` (or `name:local` in `spawn_workers`) starts a worker on it; `sendmark` tasks it and returns the answer | [Local model](#claude-in-the-cloud-a-local-model-on-this-mac), [§5.16](reference/verbs.md#516-a-worker-on-the-local-model) |
|
||||
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
|
||||
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
@@ -743,6 +1036,7 @@ these**; open the one row you actually hit.
|
||||
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
|
||||
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
|
||||
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
|
||||
| [5.16 A worker on the local model](reference/verbs.md#516-a-worker-on-the-local-model) | what `local_model` answers, what `spawn_worker <name> local` does step by step, and every `sendmark` outcome |
|
||||
|
||||
## 6. Setup and auth
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -49,11 +49,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
|
||||
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||
}
|
||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
@@ -113,21 +117,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
|
||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
|
||||
# a person's decision, made in Codeman's "Run a local model" window.
|
||||
local_model() {
|
||||
local r code body out
|
||||
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
|
||||
code=${r##*$'\n'}; body=${r%$'\n'*}
|
||||
case "$code" in
|
||||
200) out=$(jq -c '
|
||||
def say: (.text // "") as $t
|
||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||
elif $i.state == "installed" then
|
||||
# `start` alone over-promises: it is offered too while another program holds the
|
||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||
if ($i.cli and $i.endpointId and $i.model.id)
|
||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||
then {spawnable:true} + $base
|
||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||
message:(if $i.error then ($i.error.message | say)
|
||||
else "The model is installed, but the server offers no session on it right now." end)} + $base
|
||||
end
|
||||
elif $d.recommendation.reason == "not-macos" then
|
||||
{spawnable:false, reason:"not-mac",
|
||||
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
|
||||
elif $i.state == "idle" then
|
||||
{spawnable:false, reason:"not-installed",
|
||||
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
|
||||
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
|
||||
else
|
||||
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
|
||||
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
|
||||
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
|
||||
+ " Only the Run a local model window continues it.")} + $base
|
||||
end
|
||||
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
|
||||
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
|
||||
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
|
||||
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
|
||||
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
|
||||
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
|
||||
esac
|
||||
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
|
||||
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
|
||||
printf '%s\n' "$out"
|
||||
[ "$(jq -r .spawnable <<<"$out")" = true ]
|
||||
}
|
||||
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
|
||||
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
|
||||
# with exactly what the local model window's Start button sends, and read again until it
|
||||
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
|
||||
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
|
||||
# runs one runtime verb at a time and a second start keeps the runtime that runs.
|
||||
_local_ready_model() {
|
||||
local lm r i=0
|
||||
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
|
||||
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
|
||||
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
|
||||
return 1; }
|
||||
while :; do
|
||||
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
||||
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
|
||||
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
|
||||
# signal unless its harness is claude: drive that one with sendmark.
|
||||
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
||||
# never confirms on anyone's behalf.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
||||
if [ "$mode" = local ]; then
|
||||
lm=$(_local_ready_model) || return 1
|
||||
mode=$(jq -r .cli <<<"$lm")
|
||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
mark=${LOCAL_READY_MARK:-$mark}
|
||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||
return 1; }
|
||||
fi
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||
# deepseek: ask for the same permission posture the Run button sends, because the
|
||||
@@ -135,21 +244,36 @@ spawn_worker() {
|
||||
# an approval row is a worker no fan-out can finish. It is not an escalation --
|
||||
# claude workers already spawn with permissions skipped, and in multi-user mode the
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||
# so a claude worker on the local model never gets one.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||
# The answers that want a PERSON to decide come back as success with no session id.
|
||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
|
||||
return 1
|
||||
fi
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
r=$(_mark_up "$sid" "$mark" 45000)
|
||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"; return 0
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -195,10 +319,10 @@ spawn_worker() {
|
||||
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
|
||||
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
|
||||
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
|
||||
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
|
||||
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
|
||||
# (the mode never disambiguates two workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d spec n m i=0
|
||||
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
||||
@@ -232,7 +356,7 @@ spawn_workers() {
|
||||
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
|
||||
# implement the status contract is the one case that LOOKS like claude but is not:
|
||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
@@ -297,6 +421,108 @@ sendwait() {
|
||||
fi
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
|
||||
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
|
||||
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
|
||||
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
|
||||
# rc 0: the answer file was read, after the marker or once the file held still (below).
|
||||
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
|
||||
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
|
||||
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
|
||||
# (`detail` says why).
|
||||
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
|
||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
|
||||
# full-screen agent can print the marker without it ever matching: opencode redraws its
|
||||
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
|
||||
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
|
||||
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
|
||||
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
|
||||
# read and `matched` says false. It is read from this machine's disk, only when it is a
|
||||
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
|
||||
# (§5.14).
|
||||
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
|
||||
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
|
||||
# worker on another host): rc 0 then means only "the marker printed", and the answer is
|
||||
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
|
||||
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
|
||||
sendmark() {
|
||||
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||
file="$wd/codeman-answer-$tok.md"
|
||||
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
else
|
||||
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
fi
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
|
||||
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
|
||||
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
|
||||
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
|
||||
return "$rc"
|
||||
fi
|
||||
start=$(date +%s); end=$((start + t))
|
||||
while :; do # bounded waits of at most 10 s each, until the timeout
|
||||
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
|
||||
[ "$rem" -le 10 ] || rem=10
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
|
||||
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
|
||||
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
|
||||
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
|
||||
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
|
||||
return 1; }
|
||||
# No marker this round. The answer file is the other way out (see above): it counts once
|
||||
# it is a non-empty regular file whose checksum did not change since the last round.
|
||||
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
|
||||
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
|
||||
else
|
||||
prev=''
|
||||
fi
|
||||
done
|
||||
if [ "$matched" = true ] && [ -n "$file" ]; then
|
||||
while :; do
|
||||
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
|
||||
[ -f "$file" ] && { got=1; break; }
|
||||
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
|
||||
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
|
||||
# once then. A wait that did not answer (server gone) still costs its two seconds.
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
|
||||
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
|
||||
i=$((i + 2))
|
||||
done
|
||||
fi
|
||||
if [ "$got" = 1 ]; then
|
||||
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
|
||||
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
|
||||
return 0
|
||||
fi
|
||||
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
|
||||
if [ "$matched" = true ]; then
|
||||
[ -z "$file" ] && return 0
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 4
|
||||
fi
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 2
|
||||
}
|
||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||
# deepseek write a real transcript; the other modes have none, so read the terminal
|
||||
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
|
||||
@@ -319,4 +545,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
CODEMAN_PREAMBLE=1.50.0
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -484,7 +484,10 @@ snippet carries the exit code back to you.
|
||||
|
||||
For a **claude** worker with no hooks, ask for the marker in halves in the prompt
|
||||
itself ("print the word WORKDONE immediately followed by `_<token>`") for the same
|
||||
reason, and match the joined token. ⚠️ Against a TUI, match a single space-free token:
|
||||
reason, and match the joined token. For any **agent** worker without `stop` (a hook-less
|
||||
claude, a worker on the local model, every external agent mode but deepseek), the
|
||||
preamble's `sendmark <sid> "<prompt>"` is exactly that in one call, and also asks for the
|
||||
answer in a file it reads back: [§5.16](#516-a-worker-on-the-local-model). ⚠️ Against a TUI, match a single space-free token:
|
||||
a full-screen TUI positions text with cursor movements rather than literal spaces, so
|
||||
the stripped stream can read `Yes,Itrustthisfolder`, and whether a phrase keeps its
|
||||
spaces depends on how the TUI happened to draw it (observed live: some match, some
|
||||
@@ -814,6 +817,103 @@ Rules that matter:
|
||||
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
|
||||
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
|
||||
|
||||
### 5.16 A worker on the local model
|
||||
|
||||
The detail behind [Claude in the cloud, a local model on this
|
||||
Mac](../SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac). Three preamble functions,
|
||||
all read from source and pinned by `test/agent-skill-local-model.test.ts` against a fake
|
||||
server; the local model routes themselves are experimental.
|
||||
|
||||
**`local_model`** is one `GET /api/v1/local-model` (the `/api/v1` alias reaches the
|
||||
experimental route like any other) and prints one JSON line. Exit 0 means a `local` worker
|
||||
can be spawned now (`running: true`) or once `spawn_worker` has started the runtime
|
||||
(`running: false`):
|
||||
|
||||
```json
|
||||
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
|
||||
```
|
||||
|
||||
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
|
||||
|
||||
| `reason` | What it means | What to tell the user |
|
||||
|----------|---------------|-----------------------|
|
||||
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
|
||||
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
|
||||
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
|
||||
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
|
||||
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
|
||||
| `unsupported` | the server has no local model routes (404) | it predates the feature |
|
||||
| `unauthorized`, `unreachable`, `error` | the request itself failed | §6 for a 401 |
|
||||
|
||||
It never downloads, installs or starts anything. Installing is many gigabytes the person
|
||||
confirms in the window, and no route this skill calls can do it.
|
||||
|
||||
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
|
||||
|
||||
1. `local_model`; anything but exit 0 fails the spawn with that reason.
|
||||
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
|
||||
window's Start button sends, then `local_model` again until it reports `running`
|
||||
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
|
||||
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
|
||||
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
|
||||
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
|
||||
session", with the installed agent as the mode. No advisor, which is a cloud model.
|
||||
The server checks again that its own runtime runs and that the model on disk is the one
|
||||
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
||||
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
|
||||
person (a context below the agent's safe floor, a model swap that would evict another
|
||||
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
||||
and no `confirmed*` flag is ever sent.
|
||||
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
|
||||
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
|
||||
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
|
||||
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
|
||||
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
||||
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
|
||||
composer draws, or the spawn is refused before a session exists.
|
||||
|
||||
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
|
||||
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
||||
|
||||
- It joins the prompt onto one line and adds one sentence: write the complete answer to
|
||||
`codeman-answer-<token>.md` in the working directory, and only then end the reply with
|
||||
the word DONE immediately followed by `_<token>`. The typed text never holds the joined
|
||||
marker, so its echo cannot match.
|
||||
- It sends once with the exactly-once body (`$CID-<sid>`, an epoch-second `seq`, one `\r`)
|
||||
and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess.
|
||||
- It waits on `wait-output?match=DONE_<token>&from=buffer` in rounds of at most 10 s until
|
||||
the timeout (default 600 s), then reads the answer file from this machine's disk: only a
|
||||
regular file (a symlink is refused), at most 256 KB.
|
||||
- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws
|
||||
its spinner between the pieces a model streams, so with a reply streamed a character or
|
||||
two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole
|
||||
token (measured on OpenCode 1.3.0 on Linux). So between rounds it also looks at the answer file:
|
||||
once it is non-empty and its checksum held still from one round to the next, it is read
|
||||
and the wait ends with `matched: false`.
|
||||
|
||||
| exit | JSON | Meaning |
|
||||
|------|------|---------|
|
||||
| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) |
|
||||
| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly |
|
||||
| 3 | `ended: true` or `delivered: false` | the worker's pane ended. A pane that ended after it wrote the file but before the marker matched leaves `codeman-answer-<token>.md` in its folder: look there yourself |
|
||||
| 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work |
|
||||
| 1 | `detail` | the send or a wait failed |
|
||||
|
||||
What a marker proves, and what it does not: the model printed the token. Not that the
|
||||
work is done, not that it is right, and with a reasoning model not even that the turn is
|
||||
over, which is why the file is the second gate. What a small local model writes is a
|
||||
first draft; review it before you build on it. The answer files stay in the worker's
|
||||
folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup.
|
||||
`SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where
|
||||
it runs (codex in a folder it runs read-only, an agent that asks before it writes a file,
|
||||
a worker on another host): exit 0 then means only that the marker printed, and with no
|
||||
file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A
|
||||
worker that stops on such a question shows up here as exit 2, not as an error.
|
||||
|
||||
Close every local worker you opened: while one is open the window refuses Stop and
|
||||
Remove, naming it.
|
||||
|
||||
## Experimental bounded inspection and guarded submission
|
||||
|
||||
These three commands and their HTTP routes are labeled experimental, so they may
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
/**
|
||||
* The weekly check of the local model catalogue (src/config/local-model-catalog.ts) and of the
|
||||
* pinned model runtime (OLLAMA_RUNTIME_PIN in src/local-model/runtime.ts) against what the
|
||||
* registry and Ollama's releases serve today.
|
||||
*
|
||||
* npx tsx scripts/check-local-model-catalog.mjs # markdown report on stdout
|
||||
* npm run check:local-model-catalog # the same
|
||||
*
|
||||
* Exit code: 0 nothing drifted, 1 drift found, 2 the check itself failed (network, an answer
|
||||
* that does not parse, a catalogue that does not validate). On 2 the report is still printed
|
||||
* with everything that was found, but it is incomplete, so nothing should act on it.
|
||||
* `.github/workflows/local-model-catalog.yml` runs it every Monday and keeps one issue current.
|
||||
*
|
||||
* Runs under tsx because it imports the TypeScript catalogue and pin, and imports them only in
|
||||
* main(), so the test imports the pure parts without them.
|
||||
*
|
||||
* WHAT A MAC DOWNLOADS. A tag is not one build. Asked with the Accept header Ollama 0.40.15
|
||||
* sends (`pullModelManifest` in server/images.go), the registry answers a manifest LIST for
|
||||
* some tags, one child per runner, and Ollama on Apple silicon takes the first child whose
|
||||
* runner is `mlx`, else `llamacpp`, else `ggml` (`runnerPreferences()` in manifest/manifest.go).
|
||||
* Asked with the plain Docker Accept header the registry answers the llamacpp child instead,
|
||||
* which is NOT what a Mac pulls: that is how `qwen3.6:35b-a3b` came to be "2% above its
|
||||
* manifest" in the catalogue header. So this check asks the way Ollama asks, picks the child
|
||||
* the way a Mac picks it, and compares the row's `bytes` with that child's pull total (every
|
||||
* layer plus the config, which is exactly the `total` a real pull reported on a Mac). A row
|
||||
* that names its `runner` is also held to that child's runner (`catalogueRunner()`), because
|
||||
* the memory figures of a row follow its runner and a change of runner can come with almost
|
||||
* the same size.
|
||||
*
|
||||
* WHAT IS FETCHED, and nothing else:
|
||||
* - `GET /v2/library/<name>/manifests/<tag>`, the tag's manifest or manifest list.
|
||||
* - For a list: `GET /v2/library/<name>/blobs/<digest>` for each child the list names. A child
|
||||
* manifest is stored as a blob (Ollama fetches it the same way, `pullSelectedManifest`). It
|
||||
* is metadata: about 220 KB for an MLX build of 1,055 tensors.
|
||||
* - `GET .../blobs/<digest>` for the config of the child a Mac selects, a few hundred bytes,
|
||||
* for its `requires` (the oldest Ollama that may pull it).
|
||||
* - The GitHub API: the newest release of ollama/ollama, and the release of the pinned version.
|
||||
* A digest from a `layers` entry is never requested: no model weights are ever downloaded.
|
||||
* Every request has its own timeout and a byte cap, nothing is retried, every blob is checked
|
||||
* against its digest, and `GITHUB_TOKEN` is sent to api.github.com only, when it is set.
|
||||
*
|
||||
* THE DIGEST CONTRACT. A row may pin the build it describes as `digest`: the digest of the
|
||||
* manifest a Mac pulls, which is the value `/api/tags` reports after the pull (`sha256:<hex>` or
|
||||
* the bare hex). The catalogue schema does not have that field yet: `LocalModelEntrySchema` is
|
||||
* strict, so it must gain an optional `digest` before a row can carry one, under exactly that
|
||||
* name, or this check never compares it. The catalogue keeps no digest today on purpose
|
||||
* (test/local-model-catalog.test.ts, "holds no download data", refuses a `sha256` in it), so
|
||||
* pinning digests changes that rule too: a decision for the catalogue, not for this script.
|
||||
* Until then the report lists the digests to pin, and a rebuild of about the same size goes
|
||||
* unnoticed (a change of build, mlx against gguf, never does: every pair seen so far differs
|
||||
* by 2% or more, far past the size tolerance, and a row that names its runner is held to it).
|
||||
*
|
||||
* Everything that reaches the report from the network is validated first (digests, sizes,
|
||||
* runner and format names, version tags), because the workflow puts the report in an issue.
|
||||
*
|
||||
* NOT CHECKED: the memory figures, the speeds and whether a coding agent works with a model.
|
||||
* No manifest says anything about those; they change only when somebody measures on a Mac.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ---------- Constants ----------
|
||||
|
||||
export const REGISTRY_BASE = 'https://registry.ollama.ai';
|
||||
export const GITHUB_API_BASE = 'https://api.github.com';
|
||||
/** The repository whose releases the runtime pin follows. */
|
||||
export const RUNTIME_REPO = 'ollama/ollama';
|
||||
|
||||
export const MEDIA_TYPE_MANIFEST = 'application/vnd.docker.distribution.manifest.v2+json';
|
||||
export const MEDIA_TYPE_MANIFEST_LIST = 'application/vnd.ollama.manifest.list.v2+json';
|
||||
/** The Accept header of Ollama 0.40.15's own manifest request: the list first. */
|
||||
export const MANIFEST_ACCEPT = `${MEDIA_TYPE_MANIFEST_LIST}, ${MEDIA_TYPE_MANIFEST}`;
|
||||
|
||||
/** The runner order of Ollama 0.40.15 on darwin/arm64 (`runnerPreferences()`). */
|
||||
export const MAC_RUNNER_PREFERENCE = Object.freeze(['mlx', 'llamacpp', 'ggml']);
|
||||
/** Format names the report prints as they are. Anything else is printed as `unknown`. */
|
||||
export const KNOWN_FORMATS = Object.freeze(['safetensors', 'gguf']);
|
||||
|
||||
/**
|
||||
* How far the size a Mac downloads may sit from the row's `bytes` before it is drift: 0.1%.
|
||||
* The rows that were sized from layers alone sit a few hundred bytes (the config) below the
|
||||
* pull total; any rebuilt model moves by far more than this.
|
||||
*/
|
||||
export const SIZE_TOLERANCE_FRACTION = 0.001;
|
||||
|
||||
/** One request may take this long, connection and body together. */
|
||||
export const REQUEST_TIMEOUT_MS = 20_000;
|
||||
/** A manifest or child manifest larger than this is refused. The MLX build of the 35B is 220 KB. */
|
||||
export const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
|
||||
/** A config blob larger than this is refused. They are a few hundred bytes. */
|
||||
export const MAX_CONFIG_BYTES = 64 * 1024;
|
||||
/** A GitHub release answer larger than this is refused. */
|
||||
export const MAX_GITHUB_BYTES = 4 * 1024 * 1024;
|
||||
/** A manifest list with more children than this is refused. */
|
||||
export const MAX_LIST_CHILDREN = 8;
|
||||
/** A manifest with more layers than this is refused. */
|
||||
export const MAX_LAYERS = 20_000;
|
||||
|
||||
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
||||
const VERSION_PATTERN = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/;
|
||||
const RELEASE_TAG_PATTERN = /^v\d{1,6}\.\d{1,6}\.\d{1,6}$/;
|
||||
const ISO_DAY_PATTERN = /^(\d{4}-\d{2}-\d{2})T/;
|
||||
const USER_AGENT = 'codeman-local-model-catalog-check';
|
||||
|
||||
/** The check could not finish: the run ends with exit code 2. */
|
||||
export class CheckFailure extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'CheckFailure';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Pure helpers ----------
|
||||
|
||||
/** `name:tag` into its two parts. The catalogue's pattern already rules out a slash. */
|
||||
export function splitModelId(id) {
|
||||
const colon = id.indexOf(':');
|
||||
if (colon <= 0 || colon === id.length - 1 || id.includes('/')) throw new CheckFailure(`not a name:tag model id: ${id}`);
|
||||
return { name: id.slice(0, colon), tag: id.slice(colon + 1) };
|
||||
}
|
||||
|
||||
/** `sha256:<hex>` of a body, the way the registry names it. */
|
||||
export function sha256Digest(bytes) {
|
||||
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
||||
}
|
||||
|
||||
/** A digest as `sha256:<64 hex>`, accepting the bare hex `/api/tags` prints. Anything else: undefined. */
|
||||
export function normalizeDigest(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const text = value.trim().toLowerCase();
|
||||
const full = text.startsWith('sha256:') ? text : `sha256:${text}`;
|
||||
return DIGEST_PATTERN.test(full) ? full : undefined;
|
||||
}
|
||||
|
||||
/** A runner name as Ollama compares it (lowercase, trimmed). */
|
||||
export function canonicalRunner(runner) {
|
||||
return typeof runner === 'string' ? runner.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** A runner or format name for the report: one of the known names, else `unknown`. */
|
||||
export function displayName(value, known) {
|
||||
const name = canonicalRunner(value);
|
||||
return known.includes(name) ? name : 'unknown';
|
||||
}
|
||||
|
||||
function isSize(value) {
|
||||
return Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pull of this manifest downloads, in bytes: every layer plus the config. Throws on a
|
||||
* manifest that is not shaped like one, so a malformed answer never reads as a size.
|
||||
*/
|
||||
export function pullTotalBytes(manifest) {
|
||||
if (!manifest || typeof manifest !== 'object') throw new CheckFailure('the manifest is not an object');
|
||||
const { config, layers } = manifest;
|
||||
if (!Array.isArray(layers) || layers.length === 0) throw new CheckFailure('the manifest has no layers');
|
||||
if (layers.length > MAX_LAYERS) throw new CheckFailure(`the manifest has more than ${MAX_LAYERS} layers`);
|
||||
if (!config || !isSize(config.size)) throw new CheckFailure('the manifest has no config size');
|
||||
let total = config.size;
|
||||
for (const layer of layers) {
|
||||
if (!layer || !isSize(layer.size)) throw new CheckFailure('a layer of the manifest has no size');
|
||||
total += layer.size;
|
||||
}
|
||||
if (!Number.isSafeInteger(total)) throw new CheckFailure('the manifest total is not a safe integer');
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The child of a manifest list that Ollama 0.40.15 on Apple silicon pulls: for each runner in
|
||||
* `MAC_RUNNER_PREFERENCE`, the first child (in list order) with that runner. A child whose
|
||||
* mediaType is set to anything but a plain manifest is skipped, as Ollama skips it. Undefined
|
||||
* when no child has a runner a Mac runs.
|
||||
*/
|
||||
export function selectMacChild(children) {
|
||||
if (!Array.isArray(children)) return undefined;
|
||||
for (const runner of MAC_RUNNER_PREFERENCE) {
|
||||
const child = children.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
(entry.mediaType === undefined || entry.mediaType === '' || entry.mediaType === MEDIA_TYPE_MANIFEST) &&
|
||||
canonicalRunner(entry.runner) === runner
|
||||
);
|
||||
if (child) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The runner and format Ollama assumes for a manifest that names neither, from its config's
|
||||
* `model_format` (`MetadataForConfig()` in manifest/manifest.go): safetensors runs on mlx,
|
||||
* gguf on ggml.
|
||||
*/
|
||||
export function metadataForConfig(config) {
|
||||
const format = canonicalRunner(config?.model_format);
|
||||
if (format === 'safetensors') return { runner: 'mlx', format: 'safetensors' };
|
||||
if (format === 'gguf' || format === 'ggml') return { runner: 'ggml', format: 'gguf' };
|
||||
return { runner: '', format };
|
||||
}
|
||||
|
||||
/**
|
||||
* A runner name in the catalogue's words (`LocalModelEntry.runner`): `mlx`, or `llamacpp` for a
|
||||
* GGUF build, which Ollama 0.40.15 serves through llama-server whether the manifest names it
|
||||
* `llamacpp` or names nothing (`ggml`, see `metadataForConfig()`). Undefined for anything else.
|
||||
*/
|
||||
export function catalogueRunner(runner) {
|
||||
const name = canonicalRunner(runner);
|
||||
if (name === 'mlx') return 'mlx';
|
||||
if (name === 'llamacpp' || name === 'ggml') return 'llamacpp';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** `[major, minor, patch]` of a version or `v`-tag, or undefined when it is not one. */
|
||||
export function parseVersion(text) {
|
||||
const match = typeof text === 'string' ? VERSION_PATTERN.exec(text.trim()) : null;
|
||||
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
|
||||
}
|
||||
|
||||
/** Negative when `a` is older than `b`, 0 when equal, positive when newer. Both must parse. */
|
||||
export function compareVersions(a, b) {
|
||||
const left = parseVersion(a);
|
||||
const right = parseVersion(b);
|
||||
if (!left || !right) throw new CheckFailure(`cannot compare versions ${a} and ${b}`);
|
||||
for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] - right[i];
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The signed difference of `actual` from `expected`, as a fraction of `expected`. */
|
||||
export function sizeDifference(expected, actual) {
|
||||
return (actual - expected) / expected;
|
||||
}
|
||||
|
||||
/** Whether `actual` lies within `SIZE_TOLERANCE_FRACTION` of `expected`. */
|
||||
export function withinTolerance(expected, actual, tolerance = SIZE_TOLERANCE_FRACTION) {
|
||||
return Math.abs(actual - expected) <= expected * tolerance;
|
||||
}
|
||||
|
||||
/**
|
||||
* The findings for one catalogue row. `row` is `{ id, bytes, runner?, digest? }` (`runner` once
|
||||
* the catalogue names it, `digest` only once the catalogue stores one); `observed` is what
|
||||
* `inspectModel()` returned for it. A row may have several findings; none means it is clean.
|
||||
*
|
||||
* - `gone`: the tag is not in the registry, so a pull fails.
|
||||
* - `runner`: a Mac now pulls a build of another runner than the row names (a tag that became
|
||||
* a list with an mlx child, or the other way round). The two runners spend memory
|
||||
* differently, so the row's memory figures no longer describe what runs, whatever the size.
|
||||
* - `size`: what a Mac downloads is outside the tolerance of the row's `bytes`.
|
||||
* - `digest`: the row pins a digest and a Mac would now pull another one.
|
||||
* - `requires`: the build a Mac pulls declares a newer Ollama than the pinned runtime, so the
|
||||
* pinned runtime refuses the pull.
|
||||
*/
|
||||
export function classifyModel(row, observed, pinnedVersion) {
|
||||
if (observed.gone) return [{ kind: 'gone' }];
|
||||
const findings = [];
|
||||
const mac = observed.mac;
|
||||
if (row.runner !== undefined) {
|
||||
const actual = catalogueRunner(mac.runner) ?? 'unknown';
|
||||
if (actual !== row.runner) findings.push({ kind: 'runner', expected: String(row.runner), actual });
|
||||
}
|
||||
if (!withinTolerance(row.bytes, mac.bytes)) {
|
||||
findings.push({ kind: 'size', expected: row.bytes, actual: mac.bytes, fraction: sizeDifference(row.bytes, mac.bytes) });
|
||||
}
|
||||
const pinned = normalizeDigest(row.digest);
|
||||
if (row.digest !== undefined && pinned !== mac.digest) {
|
||||
findings.push({ kind: 'digest', expected: pinned ?? 'not a sha256 digest', actual: mac.digest });
|
||||
}
|
||||
if (mac.requires && compareVersions(mac.requires, pinnedVersion) > 0) {
|
||||
findings.push({ kind: 'requires', requires: mac.requires, pinned: pinnedVersion });
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The findings for the runtime pin. `observed` is what `inspectRuntime()` returned.
|
||||
*
|
||||
* - `behind`: a newer Ollama release exists than the pinned one.
|
||||
* - `pinned-release-gone`, `pinned-asset-gone`: the pinned download no longer exists, so every
|
||||
* new install fails at the runtime step.
|
||||
* - `pinned-asset-changed`: the pinned asset's size or sha256 no longer matches the pin. Every
|
||||
* install already refuses it at the hash compare; this is also what a replaced release
|
||||
* asset looks like, so read it as a security signal first.
|
||||
*/
|
||||
export function classifyRuntime(pin, observed) {
|
||||
const findings = [];
|
||||
if (compareVersions(observed.latest.version, pin.version) > 0) {
|
||||
findings.push({ kind: 'behind', pinned: pin.version, latest: observed.latest.version });
|
||||
}
|
||||
if (observed.pinned.gone) findings.push({ kind: 'pinned-release-gone' });
|
||||
else if (!observed.pinned.asset) findings.push({ kind: 'pinned-asset-gone' });
|
||||
else {
|
||||
const { size, digest } = observed.pinned.asset;
|
||||
if (size !== pin.bytes || (digest !== undefined && digest !== `sha256:${pin.sha256}`)) {
|
||||
findings.push({ kind: 'pinned-asset-changed', size, digest });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** 0 clean, 1 drift, 2 the check failed. A failure anywhere wins over drift anywhere. */
|
||||
export function exitCodeFor(result) {
|
||||
const failed =
|
||||
result.errors.length > 0 || result.models.some((model) => model.error) || Boolean(result.runtime?.error);
|
||||
if (failed) return 2;
|
||||
const drift =
|
||||
result.models.some((model) => model.findings.length > 0) || (result.runtime?.findings.length ?? 0) > 0;
|
||||
return drift ? 1 : 0;
|
||||
}
|
||||
|
||||
// ---------- Report ----------
|
||||
|
||||
/** 1234567 as `1,234,567`, independent of the locale. */
|
||||
export function groupDigits(value) {
|
||||
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/** A fraction as a signed percentage with two decimals: `+16.61%`. */
|
||||
export function formatPercent(fraction) {
|
||||
const text = (fraction * 100).toFixed(2);
|
||||
return fraction > 0 ? `+${text}%` : `${text}%`;
|
||||
}
|
||||
|
||||
/** Error text for the report: one line, backticks made quotes, at most 200 characters. */
|
||||
function oneLine(text) {
|
||||
const flat = String(text)
|
||||
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||||
.replace(/`/g, "'")
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.trim();
|
||||
return flat.length > 200 ? `${flat.slice(0, 200)}...` : flat;
|
||||
}
|
||||
|
||||
function buildName(variant) {
|
||||
return `${displayName(variant.runner, MAC_RUNNER_PREFERENCE)}, ${displayName(variant.format, KNOWN_FORMATS)}`;
|
||||
}
|
||||
|
||||
function modelResult(model) {
|
||||
if (model.error) return 'check failed';
|
||||
if (model.findings.length === 0) return 'ok';
|
||||
const names = {
|
||||
gone: 'tag gone',
|
||||
runner: 'runner changed',
|
||||
size: 'size drift',
|
||||
digest: 'digest changed',
|
||||
requires: 'needs a newer runtime',
|
||||
};
|
||||
return model.findings.map((finding) => names[finding.kind]).join(', ');
|
||||
}
|
||||
|
||||
function describeModelFinding(model, finding) {
|
||||
const id = `\`${model.id}\``;
|
||||
switch (finding.kind) {
|
||||
case 'gone':
|
||||
return `${id}: the registry no longer has this tag, so the download fails. Replace or remove the row.`;
|
||||
case 'runner':
|
||||
return (
|
||||
`${id}: a Mac now pulls the ${buildName(model.observed.mac)} build, which runs on ` +
|
||||
`\`${finding.actual}\`, while the row's \`runner\` says \`${displayName(finding.expected, ['mlx', 'llamacpp'])}\`. ` +
|
||||
'The two runners spend memory differently, so the row\'s memory figures no longer describe what runs: ' +
|
||||
'recompute them for the new runner, or pin the row to a single-build tag.'
|
||||
);
|
||||
case 'size':
|
||||
return (
|
||||
`${id}: a Mac downloads the ${buildName(model.observed.mac)} build, ${groupDigits(finding.actual)} bytes, ` +
|
||||
`${formatPercent(finding.fraction)} against the row's \`bytes\` of ${groupDigits(finding.expected)}. ` +
|
||||
'The memory fit reads `bytes`, so correct it (and re-measure if the build changed).'
|
||||
);
|
||||
case 'digest':
|
||||
return `${id}: the row pins \`${finding.expected}\`, a Mac now pulls \`${finding.actual}\`. The tag was pushed again.`;
|
||||
case 'requires':
|
||||
return (
|
||||
`${id}: the build a Mac pulls declares \`requires: ${finding.requires}\`, newer than the pinned runtime ` +
|
||||
`${finding.pinned}, which refuses to pull it.`
|
||||
);
|
||||
default:
|
||||
return `${id}: ${finding.kind}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeRuntimeFinding(finding) {
|
||||
switch (finding.kind) {
|
||||
case 'behind':
|
||||
return (
|
||||
`Ollama ${finding.latest} is out; the pin is ${finding.pinned}. Read its release notes, then move ` +
|
||||
'`OLLAMA_RUNTIME_PIN` (version, URL, size, sha256) and test an install on a Mac.'
|
||||
);
|
||||
case 'pinned-release-gone':
|
||||
return 'The release of the pinned version is gone from GitHub: every new install fails at the runtime step.';
|
||||
case 'pinned-asset-gone':
|
||||
return 'The pinned release no longer carries the pinned archive: every new install fails at the runtime step.';
|
||||
case 'pinned-asset-changed':
|
||||
return (
|
||||
`The pinned archive changed on GitHub (now ${finding.size === undefined ? 'no size' : `${groupDigits(finding.size)} bytes`}` +
|
||||
`${finding.digest ? `, \`${finding.digest}\`` : ''}). Installs refuse it at the hash compare. Find out why before moving the pin.`
|
||||
);
|
||||
default:
|
||||
return finding.kind;
|
||||
}
|
||||
}
|
||||
|
||||
function assetCell(asset) {
|
||||
if (!asset) return 'none';
|
||||
return `${groupDigits(asset.size)} bytes${asset.digest ? `, \`${asset.digest}\`` : ', no digest in the API'}`;
|
||||
}
|
||||
|
||||
/** The markdown report of one run. Pure: everything in it was validated by the IO side. */
|
||||
export function renderReport(result) {
|
||||
const lines = [];
|
||||
const code = exitCodeFor(result);
|
||||
const driftModels = result.models.filter((model) => !model.error && model.findings.length > 0).length;
|
||||
const runtimeDrift = result.runtime && !result.runtime.error ? result.runtime.findings.length > 0 : false;
|
||||
|
||||
lines.push('# Local model catalogue: weekly check', '');
|
||||
lines.push(
|
||||
`Run ${result.checkedAt}. Catalogue of ${result.catalogue.updated} (${result.models.length} models), ` +
|
||||
`runtime pin Ollama ${result.pin.version}.`,
|
||||
''
|
||||
);
|
||||
if (code === 2) {
|
||||
lines.push('**The check failed**: the results below are incomplete. Nothing should act on them.', '');
|
||||
} else if (code === 1) {
|
||||
const parts = [];
|
||||
if (driftModels > 0) parts.push(`${driftModels} of ${result.models.length} models`);
|
||||
if (runtimeDrift) parts.push('the runtime pin');
|
||||
lines.push(`**Drift found**: ${parts.join(' and ')}.`, '');
|
||||
} else {
|
||||
lines.push('**Clean**: every row matches what a Mac downloads today, and the runtime pin is current.', '');
|
||||
}
|
||||
|
||||
for (const error of result.errors) lines.push(`- Check failed: ${oneLine(error)}`);
|
||||
if (result.errors.length > 0) lines.push('');
|
||||
|
||||
lines.push('## Models', '');
|
||||
lines.push(
|
||||
'What a Mac downloads is the build Ollama on Apple silicon selects: the `mlx` build when the tag offers ' +
|
||||
'one, else `llamacpp`, else `ggml`. Its size is the pull total, every layer plus the config. ' +
|
||||
`Tolerance: ${(SIZE_TOLERANCE_FRACTION * 100).toFixed(1)}% of the row's \`bytes\`.`,
|
||||
''
|
||||
);
|
||||
lines.push('| Model | Row `bytes` | A Mac downloads | Difference | Build on a Mac | Requires | Result |');
|
||||
lines.push('| --- | ---: | ---: | ---: | --- | --- | --- |');
|
||||
for (const model of result.models) {
|
||||
const mac = model.observed?.mac;
|
||||
const cells = [
|
||||
`\`${model.id}\``,
|
||||
groupDigits(model.bytes),
|
||||
mac ? groupDigits(mac.bytes) : '',
|
||||
mac ? formatPercent(sizeDifference(model.bytes, mac.bytes)) : '',
|
||||
mac ? buildName(mac) : model.observed?.gone ? 'tag gone' : '',
|
||||
mac?.requires ? `Ollama ${mac.requires}` : '',
|
||||
modelResult(model),
|
||||
];
|
||||
lines.push(`| ${cells.join(' | ')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
const details = [];
|
||||
for (const model of result.models) {
|
||||
if (model.error) details.push(`- \`${model.id}\`: check failed: ${oneLine(model.error)}`);
|
||||
for (const finding of model.findings) details.push(`- ${describeModelFinding(model, finding)}`);
|
||||
const variants = model.observed?.variants ?? [];
|
||||
if (model.findings.some((finding) => finding.kind === 'size' || finding.kind === 'runner') && variants.length > 1) {
|
||||
const offered = variants
|
||||
.map((variant) => `${buildName(variant)} ${groupDigits(variant.bytes)}${variant === model.observed.mac ? ' (a Mac takes this one)' : ''}`)
|
||||
.join('; ');
|
||||
details.push(` The tag offers: ${offered}.`);
|
||||
}
|
||||
}
|
||||
if (details.length > 0) lines.push('### What changed', '', ...details, '');
|
||||
|
||||
lines.push('## Runtime', '');
|
||||
if (!result.runtime || result.runtime.error) {
|
||||
lines.push(`Check failed: ${oneLine(result.runtime?.error ?? 'not run')}`, '');
|
||||
} else {
|
||||
const { latest, pinned } = result.runtime.observed;
|
||||
const archive = result.runtime.assetName;
|
||||
lines.push(`| | Version | Published | \`${archive}\` |`);
|
||||
lines.push('| --- | --- | --- | --- |');
|
||||
lines.push(
|
||||
`| Pinned | ${result.pin.version} | ${pinned.publishedOn ?? ''} | ` +
|
||||
`${pinned.gone ? 'release gone' : assetCell(pinned.asset)} |`
|
||||
);
|
||||
lines.push(`| Newest release | ${latest.version} | ${latest.publishedOn ?? ''} | ${assetCell(latest.asset)} |`);
|
||||
lines.push('');
|
||||
if (result.runtime.findings.length === 0) {
|
||||
lines.push('The pin is the newest release, and its archive still matches the pin.', '');
|
||||
} else {
|
||||
for (const finding of result.runtime.findings) lines.push(`- ${describeRuntimeFinding(finding)}`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(
|
||||
`Release notes: https://github.com/${RUNTIME_REPO}/releases/tag/v${latest.version}. ` +
|
||||
'A digest from the API is a lead, not a pin: check it against the release\'s `sha256sum.txt` first.',
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
const unpinned = result.models.filter((model) => model.digest === undefined && model.observed?.mac);
|
||||
if (unpinned.length > 0) {
|
||||
lines.push('## Digests to pin', '');
|
||||
lines.push(
|
||||
'The catalogue pins no digest for these rows, so a build pushed again at about the same size goes unnoticed. ' +
|
||||
'Today\'s values: the tag\'s own manifest, and the manifest a Mac pulls. The one to pin is the second, the ' +
|
||||
'digest `/api/tags` reports after the pull, as the row field `digest` (which the catalogue schema must ' +
|
||||
'gain first).',
|
||||
''
|
||||
);
|
||||
const measured = unpinned.filter((model) => model.figures === 'measured');
|
||||
if (measured.length > 0) {
|
||||
const ids = measured.map((model) => `\`${model.id}\``).join(', ');
|
||||
lines.push(
|
||||
`Pin first: ${ids}. Its memory figures were measured on one build, and without a digest nothing here ` +
|
||||
'tells that build from a rebuild of about the same size.',
|
||||
''
|
||||
);
|
||||
}
|
||||
lines.push('| Model | Build a Mac pulls | Tag manifest | Manifest a Mac pulls |');
|
||||
lines.push('| --- | --- | --- | --- |');
|
||||
for (const model of unpinned) {
|
||||
lines.push(
|
||||
`| \`${model.id}\` | ${buildName(model.observed.mac)} | \`${model.observed.tagDigest}\` | ` +
|
||||
`\`${model.observed.mac.digest}\` |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('## Not checked', '');
|
||||
lines.push(
|
||||
'The memory figures, the speeds and whether a coding agent works with a model: no manifest says anything ' +
|
||||
'about those. They change only when somebody measures on a Mac.',
|
||||
''
|
||||
);
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
// ---------- IO, with an injected fetch ----------
|
||||
|
||||
/**
|
||||
* One GET, bounded: `REQUEST_TIMEOUT_MS` for the whole exchange and `maxBytes` for the body.
|
||||
* Answers `{ status, body }` (body only for a 2xx). Never retries.
|
||||
*/
|
||||
export async function boundedGet(fetchFn, url, { headers = {}, maxBytes }) {
|
||||
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
||||
let res;
|
||||
try {
|
||||
res = await fetchFn(url, { headers: { 'User-Agent': USER_AGENT, ...headers }, redirect: 'follow', signal });
|
||||
} catch (err) {
|
||||
throw new CheckFailure(`GET ${url} failed: ${describeError(err)}`);
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
return { status: res.status };
|
||||
}
|
||||
const declared = Number(res.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new CheckFailure(`GET ${url}: ${declared} bytes is over the cap of ${maxBytes}`);
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
if (res.body) {
|
||||
const reader = res.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new CheckFailure(`GET ${url}: the body is over the cap of ${maxBytes} bytes`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof CheckFailure) throw err;
|
||||
throw new CheckFailure(`GET ${url}: reading the body failed: ${describeError(err)}`);
|
||||
}
|
||||
return { status: res.status, body: Buffer.concat(chunks) };
|
||||
}
|
||||
|
||||
function describeError(err) {
|
||||
if (err instanceof Error) {
|
||||
const cause = err.cause instanceof Error && err.cause.message ? `: ${err.cause.message}` : '';
|
||||
return `${err.name === 'TimeoutError' ? 'timed out' : err.message}${cause}`;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function parseJson(body, what) {
|
||||
try {
|
||||
return JSON.parse(body.toString('utf8'));
|
||||
} catch {
|
||||
throw new CheckFailure(`${what} is not JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A blob of the repository, checked against its digest. */
|
||||
async function getBlob(fetchFn, name, digest, maxBytes, what) {
|
||||
const url = `${REGISTRY_BASE}/v2/library/${encodeURIComponent(name)}/blobs/${digest}`;
|
||||
const answer = await boundedGet(fetchFn, url, { maxBytes });
|
||||
if (!answer.body) throw new CheckFailure(`${what} ${digest}: the registry answered ${answer.status}`);
|
||||
if (sha256Digest(answer.body) !== digest) throw new CheckFailure(`${what} ${digest}: the body does not match its digest`);
|
||||
return answer.body;
|
||||
}
|
||||
|
||||
/** One build of a tag: its runner, format, digest and pull total, all validated. */
|
||||
function variantOf(manifest, digest, fallback = {}) {
|
||||
return {
|
||||
runner: canonicalRunner(manifest.runner) || canonicalRunner(fallback.runner),
|
||||
format: canonicalRunner(manifest.format) || canonicalRunner(fallback.format),
|
||||
digest,
|
||||
bytes: pullTotalBytes(manifest),
|
||||
configDigest: normalizeDigest(manifest.config?.digest),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the registry serves for `id` today, read the way Ollama on a Mac reads it:
|
||||
* `{ gone: true }`, or `{ tagDigest, variants, mac }` where `mac` is the variant a Mac pulls
|
||||
* (with its config's `requires`). Throws `CheckFailure` when the answer is not usable.
|
||||
*/
|
||||
export async function inspectModel(fetchFn, id) {
|
||||
const { name, tag } = splitModelId(id);
|
||||
const url = `${REGISTRY_BASE}/v2/library/${encodeURIComponent(name)}/manifests/${encodeURIComponent(tag)}`;
|
||||
const answer = await boundedGet(fetchFn, url, { headers: { Accept: MANIFEST_ACCEPT }, maxBytes: MAX_MANIFEST_BYTES });
|
||||
if (answer.status === 404) return { gone: true };
|
||||
if (!answer.body) throw new CheckFailure(`the registry answered ${answer.status} for the manifest`);
|
||||
const tagDigest = sha256Digest(answer.body);
|
||||
const manifest = parseJson(answer.body, 'the manifest');
|
||||
|
||||
let variants;
|
||||
let mac;
|
||||
if (manifest.mediaType === MEDIA_TYPE_MANIFEST_LIST) {
|
||||
const children = Array.isArray(manifest.manifests) ? manifest.manifests : [];
|
||||
if (children.length === 0 || children.length > MAX_LIST_CHILDREN) {
|
||||
throw new CheckFailure(`the manifest list has ${children.length} children`);
|
||||
}
|
||||
const selected = selectMacChild(children);
|
||||
if (!selected) throw new CheckFailure('the manifest list has no build a Mac runs (mlx, llamacpp or ggml)');
|
||||
variants = [];
|
||||
for (const child of children) {
|
||||
const digest = normalizeDigest(child?.digest);
|
||||
if (!digest || !child.digest.startsWith('sha256:')) throw new CheckFailure('a child of the manifest list has no sha256 digest');
|
||||
const body = await getBlob(fetchFn, name, digest, MAX_MANIFEST_BYTES, 'child manifest');
|
||||
const variant = variantOf(parseJson(body, `child manifest ${digest}`), digest, child);
|
||||
variants.push(variant);
|
||||
if (child === selected) mac = variant;
|
||||
}
|
||||
} else if (manifest.mediaType === MEDIA_TYPE_MANIFEST || manifest.mediaType === undefined) {
|
||||
mac = variantOf(manifest, tagDigest);
|
||||
variants = [mac];
|
||||
} else {
|
||||
throw new CheckFailure('the manifest has a media type this check does not know');
|
||||
}
|
||||
|
||||
if (!mac.configDigest) throw new CheckFailure('the build a Mac pulls has no config digest');
|
||||
const config = parseJson(await getBlob(fetchFn, name, mac.configDigest, MAX_CONFIG_BYTES, 'config'), 'the config');
|
||||
// A manifest that names no runner or format gets them from its config, as Ollama fills them.
|
||||
const assumed = metadataForConfig(config);
|
||||
mac.runner ||= assumed.runner;
|
||||
mac.format ||= assumed.format;
|
||||
// A malformed `requires` is ignored, as Ollama ignores it (`checkPullRequires`).
|
||||
const requires = parseVersion(config?.requires);
|
||||
mac.requires = requires ? requires.join('.') : undefined;
|
||||
return { tagDigest, variants, mac };
|
||||
}
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The validated parts of a GitHub release: version, day it was published, the named asset. */
|
||||
function releaseFacts(release, assetName) {
|
||||
if (!release || typeof release.tag_name !== 'string' || !RELEASE_TAG_PATTERN.test(release.tag_name)) {
|
||||
throw new CheckFailure('the release has no vX.Y.Z tag');
|
||||
}
|
||||
const day = typeof release.published_at === 'string' ? ISO_DAY_PATTERN.exec(release.published_at) : null;
|
||||
const raw = Array.isArray(release.assets) ? release.assets.find((asset) => asset?.name === assetName) : undefined;
|
||||
let asset;
|
||||
if (raw) {
|
||||
if (!isSize(raw.size)) throw new CheckFailure(`the release asset ${assetName} has no size`);
|
||||
asset = { size: raw.size, digest: normalizeDigest(raw.digest) };
|
||||
}
|
||||
return { version: release.tag_name.slice(1), publishedOn: day ? day[1] : undefined, asset };
|
||||
}
|
||||
|
||||
/** The newest release and the pinned one, from the GitHub API. Throws `CheckFailure`. */
|
||||
export async function inspectRuntime(fetchFn, pin, token) {
|
||||
const assetName = new URL(pin.url).pathname.split('/').pop();
|
||||
const headers = githubHeaders(token);
|
||||
const latestAnswer = await boundedGet(fetchFn, `${GITHUB_API_BASE}/repos/${RUNTIME_REPO}/releases/latest`, {
|
||||
headers,
|
||||
maxBytes: MAX_GITHUB_BYTES,
|
||||
});
|
||||
if (!latestAnswer.body) throw new CheckFailure(`GitHub answered ${latestAnswer.status} for the newest release`);
|
||||
const latest = releaseFacts(parseJson(latestAnswer.body, 'the newest release'), assetName);
|
||||
|
||||
const pinnedAnswer = await boundedGet(
|
||||
fetchFn,
|
||||
`${GITHUB_API_BASE}/repos/${RUNTIME_REPO}/releases/tags/v${encodeURIComponent(pin.version)}`,
|
||||
{ headers, maxBytes: MAX_GITHUB_BYTES }
|
||||
);
|
||||
let pinned;
|
||||
if (pinnedAnswer.status === 404) pinned = { gone: true };
|
||||
else if (!pinnedAnswer.body) throw new CheckFailure(`GitHub answered ${pinnedAnswer.status} for the pinned release`);
|
||||
else pinned = releaseFacts(parseJson(pinnedAnswer.body, 'the pinned release'), assetName);
|
||||
return { assetName, observed: { latest, pinned } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole check. `catalog` is the validated catalogue, `pin` the runtime pin, `fetchFn` the
|
||||
* fetch to use. Never throws for a network problem: each failure lands in the result, and
|
||||
* `exitCodeFor()` turns it into 2.
|
||||
*/
|
||||
export async function runCheck({ catalog, pin, fetchFn, now, githubToken }) {
|
||||
const result = {
|
||||
checkedAt: `${now.toISOString().slice(0, 16).replace('T', ' ')} UTC`,
|
||||
catalogue: { updated: catalog.updated },
|
||||
pin: { version: pin.version },
|
||||
models: [],
|
||||
runtime: undefined,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Rows in parallel, each row's requests one after another: a handful of rows, a few requests each.
|
||||
result.models = await Promise.all(
|
||||
catalog.models.map(async (row) => {
|
||||
// `digest` is not in the catalogue schema yet: see the file header. `runner` is in the
|
||||
// schema from the catalogue that names each row's runner on; before that it is absent.
|
||||
const digest = /** @type {{ digest?: unknown }} */ (row).digest;
|
||||
const runner = /** @type {{ runner?: unknown }} */ (row).runner;
|
||||
const entry = { id: row.id, bytes: row.bytes, figures: row.figures, digest, findings: [] };
|
||||
try {
|
||||
entry.observed = await inspectModel(fetchFn, row.id);
|
||||
entry.findings = classifyModel({ id: row.id, bytes: row.bytes, runner, digest }, entry.observed, pin.version);
|
||||
} catch (err) {
|
||||
entry.error = err instanceof CheckFailure ? err.message : describeError(err);
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const runtime = await inspectRuntime(fetchFn, pin, githubToken);
|
||||
result.runtime = { ...runtime, findings: classifyRuntime(pin, runtime.observed) };
|
||||
} catch (err) {
|
||||
result.runtime = { error: err instanceof CheckFailure ? err.message : describeError(err), findings: [] };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
async function main() {
|
||||
let catalog;
|
||||
let pin;
|
||||
try {
|
||||
const { getLocalModelCatalog } = await import('../src/config/local-model-catalog.js');
|
||||
const { OLLAMA_RUNTIME_PIN } = await import('../src/local-model/runtime.js');
|
||||
catalog = getLocalModelCatalog();
|
||||
pin = OLLAMA_RUNTIME_PIN;
|
||||
} catch (err) {
|
||||
console.error(`The catalogue or the runtime pin could not be loaded: ${describeError(err)}`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const result = await runCheck({
|
||||
catalog,
|
||||
pin,
|
||||
fetchFn: globalThis.fetch,
|
||||
now: new Date(),
|
||||
githubToken: process.env.GITHUB_TOKEN || undefined,
|
||||
});
|
||||
process.stdout.write(renderReport(result));
|
||||
process.exitCode = exitCodeFor(result);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((err) => {
|
||||
console.error(`The check failed: ${describeError(err)}`);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
}
|
||||
+318
-24
@@ -7,7 +7,8 @@ description: >-
|
||||
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
|
||||
read their output, and clean up; where available, message claude workers directly
|
||||
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, watch
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, hand
|
||||
work to the local model Codeman installed on this Mac ("use the local model"), watch
|
||||
another session, start and manage workers, or retrieve saved local Codex history
|
||||
by file/native ID or root and descendants with topic/date filters. Live API
|
||||
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
|
||||
@@ -106,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -134,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -186,11 +187,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
|
||||
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||
}
|
||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
@@ -250,21 +255,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
|
||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
|
||||
# a person's decision, made in Codeman's "Run a local model" window.
|
||||
local_model() {
|
||||
local r code body out
|
||||
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
|
||||
code=${r##*$'\n'}; body=${r%$'\n'*}
|
||||
case "$code" in
|
||||
200) out=$(jq -c '
|
||||
def say: (.text // "") as $t
|
||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||
elif $i.state == "installed" then
|
||||
# `start` alone over-promises: it is offered too while another program holds the
|
||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||
if ($i.cli and $i.endpointId and $i.model.id)
|
||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||
then {spawnable:true} + $base
|
||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||
message:(if $i.error then ($i.error.message | say)
|
||||
else "The model is installed, but the server offers no session on it right now." end)} + $base
|
||||
end
|
||||
elif $d.recommendation.reason == "not-macos" then
|
||||
{spawnable:false, reason:"not-mac",
|
||||
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
|
||||
elif $i.state == "idle" then
|
||||
{spawnable:false, reason:"not-installed",
|
||||
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
|
||||
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
|
||||
else
|
||||
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
|
||||
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
|
||||
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
|
||||
+ " Only the Run a local model window continues it.")} + $base
|
||||
end
|
||||
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
|
||||
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
|
||||
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
|
||||
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
|
||||
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
|
||||
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
|
||||
esac
|
||||
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
|
||||
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
|
||||
printf '%s\n' "$out"
|
||||
[ "$(jq -r .spawnable <<<"$out")" = true ]
|
||||
}
|
||||
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
|
||||
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
|
||||
# with exactly what the local model window's Start button sends, and read again until it
|
||||
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
|
||||
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
|
||||
# runs one runtime verb at a time and a second start keeps the runtime that runs.
|
||||
_local_ready_model() {
|
||||
local lm r i=0
|
||||
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
|
||||
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
|
||||
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
|
||||
return 1; }
|
||||
while :; do
|
||||
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
||||
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
|
||||
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
|
||||
# signal unless its harness is claude: drive that one with sendmark.
|
||||
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
||||
# never confirms on anyone's behalf.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
||||
if [ "$mode" = local ]; then
|
||||
lm=$(_local_ready_model) || return 1
|
||||
mode=$(jq -r .cli <<<"$lm")
|
||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
mark=${LOCAL_READY_MARK:-$mark}
|
||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||
return 1; }
|
||||
fi
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||
# deepseek: ask for the same permission posture the Run button sends, because the
|
||||
@@ -272,21 +382,36 @@ spawn_worker() {
|
||||
# an approval row is a worker no fan-out can finish. It is not an escalation --
|
||||
# claude workers already spawn with permissions skipped, and in multi-user mode the
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||
# so a claude worker on the local model never gets one.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||
# The answers that want a PERSON to decide come back as success with no session id.
|
||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
|
||||
return 1
|
||||
fi
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
r=$(_mark_up "$sid" "$mark" 45000)
|
||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"; return 0
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -332,10 +457,10 @@ spawn_worker() {
|
||||
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
|
||||
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
|
||||
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
|
||||
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
|
||||
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
|
||||
# (the mode never disambiguates two workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d spec n m i=0
|
||||
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
||||
@@ -369,7 +494,7 @@ spawn_workers() {
|
||||
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
|
||||
# implement the status contract is the one case that LOOKS like claude but is not:
|
||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
@@ -434,6 +559,108 @@ sendwait() {
|
||||
fi
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
|
||||
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
|
||||
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
|
||||
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
|
||||
# rc 0: the answer file was read, after the marker or once the file held still (below).
|
||||
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
|
||||
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
|
||||
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
|
||||
# (`detail` says why).
|
||||
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
|
||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
|
||||
# full-screen agent can print the marker without it ever matching: opencode redraws its
|
||||
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
|
||||
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
|
||||
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
|
||||
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
|
||||
# read and `matched` says false. It is read from this machine's disk, only when it is a
|
||||
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
|
||||
# (§5.14).
|
||||
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
|
||||
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
|
||||
# worker on another host): rc 0 then means only "the marker printed", and the answer is
|
||||
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
|
||||
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
|
||||
sendmark() {
|
||||
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||
file="$wd/codeman-answer-$tok.md"
|
||||
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
else
|
||||
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
fi
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
|
||||
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
|
||||
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
|
||||
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
|
||||
return "$rc"
|
||||
fi
|
||||
start=$(date +%s); end=$((start + t))
|
||||
while :; do # bounded waits of at most 10 s each, until the timeout
|
||||
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
|
||||
[ "$rem" -le 10 ] || rem=10
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
|
||||
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
|
||||
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
|
||||
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
|
||||
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
|
||||
return 1; }
|
||||
# No marker this round. The answer file is the other way out (see above): it counts once
|
||||
# it is a non-empty regular file whose checksum did not change since the last round.
|
||||
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
|
||||
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
|
||||
else
|
||||
prev=''
|
||||
fi
|
||||
done
|
||||
if [ "$matched" = true ] && [ -n "$file" ]; then
|
||||
while :; do
|
||||
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
|
||||
[ -f "$file" ] && { got=1; break; }
|
||||
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
|
||||
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
|
||||
# once then. A wait that did not answer (server gone) still costs its two seconds.
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
|
||||
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
|
||||
i=$((i + 2))
|
||||
done
|
||||
fi
|
||||
if [ "$got" = 1 ]; then
|
||||
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
|
||||
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
|
||||
return 0
|
||||
fi
|
||||
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
|
||||
if [ "$matched" = true ]; then
|
||||
[ -z "$file" ] && return 0
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 4
|
||||
fi
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 2
|
||||
}
|
||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||
# deepseek write a real transcript; the other modes have none, so read the terminal
|
||||
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
|
||||
@@ -456,10 +683,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
CODEMAN_PREAMBLE=1.50.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -510,7 +737,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
@@ -557,8 +784,8 @@ the time went into deliberation, not the API. The four things that actually cost
|
||||
polled pid for nothing, and shipped its workers without lineage.
|
||||
- **Verifying what is already checked for you.** Two verifications specifically are not
|
||||
worth a call here, because `spawn_worker` carries them: the hooks check (it refuses a
|
||||
name that resolved to a hook-less directory with one local grep, so a worker it hands
|
||||
back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
|
||||
name that resolved to a hook-less directory with one local grep, so a claude worker it
|
||||
hands back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
|
||||
which is dead weight because `wait-output` already blocks on the composer.
|
||||
|
||||
Four things this block leans on, each one link away, no detour needed to run it:
|
||||
@@ -618,6 +845,71 @@ Three things to know before you spawn one:
|
||||
worker whose pane clearly finished: that profile is one of them, so drive it with
|
||||
markers instead.
|
||||
|
||||
### Claude in the cloud, a local model on this Mac
|
||||
|
||||
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
|
||||
on its welcome screen), you can hand work to that model from here. You stay the planner and
|
||||
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
|
||||
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
|
||||
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
|
||||
other mode: `spawn_workers plan review:deepseek summ:local`.
|
||||
|
||||
Reach for it when:
|
||||
|
||||
- **The work is bulk, not judgment**: summarizing a folder, classifying, first drafts of
|
||||
tests, boilerplate. A local turn has no per-token bill.
|
||||
- **The files should stay on this machine**: the worker reads them here, and only what you
|
||||
read back from it enters your own context.
|
||||
- **You want a second opinion from a different model**, with different blind spots.
|
||||
- **The worker should not depend on the network**: the model's runtime listens on this Mac
|
||||
only, started with its cloud features off. You, the lead, still need a connection.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
|
||||
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
|
||||
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
|
||||
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
|
||||
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
|
||||
jq -r '.answer // "(no answer)"' <<<"$R"
|
||||
if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null
|
||||
else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi
|
||||
```
|
||||
|
||||
`sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for
|
||||
the answer in `codeman-answer-<token>.md` in the worker's folder and then a split marker,
|
||||
waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the
|
||||
marker in pieces that never match, so a file that holds still between two checks ends the
|
||||
wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`,
|
||||
not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model
|
||||
printed a token, not that the work is done or right. Review what comes back the way you
|
||||
would review any first draft.
|
||||
|
||||
The limits, before you promise the user anything:
|
||||
|
||||
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
|
||||
window. This skill never installs one: that is a download of many gigabytes the person
|
||||
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
|
||||
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
|
||||
instead of guessing.
|
||||
- **One model, one request at a time.** The runtime loads one model and answers one request
|
||||
at a time, so two local workers take turns rather than work side by side. Fan out to the
|
||||
cloud; give the Mac a queue.
|
||||
- **Slower than a cloud model.** The one measured pair, OpenCode with Qwen3.6 35B-A3B on an
|
||||
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
|
||||
loads the model first. A local worker run by claude spends minutes on its first answer
|
||||
(its fixed prompt is about 36,000 tokens).
|
||||
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
|
||||
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
|
||||
has hooks and `sendwait` works as for any claude worker.
|
||||
- **Spawning can change what the person set.** A stopped runtime is started, which also
|
||||
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
|
||||
open, the window refuses Stop and Remove and names your workers, so delete them as soon
|
||||
as they are done.
|
||||
- **Admin only in multi-user mode, and experimental.** The local model routes answer 403
|
||||
for anyone else (`admin-only`), and they may change in any release.
|
||||
|
||||
## 2. What do you want to do?
|
||||
|
||||
One row per job. Acting on this table alone is correct; the §5 links are the detail.
|
||||
@@ -627,7 +919,8 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
|
||||
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
|
||||
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead. For an agent worker, `sendmark <sid> "<prompt>"` does all of it and reads the answer back | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
|
||||
| hand work to the local model on this Mac (cheap bulk work, files that stay here, a second opinion) | `local_model` says what is installed; `spawn_worker <name> local` (or `name:local` in `spawn_workers`) starts a worker on it; `sendmark` tasks it and returns the answer | [Local model](#claude-in-the-cloud-a-local-model-on-this-mac), [§5.16](reference/verbs.md#516-a-worker-on-the-local-model) |
|
||||
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
|
||||
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
@@ -743,6 +1036,7 @@ these**; open the one row you actually hit.
|
||||
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
|
||||
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
|
||||
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
|
||||
| [5.16 A worker on the local model](reference/verbs.md#516-a-worker-on-the-local-model) | what `local_model` answers, what `spawn_worker <name> local` does step by step, and every `sendmark` outcome |
|
||||
|
||||
## 6. Setup and auth
|
||||
|
||||
|
||||
+242
-16
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||
# from that body (test/agent-skill.test.ts pins it).
|
||||
@@ -49,11 +49,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
|
||||
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
|
||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||
}
|
||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
@@ -113,21 +117,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
|
||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
|
||||
# a person's decision, made in Codeman's "Run a local model" window.
|
||||
local_model() {
|
||||
local r code body out
|
||||
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
|
||||
code=${r##*$'\n'}; body=${r%$'\n'*}
|
||||
case "$code" in
|
||||
200) out=$(jq -c '
|
||||
def say: (.text // "") as $t
|
||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||
elif $i.state == "installed" then
|
||||
# `start` alone over-promises: it is offered too while another program holds the
|
||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||
if ($i.cli and $i.endpointId and $i.model.id)
|
||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||
then {spawnable:true} + $base
|
||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||
message:(if $i.error then ($i.error.message | say)
|
||||
else "The model is installed, but the server offers no session on it right now." end)} + $base
|
||||
end
|
||||
elif $d.recommendation.reason == "not-macos" then
|
||||
{spawnable:false, reason:"not-mac",
|
||||
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
|
||||
elif $i.state == "idle" then
|
||||
{spawnable:false, reason:"not-installed",
|
||||
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
|
||||
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
|
||||
else
|
||||
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
|
||||
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
|
||||
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
|
||||
+ " Only the Run a local model window continues it.")} + $base
|
||||
end
|
||||
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
|
||||
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
|
||||
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
|
||||
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
|
||||
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
|
||||
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
|
||||
esac
|
||||
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
|
||||
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
|
||||
printf '%s\n' "$out"
|
||||
[ "$(jq -r .spawnable <<<"$out")" = true ]
|
||||
}
|
||||
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
|
||||
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
|
||||
# with exactly what the local model window's Start button sends, and read again until it
|
||||
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
|
||||
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
|
||||
# runs one runtime verb at a time and a second start keeps the runtime that runs.
|
||||
_local_ready_model() {
|
||||
local lm r i=0
|
||||
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
|
||||
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
|
||||
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
|
||||
return 1; }
|
||||
while :; do
|
||||
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
|
||||
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
|
||||
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
||||
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
|
||||
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
|
||||
# signal unless its harness is claude: drive that one with sendmark.
|
||||
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
||||
# never confirms on anyone's behalf.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
||||
if [ "$mode" = local ]; then
|
||||
lm=$(_local_ready_model) || return 1
|
||||
mode=$(jq -r .cli <<<"$lm")
|
||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
mark=${LOCAL_READY_MARK:-$mark}
|
||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||
return 1; }
|
||||
fi
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||
# deepseek: ask for the same permission posture the Run button sends, because the
|
||||
@@ -135,21 +244,36 @@ spawn_worker() {
|
||||
# an approval row is a worker no fan-out can finish. It is not an escalation --
|
||||
# claude workers already spawn with permissions skipped, and in multi-user mode the
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||
# so a claude worker on the local model never gets one.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||
# The answers that want a PERSON to decide come back as success with no session id.
|
||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
|
||||
return 1
|
||||
fi
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
r=$(_mark_up "$sid" "$mark" 45000)
|
||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
printf '%s\n' "$sid"; return 0
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -195,10 +319,10 @@ spawn_worker() {
|
||||
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
|
||||
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
|
||||
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
|
||||
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
|
||||
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
|
||||
# (the mode never disambiguates two workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d spec n m i=0
|
||||
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
||||
@@ -232,7 +356,7 @@ spawn_workers() {
|
||||
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
|
||||
# implement the status contract is the one case that LOOKS like claude but is not:
|
||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||
# pane clearly finished means that profile, so switch that worker to markers.
|
||||
@@ -297,6 +421,108 @@ sendwait() {
|
||||
fi
|
||||
printf '%s\n' "$r"
|
||||
}
|
||||
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
|
||||
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
|
||||
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
|
||||
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
|
||||
# rc 0: the answer file was read, after the marker or once the file held still (below).
|
||||
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
|
||||
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
|
||||
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
|
||||
# (`detail` says why).
|
||||
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
|
||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
|
||||
# full-screen agent can print the marker without it ever matching: opencode redraws its
|
||||
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
|
||||
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
|
||||
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
|
||||
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
|
||||
# read and `matched` says false. It is read from this machine's disk, only when it is a
|
||||
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
|
||||
# (§5.14).
|
||||
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
|
||||
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
|
||||
# worker on another host): rc 0 then means only "the marker printed", and the answer is
|
||||
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
|
||||
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
|
||||
sendmark() {
|
||||
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
|
||||
case "$p" in *$'\n'*|*$'\r'*)
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||
file="$wd/codeman-answer-$tok.md"
|
||||
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
else
|
||||
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
|
||||
fi
|
||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
|
||||
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
|
||||
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
|
||||
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
|
||||
return "$rc"
|
||||
fi
|
||||
start=$(date +%s); end=$((start + t))
|
||||
while :; do # bounded waits of at most 10 s each, until the timeout
|
||||
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
|
||||
[ "$rem" -le 10 ] || rem=10
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
|
||||
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
|
||||
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
|
||||
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
|
||||
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
|
||||
return 1; }
|
||||
# No marker this round. The answer file is the other way out (see above): it counts once
|
||||
# it is a non-empty regular file whose checksum did not change since the last round.
|
||||
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
|
||||
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
|
||||
else
|
||||
prev=''
|
||||
fi
|
||||
done
|
||||
if [ "$matched" = true ] && [ -n "$file" ]; then
|
||||
while :; do
|
||||
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
|
||||
[ -f "$file" ] && { got=1; break; }
|
||||
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
|
||||
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
|
||||
# once then. A wait that did not answer (server gone) still costs its two seconds.
|
||||
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
|
||||
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
|
||||
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
|
||||
i=$((i + 2))
|
||||
done
|
||||
fi
|
||||
if [ "$got" = 1 ]; then
|
||||
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
|
||||
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
|
||||
return 0
|
||||
fi
|
||||
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
|
||||
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
|
||||
if [ "$matched" = true ]; then
|
||||
[ -z "$file" ] && return 0
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 4
|
||||
fi
|
||||
[ "$ended" = 1 ] && return 3
|
||||
return 2
|
||||
}
|
||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||
# deepseek write a real transcript; the other modes have none, so read the terminal
|
||||
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
|
||||
@@ -319,4 +545,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.44.0
|
||||
CODEMAN_PREAMBLE=1.50.0
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -484,7 +484,10 @@ snippet carries the exit code back to you.
|
||||
|
||||
For a **claude** worker with no hooks, ask for the marker in halves in the prompt
|
||||
itself ("print the word WORKDONE immediately followed by `_<token>`") for the same
|
||||
reason, and match the joined token. ⚠️ Against a TUI, match a single space-free token:
|
||||
reason, and match the joined token. For any **agent** worker without `stop` (a hook-less
|
||||
claude, a worker on the local model, every external agent mode but deepseek), the
|
||||
preamble's `sendmark <sid> "<prompt>"` is exactly that in one call, and also asks for the
|
||||
answer in a file it reads back: [§5.16](#516-a-worker-on-the-local-model). ⚠️ Against a TUI, match a single space-free token:
|
||||
a full-screen TUI positions text with cursor movements rather than literal spaces, so
|
||||
the stripped stream can read `Yes,Itrustthisfolder`, and whether a phrase keeps its
|
||||
spaces depends on how the TUI happened to draw it (observed live: some match, some
|
||||
@@ -814,6 +817,103 @@ Rules that matter:
|
||||
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
|
||||
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
|
||||
|
||||
### 5.16 A worker on the local model
|
||||
|
||||
The detail behind [Claude in the cloud, a local model on this
|
||||
Mac](../SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac). Three preamble functions,
|
||||
all read from source and pinned by `test/agent-skill-local-model.test.ts` against a fake
|
||||
server; the local model routes themselves are experimental.
|
||||
|
||||
**`local_model`** is one `GET /api/v1/local-model` (the `/api/v1` alias reaches the
|
||||
experimental route like any other) and prints one JSON line. Exit 0 means a `local` worker
|
||||
can be spawned now (`running: true`) or once `spawn_worker` has started the runtime
|
||||
(`running: false`):
|
||||
|
||||
```json
|
||||
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
|
||||
```
|
||||
|
||||
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
|
||||
|
||||
| `reason` | What it means | What to tell the user |
|
||||
|----------|---------------|-----------------------|
|
||||
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
|
||||
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
|
||||
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
|
||||
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
|
||||
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
|
||||
| `unsupported` | the server has no local model routes (404) | it predates the feature |
|
||||
| `unauthorized`, `unreachable`, `error` | the request itself failed | §6 for a 401 |
|
||||
|
||||
It never downloads, installs or starts anything. Installing is many gigabytes the person
|
||||
confirms in the window, and no route this skill calls can do it.
|
||||
|
||||
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
|
||||
|
||||
1. `local_model`; anything but exit 0 fails the spawn with that reason.
|
||||
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
|
||||
window's Start button sends, then `local_model` again until it reports `running`
|
||||
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
|
||||
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
|
||||
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
|
||||
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
|
||||
session", with the installed agent as the mode. No advisor, which is a cloud model.
|
||||
The server checks again that its own runtime runs and that the model on disk is the one
|
||||
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
||||
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
|
||||
person (a context below the agent's safe floor, a model swap that would evict another
|
||||
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
||||
and no `confirmed*` flag is ever sent.
|
||||
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
|
||||
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
|
||||
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
|
||||
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
|
||||
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
||||
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
|
||||
composer draws, or the spawn is refused before a session exists.
|
||||
|
||||
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
|
||||
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
||||
|
||||
- It joins the prompt onto one line and adds one sentence: write the complete answer to
|
||||
`codeman-answer-<token>.md` in the working directory, and only then end the reply with
|
||||
the word DONE immediately followed by `_<token>`. The typed text never holds the joined
|
||||
marker, so its echo cannot match.
|
||||
- It sends once with the exactly-once body (`$CID-<sid>`, an epoch-second `seq`, one `\r`)
|
||||
and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess.
|
||||
- It waits on `wait-output?match=DONE_<token>&from=buffer` in rounds of at most 10 s until
|
||||
the timeout (default 600 s), then reads the answer file from this machine's disk: only a
|
||||
regular file (a symlink is refused), at most 256 KB.
|
||||
- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws
|
||||
its spinner between the pieces a model streams, so with a reply streamed a character or
|
||||
two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole
|
||||
token (measured on OpenCode 1.3.0 on Linux). So between rounds it also looks at the answer file:
|
||||
once it is non-empty and its checksum held still from one round to the next, it is read
|
||||
and the wait ends with `matched: false`.
|
||||
|
||||
| exit | JSON | Meaning |
|
||||
|------|------|---------|
|
||||
| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) |
|
||||
| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly |
|
||||
| 3 | `ended: true` or `delivered: false` | the worker's pane ended. A pane that ended after it wrote the file but before the marker matched leaves `codeman-answer-<token>.md` in its folder: look there yourself |
|
||||
| 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work |
|
||||
| 1 | `detail` | the send or a wait failed |
|
||||
|
||||
What a marker proves, and what it does not: the model printed the token. Not that the
|
||||
work is done, not that it is right, and with a reasoning model not even that the turn is
|
||||
over, which is why the file is the second gate. What a small local model writes is a
|
||||
first draft; review it before you build on it. The answer files stay in the worker's
|
||||
folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup.
|
||||
`SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where
|
||||
it runs (codex in a folder it runs read-only, an agent that asks before it writes a file,
|
||||
a worker on another host): exit 0 then means only that the marker printed, and with no
|
||||
file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A
|
||||
worker that stops on such a question shows up here as exit 2, not as an error.
|
||||
|
||||
Close every local worker you opened: while one is open the window refuses Stop and
|
||||
Remove, naming it.
|
||||
|
||||
## Experimental bounded inspection and guarded submission
|
||||
|
||||
These three commands and their HTTP routes are labeled experimental, so they may
|
||||
|
||||
+6
-2
@@ -250,7 +250,7 @@ function describeSkillResult(result: AgentSkillApplyResult, target: string): { o
|
||||
},
|
||||
symlink: {
|
||||
ok: false,
|
||||
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
|
||||
text: `${target}, its parent or its reference folder is a symlink, refusing to write through it.`,
|
||||
},
|
||||
};
|
||||
return messages[result];
|
||||
@@ -320,7 +320,11 @@ skillCmd
|
||||
if (result === 'symlink') {
|
||||
// A shared-skills layout (`.agents/skills -> ../.claude/skills`) is deliberate:
|
||||
// skip it rather than fail the whole sweep.
|
||||
console.log(palette.warn(`- Skipped ${target}: it (or its parent) is a symlink, so it was not touched`));
|
||||
console.log(
|
||||
palette.warn(
|
||||
`- Skipped ${target}: it, its parent or its reference folder is a symlink, so it was not touched`
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
removedAny = true;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* @module config/attachment-guard
|
||||
*/
|
||||
|
||||
import { sep } from 'node:path';
|
||||
import { join, sep } from 'node:path';
|
||||
import { isSensitivePath } from '../web/sensitive-path.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
|
||||
|
||||
@@ -131,3 +131,25 @@ export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly
|
||||
if (isSensitivePath(absPath)) return true;
|
||||
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `isBlockedAttachmentPath`, for a directory. The sensitive patterns describe file locations
|
||||
* such as ~/.ssh/<key>, so a child probe is what refuses a folder like ~/.ssh itself.
|
||||
*/
|
||||
export function isBlockedAttachmentDirectory(dir: string, blockedTrees: readonly string[]): boolean {
|
||||
return (
|
||||
isBlockedAttachmentPath(dir, blockedTrees) ||
|
||||
isBlockedAttachmentPath(join(dir, '__codeman_blocked_dir_probe__'), blockedTrees)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the attachment guard refuses `root` as the root of a worktree outside the session
|
||||
* folder: workspace confinement is on, the folder is blocked or sensitive, or it surrounds a
|
||||
* blocked tree. `root` must be absolute and symlink-resolved.
|
||||
*/
|
||||
export function isOutsideWorktreeRootRefused(root: string, guard: AttachmentGuardConfig): boolean {
|
||||
if (guard.confineToWorkspace) return true;
|
||||
if (isBlockedAttachmentDirectory(root, guard.blockedTrees)) return true;
|
||||
return guard.blockedTrees.some((tree) => isUnderTree(tree, root));
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
* is downloaded is a compiled constant of its own (src/local-model/runtime.ts).
|
||||
* - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can
|
||||
* only ever name a model of the default registry and namespace.
|
||||
* - A model row names the `runner` a Mac uses for its tag, and its memory figures follow that
|
||||
* runner's rule (MEMORY below). A row's figures are its OWN: measured on that model, or
|
||||
* computed from that model's architecture. test/local-model-catalog.test.ts recomputes every
|
||||
* computed figure from a table of architectures and refuses a row that is not in it, so a
|
||||
* new row needs its model's config.json read, not a figure borrowed from a neighbour.
|
||||
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
|
||||
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
|
||||
* take a custom model is checked against the registry at call time, in
|
||||
@@ -19,48 +24,131 @@
|
||||
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
|
||||
* so a bad row fails the gate and never reaches the route.
|
||||
*
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-06):
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-08). [V] read
|
||||
* at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
*
|
||||
* | Figure | Source | Checked |
|
||||
* |----------------------|-----------------------------------------------------------|--------------------------------------|
|
||||
* | `id` | the registry's tag | yes, by the facts review |
|
||||
* | `bytes` | `qwen3.6:35b-a3b`: the total of a real pull on a Mac | that row: measured. It is an MLX |
|
||||
* | | (23,105,798,956). Every other row: the sum of the layer | build on a Mac, 2% above its |
|
||||
* | | sizes of the tag's registry manifest | manifest. The others: not on a Mac |
|
||||
* | `heldBytesPerToken`, | measured for `qwen3.6:35b-a3b` on an Apple M5 Pro with | that row: measured. The others carry |
|
||||
* | `overheadBytes`, | 48 GB: 36,000 to 38,000 bytes held per token of context, | the SAME three figures, not their |
|
||||
* | `peakExtraBytes` | 2.5 to 3 GB beside the weights, and about 5 GB more for a | own: nobody ran them. `figures` says |
|
||||
* | | moment while a long prompt is processed | which row is which |
|
||||
* | `license` | the model card (all Apache-2.0) | read by the research, not re-checked |
|
||||
* | `paramsB` | 27.8 from the model card; the others are the count in the | no. Only the 12 billion line reads |
|
||||
* | | model's name | it, and no row is near that line |
|
||||
* | `contexts`, `rank` | a judgement of the design | no |
|
||||
* | `speedPer100Gbps` | one published run on an M1 Max (400 GB/s): generation | the run was read. It stands for less |
|
||||
* | | only, short prompt. The two q8_0 rows are SCALED by file | than a session: no long prompt, no |
|
||||
* | | size, not measured | thinking tokens |
|
||||
* | `id` | the registry's tag. The GGUF tags in lowercase: the | [V] every row: the registry answers |
|
||||
* | | registry spells them `-q4_K_M`, and an id here is | the lowercase id with the same |
|
||||
* | | lowercase (`LOCAL_MODEL_ID_PATTERN`) | manifest. NOT checked: that the |
|
||||
* | | | runtime lists the pulled model under |
|
||||
* | | | that name (Codeman compares exactly) |
|
||||
* | `runner` | the build the runtime 0.40.15 pulls on Apple silicon: the | [V] every row |
|
||||
* | | MLX child of a manifest list, else the one manifest | |
|
||||
* | | (`runnerPreferences()`: mlx, then llamacpp) | |
|
||||
* | `bytes` | every layer plus the config of that build: the `total` | [V] every row. [M] for |
|
||||
* | | a pull reports | `qwen3.6:35b-a3b`, byte for byte |
|
||||
* | `heldBytesPerToken`, | `qwen3.6:35b-a3b`: [M] on an Apple M5 Pro with 48 GB. | the others: [C], never run on a Mac. |
|
||||
* | `overheadBytes`, | Every other row: [C] from its model's config.json for its | `figures` says which row is which. |
|
||||
* | `peakExtraBytes` | runner, see MEMORY below. No row borrows another's | test/local-model-catalog.test.ts |
|
||||
* | | figures: two rows share figures only when they share the | recomputes every one |
|
||||
* | | architecture and the runner | |
|
||||
* | `license`, `paramsB` | the model card (`paramsB` is the TOTAL, not the active | [V] |
|
||||
* | | parameters) | |
|
||||
* | `rank` | a judgement on the agentic coding results of the vendors' | no: vendor tables disagree with each |
|
||||
* | | cards (SWE-bench Verified, SWE-bench Pro, Terminal-Bench) | other by several points |
|
||||
* | `contexts` | up to the model's own window, and at least the 65536 a | no |
|
||||
* | | harness needs | |
|
||||
* | `speedPer100Gbps` | one published run per model family on an M1 Max | the run was read. It stands for less |
|
||||
* | | (400 GB/s): generation only, short prompt. A row that is | than a session: no long prompt, no |
|
||||
* | | not that run is SCALED from it, and says how | thinking tokens. An MTP build is |
|
||||
* | | | faster than the run without it |
|
||||
* | `tested` | our own run of the model with a harness on a real Mac | one entry, with its measured speed |
|
||||
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
|
||||
* | harness `minContext` | 65536: the runtime's documentation for both harnesses. | 65536 yes. 32768 no, which is why |
|
||||
* | | 32768: recalled | that row is gated |
|
||||
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. 32768 no, which is why |
|
||||
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | that row is gated |
|
||||
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | |
|
||||
* | | leaves 14,000 to 16,000 tokens, less than one default | |
|
||||
* | | 2,000-line file read | |
|
||||
* | `chips` | the bandwidth column of | yes, read at the source for this |
|
||||
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
|
||||
*
|
||||
* The memory figures decide a lot and rest on ONE measured model, so for every other row they
|
||||
* can be wrong in either direction:
|
||||
* - For the 9B rows they may be too HIGH (the 5 GB for a moment was seen with a prompt of
|
||||
* 89,000 tokens on the 35B model). That errs on the careful side: a small Mac is told
|
||||
* "none" before it is told to download something that may not fit.
|
||||
* - For the two dense `qwen3.8:27b` rows the per-token figure may be too LOW: computed from
|
||||
* its config alone that model holds 64 KiB per token, more than the 37,000 bytes measured
|
||||
* on the 35B (whose own computed figure was 20 KiB). Their need at a large context is
|
||||
* therefore the least certain figure of the catalogue.
|
||||
* A row gets its own figures when somebody measures it. Until then the check after the
|
||||
* download (the runtime must report the whole model on the GPU) is what catches a row that
|
||||
* was judged too kindly.
|
||||
* MEMORY. The need is `bytes + heldBytesPerToken x context + overheadBytes + peakExtraBytes`
|
||||
* (src/local-model/recommend.ts). The two runners spend memory differently, so a row's three
|
||||
* figures are filled by the rule of its runner:
|
||||
*
|
||||
* - `llamacpp` (llama-server on Metal; runtime v0.40.15 source, llm/llama_server.go, and the
|
||||
* llama.cpp it bundles, b12111). It allocates the K/V cache for the WHOLE context when the
|
||||
* model loads, in the cache type the runtime sets (`RUNTIME_KV_CACHE_TYPE` in
|
||||
* src/local-model/runtime.ts, q8_0: 34 bytes per 32 values), and does not grow after that.
|
||||
* "MTP" below is a GGUF with a multi-token-prediction layer (`nextn_predict_layers` 1 in
|
||||
* its header, read on 2026-11-08 from the first bytes of each model layer: the two `-mtp`
|
||||
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not). For
|
||||
* such a model the runtime always passes `--spec-type draft-mtp --spec-draft-n-max N`, N
|
||||
* being the tag's own `draft_num_predict` (its params layer: 4 for
|
||||
* `qwen3.8:27b-mtp-q4_k_m`, 2 for `qwen3.6:35b-a3b-mtp-q4_k_m`) or the runtime's default
|
||||
* of 4 (`qwen3.8:27b-q8_0` sets none).
|
||||
* - held = attention layers x K/V heads x head dim x 2 (K and V) x 34/32. An MTP model
|
||||
* adds its prediction layer, whose cache llama-server keeps in a draft context of the
|
||||
* same length in the draft's own cache type, f16 by default (2 bytes per value).
|
||||
* - overhead = the fixed caches (the f32 recurrent state of the linear-attention layers,
|
||||
* or the sliding-window cache of `n_swa + 512` cells), held 1 + N times for an MTP
|
||||
* model (llama.cpp keeps one recurrent state per drafted token for rollback,
|
||||
* `need_n_rs_seq()`), + the context checkpoints: `LLAMA_ARG_CTX_CHECKPOINTS` host copies
|
||||
* (`RUNTIME_CTX_CHECKPOINTS`, 8) of the state that cannot be rolled back (the recurrent
|
||||
* state, or the sliding-window cache), + 1.0 GB for the runner's own buffers (calibrated
|
||||
* on a third-party table of a 27B model on llama.cpp: 8.4 GB at 4,096 tokens with
|
||||
* 7.15 GB of weights), + the 1024 MiB host prompt cache that `LLAMA_ARG_CACHE_RAM`
|
||||
* allows (`RUNTIME_HOST_PROMPT_CACHE_MIB`). The checkpoints and the prompt cache are
|
||||
* host memory, not Metal buffers; on a Mac both come out of the same memory, so they
|
||||
* are counted like the rest.
|
||||
* - peak = 0: its compute buffers are allocated at load and are inside the 1.0 GB.
|
||||
* The one outside check of this rule: a third party measured 65,600 bytes per token of
|
||||
* growth at f16 for a model of the Qwen3.8-27B architecture, where its config gives 65,536.
|
||||
* The checkpoints and the rollback copies are read in the llama.cpp source
|
||||
* (tools/server/server-context.cpp `create_checkpoint`, src/llama-memory-recurrent.cpp),
|
||||
* not measured.
|
||||
* - `mlx` (runtime v0.40.15 source, mlxrunner/). It grows the K/V cache in bf16 with the
|
||||
* tokens used, keeps the recurrent state in f32 and a snapshot of it every 8,192 tokens,
|
||||
* and keeps up to 8 GiB of paged-out prefix snapshots (prefix_cache.go `maxPagedOutBytes`,
|
||||
* not configurable). Third-party OpenCode sessions show that growth (runtime issues 17924
|
||||
* and 18131), and the runtime books an MLX model at its weights alone (issue 19417).
|
||||
* - `qwen3.6:35b-a3b`: MEASURED. 36,000 to 38,000 bytes held per token after prompts of
|
||||
* 89,000 and 167,000 tokens, 2.5 to 3 GB beside the weights, about 5 GB more for a
|
||||
* moment while a long prompt was processed. NOT covered by that measurement: a session
|
||||
* of many requests, in which the prefix snapshots may add up to 8 GiB. Its figures stay
|
||||
* the measured ones (with 8 GiB on top it would not be offered on the Mac it ran on),
|
||||
* and a 30-request session on it is the first thing to measure next.
|
||||
* - every other `mlx` row: held = the K/V cache in bf16 + one recurrent snapshot per
|
||||
* 8,192 tokens, computed from the config, x 37,000 / 28,340: the ratio of what the
|
||||
* measured row really held to the same computation for it (same runner, same
|
||||
* architecture family). Overhead = the measured row's 3 GB + the whole 8 GiB prefix
|
||||
* budget. Peak = the measured row's 5 GB, NOT scaled: what makes it up was not
|
||||
* measured.
|
||||
*
|
||||
* After the download the install checks that the runtime holds the whole model on the GPU.
|
||||
* For a `llamacpp` row both of the runtime's figures are the buffers llama-server reports
|
||||
* allocating (weights, K/V cache, compute; `MemorySize()` in llm/llama_server.go), so the
|
||||
* check proves the whole load is on the GPU, whatever the cache type; what grows after the
|
||||
* load in host memory (the context checkpoints and the prompt cache) is counted above, not
|
||||
* checked. For an `mlx` row it proves only the weights (issue 19417): the figures above are
|
||||
* all that stands between such a row and swap.
|
||||
*
|
||||
* Models set aside on purpose (a weekly review should look at them again):
|
||||
* - `bonsai-2:27b`, `bonsai-2:9b` (ternary builds of Qwen3.8-27B and Qwen3.5-9B; the 27B
|
||||
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop, and
|
||||
* whether 2-bit weights keep tool calls reliable was never measured.
|
||||
* - `ornith-1.5:9b`, `ornith-1.5:35b`, `ornith-1.5:397b` (GGUF only): strong cards, but their
|
||||
* manifests name no renderer and no tool parser, so whether tool calls work through the
|
||||
* runtime is unknown (runtime pull request 17965 is open). `ornith-1.5:122b` has both.
|
||||
* - `gpt-oss:20b`: weak in agentic coding (SWE-bench Verified 52.4, Terminal-Bench 2.1 15.2
|
||||
* in NVIDIA's table).
|
||||
* - `flint:40b`: runtime issue 19191 breaks integer tool arguments after the first call.
|
||||
* - `lfm2.5:24b-a2b`: its licence allows commercial use only below USD 10M of revenue.
|
||||
* - `laguna-xs-2.1`: the runtime's own page names an open problem with it on macOS.
|
||||
* `laguna-s-2.1`: open runtime issues 17602 and 17632.
|
||||
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence.
|
||||
* - The plain tags `qwen3.5:9b`, `mimo-v2.6:9b` and `qwen3.8:27b`: a Mac pulls their MLX
|
||||
* builds, whose 8 GiB of prefix snapshots do not fit the Macs a 9B or a 27B is for. Their
|
||||
* GGUF builds are listed instead.
|
||||
* - `qwen3.5:122b-a10b`: replaced by `ornith-1.5:122b`, a fine-tune of it (SWE-bench
|
||||
* Verified 84.5 against 72.0) whose MLX build carries a renderer and a tool parser.
|
||||
*
|
||||
* `tested` has one entry: `qwen3.6:35b-a3b` with OpenCode on an Apple M5 Pro with 48 GB,
|
||||
* 2026-11-06, 67.7 tokens per second. Only a Mac of exactly that chip and memory size is told
|
||||
* "Recommended" and shown that speed in place of the estimate.
|
||||
* "Recommended" and shown that speed in place of the estimate. Every other row was never run
|
||||
* on a Mac by us, and its row says `estimated` and "not run on a Mac like this yet".
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -114,6 +202,7 @@ const LocalModelEntrySchema = z
|
||||
label: z.string().min(1).max(80),
|
||||
license: z.string().min(1).max(40),
|
||||
paramsB: z.number().positive(),
|
||||
runner: z.enum(['mlx', 'llamacpp']),
|
||||
bytes: positiveInt,
|
||||
heldBytesPerToken: positiveInt,
|
||||
overheadBytes: z.number().int().nonnegative(),
|
||||
@@ -218,8 +307,8 @@ export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
|
||||
|
||||
/**
|
||||
* The memory figures measured for `qwen3.6:35b-a3b` on an Apple M5 Pro with 48 GB (2026-11-06,
|
||||
* the MLX runner), in decimal bytes. That row carries them as `measured`; every other row
|
||||
* carries the same three as `estimated`, see the file header.
|
||||
* the MLX runner), in decimal bytes. Only that row carries them; see the file header (MEMORY)
|
||||
* for what they do not cover.
|
||||
*/
|
||||
const MEASURED_MEMORY = {
|
||||
/** 36,000 to 38,000 bytes stayed held per token of context after a prompt of 89,000 and one of 167,000 tokens. */
|
||||
@@ -230,117 +319,212 @@ const MEASURED_MEMORY = {
|
||||
peakExtraBytes: 5_000_000_000,
|
||||
} as const;
|
||||
|
||||
/** The llama.cpp runner's own buffers, compute buffers included (file header, MEMORY). */
|
||||
const LLAMACPP_RUNNER_BYTES = 1_000_000_000;
|
||||
/** The host prompt cache llama-server may keep: `LLAMA_ARG_CACHE_RAM`, 1024 MiB (`RUNTIME_HOST_PROMPT_CACHE_MIB`). */
|
||||
const LLAMACPP_PROMPT_CACHE_BYTES = 1024 * 1024 * 1024;
|
||||
/** The context checkpoints llama-server may keep: `LLAMA_ARG_CTX_CHECKPOINTS`, 8 (`RUNTIME_CTX_CHECKPOINTS`). */
|
||||
const LLAMACPP_CTX_CHECKPOINTS = 8;
|
||||
/** The tokens the runtime drafts per step for an MTP model whose tag sets no `draft_num_predict`, 4: so many rollback states. */
|
||||
const LLAMACPP_DEFAULT_DRAFT_TOKENS = 4;
|
||||
/** The prefix snapshots the MLX runner may keep: `maxPagedOutBytes`, 8 GiB. */
|
||||
const MLX_PREFIX_CACHE_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The catalogue that ships with Codeman. Never read directly outside this file and its test:
|
||||
* callers use `getLocalModelCatalog()`, which validates it first.
|
||||
*/
|
||||
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
updated: '2026-11-08',
|
||||
// The oldest macOS the model runtime supports.
|
||||
minMacosMajor: 14,
|
||||
// Ordered by rank. The higher rank wins among models that fit equally well and are equally
|
||||
// fast. `flint:40b` is left out on purpose: its plain tag is the slow build on a Mac and it
|
||||
// has an open bug with tool arguments.
|
||||
// Ordered by rank. The higher rank wins among models that fit and whose speed is graded
|
||||
// alike (recommend.ts ranks the speed grade first). See the file header for the models
|
||||
// that are left out and why.
|
||||
models: [
|
||||
{
|
||||
id: 'qwen3.5:9b',
|
||||
// Small Macs (16 to 32 GB, `limited`): the base model of the row below.
|
||||
id: 'qwen3.5:9b-q4_k_m',
|
||||
label: 'Qwen3.5 9B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 9,
|
||||
bytes: 6_550_825_373,
|
||||
...MEASURED_MEMORY,
|
||||
runner: 'llamacpp',
|
||||
bytes: 6_550_825_550,
|
||||
// 8 attention layers x 4 K/V heads x 256 x 2 x 34/32.
|
||||
heldBytesPerToken: 17_408,
|
||||
// 24 linear-attention layers hold 52,690,944 bytes of f32 state: once live, and once in
|
||||
// each context checkpoint.
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [65536, 32768],
|
||||
contexts: [131072, 65536],
|
||||
rank: 1,
|
||||
// 37.1 tokens per second measured on 400 GB/s.
|
||||
// Measured: 37.1 tokens per second on 400 GB/s (this Q4_K_M build, llama.cpp).
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.5:9b-q8_0',
|
||||
label: 'Qwen3.5 9B (Q8)',
|
||||
license: 'Apache-2.0',
|
||||
// Small Macs (16 to 32 GB, `limited`). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
|
||||
// Qwen3.5-9B for code and agents: SWE-bench Verified 61.1 and SWE-bench Pro 44.6,
|
||||
// against 60.0 and 32.0 for its base (Xiaomi's card). Requires exactly the pinned
|
||||
// runtime, 0.40.15.
|
||||
id: 'mimo-v2.6:9b-q4_k_m',
|
||||
label: 'MiMo V2.6 9B',
|
||||
license: 'MIT',
|
||||
paramsB: 9,
|
||||
bytes: 10_449_217_949,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [65536, 32768],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above scaled by file size.
|
||||
speedPer100Gbps: 5.8,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'gpt-oss:20b',
|
||||
label: 'GPT-OSS 20B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 20,
|
||||
bytes: 13_793_441_427,
|
||||
...MEASURED_MEMORY,
|
||||
runner: 'llamacpp',
|
||||
bytes: 6_550_811_033,
|
||||
// The architecture and the quantization of the row above, so its figures.
|
||||
heldBytesPerToken: 17_408,
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 3,
|
||||
// No speed figure: its speed is unknown on every chip.
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above, the same architecture and file size.
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Cohere's model for agentic software engineering, 3 billion parameters active:
|
||||
// SWE-bench Verified 67.6 (vendor). Its card asks OpenCode for interleaved reasoning,
|
||||
// which Codeman's OpenCode settings do not set.
|
||||
id: 'north-mini-code-1.0:q4_k_m',
|
||||
label: 'North Mini Code 1.0',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 30,
|
||||
runner: 'llamacpp',
|
||||
bytes: 18_593_967_008,
|
||||
// 13 full-attention layers x 4 K/V heads x 128 x 2 x 34/32.
|
||||
heldBytesPerToken: 14_144,
|
||||
// 36 sliding-window layers: (4,096 + 512) cells x 4 x 128 x 2 x 34/32 = 180,486,144, live
|
||||
// and in each context checkpoint.
|
||||
overheadBytes: 180_486_144 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 3,
|
||||
// Not measured: the 13.6 measured for Qwen3.6 35B-A3B on llama.cpp, which has the same
|
||||
// 3 billion active parameters.
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Macs of 36 GB on a slower chip: the model of the tested pair, as a GGUF build whose
|
||||
// memory does not grow after it loaded. With its multi-token-prediction layer.
|
||||
id: 'qwen3.6:35b-a3b-mtp-q4_k_m',
|
||||
label: 'Qwen3.6 35B-A3B (GGUF)',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 35,
|
||||
runner: 'llamacpp',
|
||||
bytes: 22_621_314_381,
|
||||
// 10 attention layers x 2 K/V heads x 256 x 2 x 34/32 = 10,880, + the prediction layer in
|
||||
// f16: 2 x 256 x 2 x 2 = 2,048.
|
||||
heldBytesPerToken: 12_928,
|
||||
// 30 linear-attention layers hold 65,863,680 bytes of f32 state: live, in the 2 rollback
|
||||
// copies of prediction (this tag drafts 2 tokens, `draft_num_predict` 2), and in each
|
||||
// context checkpoint.
|
||||
overheadBytes:
|
||||
65_863_680 * (1 + 2 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 4,
|
||||
// Measured: 54.5 tokens per second on 400 GB/s (llama.cpp, without prediction).
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// 48 GB: the tested pair. SWE-bench Verified 73.4, SWE-bench Pro 49.5 (Qwen's card).
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 35,
|
||||
// What a Mac really downloads: an MLX build, 2% above the 22,621,314,161 of the manifest.
|
||||
// The plain tag is a manifest list; a Mac takes its MLX child.
|
||||
runner: 'mlx',
|
||||
// What a Mac really downloads: the MLX build, 2% above the GGUF build of the row above.
|
||||
bytes: 23_105_798_956,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'measured',
|
||||
contexts: [131072, 65536],
|
||||
rank: 4,
|
||||
// 54.5 tokens per second measured on 400 GB/s (3 billion parameters are active per token).
|
||||
// The MLX build ran faster than this figure predicts on the one Mac we tested (67.7
|
||||
// against 41.8), so on other chips the estimate is on the low side.
|
||||
speedPer100Gbps: 13.6,
|
||||
rank: 5,
|
||||
// Measured: 67.8 tokens per second on 400 GB/s with MLX. The one Mac we tested ran it at
|
||||
// 67.7 on 307 GB/s, so on other chips the estimate is on the low side.
|
||||
speedPer100Gbps: 16.9,
|
||||
// Run by us with OpenCode on the MacBook Pro (Mac17,13): a four-step task with tool calls.
|
||||
tested: [{ chip: 'Apple M5 Pro', memGb: 48, cli: 'opencode', date: '2026-11-06', tokensPerSecond: 67.7 }],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.8:27b',
|
||||
label: 'Qwen3.8 27B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 27.8,
|
||||
bytes: 17_741_871_939,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536, 32768],
|
||||
rank: 5,
|
||||
// 11.8 tokens per second measured on 400 GB/s.
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Large Macs that want 8-bit weights: the row below in Q8_0, half as fast.
|
||||
id: 'qwen3.8:27b-q8_0',
|
||||
label: 'Qwen3.8 27B (Q8)',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 27.8,
|
||||
bytes: 29_978_241_837,
|
||||
...MEASURED_MEMORY,
|
||||
runner: 'llamacpp',
|
||||
bytes: 29_978_242_050,
|
||||
// The architecture of the row below, prediction layer included (its GGUF has one), and the
|
||||
// runtime's 4 drafted tokens (the tag sets none), so its figures.
|
||||
heldBytesPerToken: 38_912,
|
||||
overheadBytes:
|
||||
156_893_184 * (1 + LLAMACPP_DEFAULT_DRAFT_TOKENS + LLAMACPP_CTX_CHECKPOINTS) +
|
||||
LLAMACPP_RUNNER_BYTES +
|
||||
LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072],
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 6,
|
||||
// Not measured: the 2.95 of the row above scaled by file size.
|
||||
// Not measured: the 2.95 of the row below scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.5:122b-a10b',
|
||||
label: 'Qwen3.5 122B-A10B',
|
||||
// 36 to 96 GB on a fast chip: the strongest model of this size. Terminal-Bench 2.1 73.0,
|
||||
// SWE-bench Pro 61.7 (Qwen's card). Dense, so it is slow on a Pro chip.
|
||||
id: 'qwen3.8:27b-mtp-q4_k_m',
|
||||
label: 'Qwen3.8 27B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 122,
|
||||
bytes: 81_370_035_877,
|
||||
...MEASURED_MEMORY,
|
||||
paramsB: 27.8,
|
||||
runner: 'llamacpp',
|
||||
bytes: 17_741_872_154,
|
||||
// 16 attention layers x 4 K/V heads x 256 x 2 x 34/32 = 34,816, + the prediction layer in
|
||||
// f16: 4 x 256 x 2 x 2 = 4,096.
|
||||
heldBytesPerToken: 38_912,
|
||||
// 48 linear-attention layers hold 156,893,184 bytes of f32 state: live, in the 4 rollback
|
||||
// copies of prediction (`draft_num_predict` 4), and in each context checkpoint.
|
||||
overheadBytes:
|
||||
156_893_184 * (1 + 4 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072],
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 7,
|
||||
// No speed figure: its speed is unknown on every chip.
|
||||
// Measured: 11.8 tokens per second on 400 GB/s (llama.cpp, Q4_K_M, without prediction).
|
||||
// This build predicts several tokens at a time, so it is faster than this figure says.
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// 128 GB and more. A fine-tune of Qwen3.5-122B-A10B, 10 billion parameters active:
|
||||
// Terminal-Bench 2.1 81.2, SWE-bench Verified 84.5, SWE-bench Pro 76.9 (Ornith's card).
|
||||
id: 'ornith-1.5:122b',
|
||||
label: 'Ornith 1.5 122B-A10B',
|
||||
license: 'MIT',
|
||||
paramsB: 122,
|
||||
// The plain tag is a manifest list; a Mac takes its MLX child (with a renderer and a
|
||||
// tool parser, which the GGUF builds of this family lack).
|
||||
runner: 'mlx',
|
||||
bytes: 73_388_069_552,
|
||||
// 12 attention layers x 2 K/V heads x 256 x 2 x 2 bytes = 24,576, + a 153,649,152-byte
|
||||
// recurrent snapshot per 8,192 tokens = 18,756: 43,332, x 37,000 / 28,340.
|
||||
heldBytesPerToken: 56_574,
|
||||
overheadBytes: MEASURED_MEMORY.overheadBytes + MLX_PREFIX_CACHE_BYTES,
|
||||
peakExtraBytes: MEASURED_MEMORY.peakExtraBytes,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072],
|
||||
rank: 8,
|
||||
// Not measured: the 16.9 of Qwen3.6 35B-A3B on MLX x 3/10, its active parameters
|
||||
// against these, rounded down.
|
||||
speedPer100Gbps: 5,
|
||||
tested: [],
|
||||
},
|
||||
],
|
||||
|
||||
+103
-5
@@ -1358,6 +1358,87 @@ async function isSymlink(path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a folder INSIDE the copy that holds skill files (today only `reference/`) is a
|
||||
* symlink. The checks on the copy and its parent stop at the copy itself, and a write or
|
||||
* unlink of `reference/<name>.md` follows a linked `reference/` wherever it points:
|
||||
* measured, `reference -> <elsewhere>` made install overwrite a `history.md` there and
|
||||
* uninstall delete it. A cloned repository can commit that link, and a Docker case's
|
||||
* container can plant it in the bind-mounted workspace that the host injects into, so
|
||||
* both are refused like a linked copy. Checked once before the first write or unlink; a
|
||||
* link swapped in after the check (a race against the container) is not covered.
|
||||
*/
|
||||
async function agentSkillSubdirIsSymlink(skillDir: string, files: AgentSkillFile[]): Promise<boolean> {
|
||||
const subdirs = new Set<string>(['reference']);
|
||||
for (const file of files) {
|
||||
// Every folder on the way to the file, outermost first: `a/b/x.md` checks `a`, then `a/b`.
|
||||
const parts = dirname(file.relPath)
|
||||
.split(/[\\/]/)
|
||||
.filter((part) => part !== '' && part !== '.');
|
||||
for (let i = 1; i <= parts.length; i++) subdirs.add(join(...parts.slice(0, i)));
|
||||
}
|
||||
for (const subdir of subdirs) {
|
||||
if (await isSymlink(join(skillDir, subdir))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ignore file every Codeman-managed skill copy carries, and what it holds: `*`
|
||||
* matches everything in the copy's own folder (the ignore file included), so a user's
|
||||
* repository never lists `.claude/skills/codeman/` (or `.agents/skills/codeman/`) as
|
||||
* untracked, without Codeman touching the repository's own `.gitignore` or
|
||||
* `info/exclude`. Same idea as the prompt-uploads folder (`ensureUploadDir`).
|
||||
*
|
||||
* Deliberately NOT part of `readAgentSkillSource()`: it is written once with O_EXCL and
|
||||
* never compared or rewritten, so a file the user put there (or edited) is theirs.
|
||||
*
|
||||
* Measured against Claude Code 2.1.319, whose skill discovery runs `git check-ignore`
|
||||
* only on directories at or above `.claude/skills` (the nested-skills walk), never on a
|
||||
* skill folder or its SKILL.md: a `*` inside `codeman/` ignores the folder's CONTENTS,
|
||||
* not the folder, so the skill still loads. Codex 0.162 does no gitignore check on skills.
|
||||
*/
|
||||
const AGENT_SKILL_GITIGNORE = '.gitignore';
|
||||
const AGENT_SKILL_GITIGNORE_CONTENT = '*\n';
|
||||
|
||||
/**
|
||||
* Write the copy's `.gitignore` if none is there. Returns whether it wrote one.
|
||||
* 'wx' (O_CREAT|O_EXCL) never replaces an existing file and refuses a symlink at the
|
||||
* name. A create that succeeded before the write failed (ENOSPC) would leave an empty
|
||||
* file that reads as the user's next time, so it is taken back; that cleanup's own
|
||||
* failure must not replace the cause.
|
||||
*/
|
||||
async function ensureAgentSkillGitignore(skillDir: string): Promise<boolean> {
|
||||
const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE);
|
||||
try {
|
||||
await writeFile(ignoreFile, AGENT_SKILL_GITIGNORE_CONTENT, { flag: 'wx' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
||||
await unlink(ignoreFile).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the copy's `.gitignore` only when it is the LAST thing in the folder and holds
|
||||
* exactly what Codeman writes. Anything else in the folder (a user's own notes) keeps
|
||||
* the ignore file, so those files stay out of `git status` as they were, and a
|
||||
* `.gitignore` with other content is the user's.
|
||||
*/
|
||||
async function removeAgentSkillGitignoreIfLast(skillDir: string): Promise<void> {
|
||||
const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE);
|
||||
try {
|
||||
const entries = await readdir(skillDir);
|
||||
if (entries.length !== 1 || entries[0] !== AGENT_SKILL_GITIGNORE) return;
|
||||
if (!(await lstat(ignoreFile)).isFile()) return;
|
||||
if ((await readFile(ignoreFile, 'utf-8')) !== AGENT_SKILL_GITIGNORE_CONTENT) return;
|
||||
await unlink(ignoreFile);
|
||||
} catch {
|
||||
// best-effort: a folder that stays behind holds only an ignore file
|
||||
}
|
||||
}
|
||||
|
||||
/** What an install/remove actually did, so callers (CLI, logs) can say so. */
|
||||
export type AgentSkillApplyResult =
|
||||
| 'installed' // fresh copy written
|
||||
@@ -1366,23 +1447,31 @@ export type AgentSkillApplyResult =
|
||||
| 'removed' // our copy deleted
|
||||
| 'absent' // nothing there to remove
|
||||
| 'foreign' // a copy exists but is not ours; left untouched
|
||||
| 'symlink'; // the skill dir (or its parent) is a symlink; left untouched
|
||||
| 'symlink'; // the skill dir, its parent or a folder inside it is a symlink; left untouched
|
||||
|
||||
/**
|
||||
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
|
||||
* directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
|
||||
* `<case>/.agents/skills/codeman`).
|
||||
*
|
||||
* Refuses two shapes rather than writing through them:
|
||||
* Refuses these shapes rather than writing through them:
|
||||
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
|
||||
* layout (`.claude/skills/codeman -> ../../skills/codeman`) would otherwise have
|
||||
* the injector overwrite the repo source through the link;
|
||||
* - a SYMLINK at a folder inside the copy (`reference/`), which would carry the skill's
|
||||
* files to wherever it points (`agentSkillSubdirIsSymlink`);
|
||||
* - a FOREIGN copy (SKILL.md present without our marker): that is the user's own
|
||||
* skill, and per the statusLine rule we never clobber what we did not write.
|
||||
*
|
||||
* Idempotent and cheap: unchanged files are not rewritten, so calling on every
|
||||
* session create causes no mtime churn.
|
||||
*
|
||||
* The copy ignores itself in git: a `.gitignore` of `*` is written FIRST, before any
|
||||
* skill file, so a `git status` that runs mid-install never lists the folder either
|
||||
* (`ensureAgentSkillGitignore`). It is written once and never rewritten; a copy made
|
||||
* before this existed gains it on its next install or refresh, which then reports
|
||||
* 'refreshed'.
|
||||
*
|
||||
* Serialized on the skill dir through the same lock the settings writers use: two
|
||||
* sessions created at once in one repo both inject this skill, and interleaving their
|
||||
* ownership read with the other's write reports a bogus result (an 'unchanged' for a
|
||||
@@ -1402,7 +1491,9 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
||||
if (existing !== null && !existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
|
||||
|
||||
const files = await readAgentSkillSource();
|
||||
let changed = false;
|
||||
if (await agentSkillSubdirIsSymlink(skillDir, files)) return 'symlink';
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
let changed = await ensureAgentSkillGitignore(skillDir);
|
||||
for (const file of files) {
|
||||
const target = join(skillDir, file.relPath);
|
||||
let current: string | null = null;
|
||||
@@ -1544,7 +1635,8 @@ export async function refreshUserAgentSkill(): Promise<AgentSkillApplyResult | '
|
||||
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
|
||||
* refusals as the install path. Deletes only files the packaged source would have
|
||||
* written (never `rm -rf`, so a user's extra files in the directory survive), then
|
||||
* prunes the directories bottom-up if they emptied.
|
||||
* the copy's own `.gitignore` when nothing else is left beside it, then prunes the
|
||||
* directories bottom-up if they emptied.
|
||||
*
|
||||
* Shares the install path's per-dir lock so an uninstall can't run between an install's
|
||||
* ownership read and its writes, which would leave half the skill back on disk.
|
||||
@@ -1564,10 +1656,13 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
|
||||
// Manifest-based, with SKILL.md as the fallback when the packaged source is
|
||||
// unreadable: removal must still work on an install whose skills/ dir went missing.
|
||||
const files = await readAgentSkillSource().catch((): AgentSkillFile[] => [{ relPath: 'SKILL.md', content: '' }]);
|
||||
// An unlink through a linked `reference/` deletes files wherever it points.
|
||||
if (await agentSkillSubdirIsSymlink(skillDir, files)) return 'symlink';
|
||||
for (const file of files) {
|
||||
await unlink(join(skillDir, file.relPath)).catch(() => {});
|
||||
}
|
||||
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
|
||||
await removeAgentSkillGitignoreIfLast(skillDir);
|
||||
await rmdir(skillDir).catch(() => {});
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
|
||||
return 'removed';
|
||||
@@ -1601,7 +1696,10 @@ export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string {
|
||||
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
|
||||
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
|
||||
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
|
||||
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
|
||||
* shape. Gated by the synced `agentSkillEnabled` app setting: default ON since 1.50.0
|
||||
* (an absent key is on, only an explicit false is off; `getAgentSkillEnabled()` in
|
||||
* server.ts), which is safe for a user's repository because the copy ignores itself
|
||||
* in git (`ensureAgentSkillGitignore`).
|
||||
*
|
||||
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
|
||||
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
|
||||
|
||||
+223
-31
@@ -13,17 +13,30 @@
|
||||
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
|
||||
* changes, and a file read starts no process, no repository config and no filter.
|
||||
*
|
||||
* Only a folder strictly below `workingDir` is ever an answer: the file routes already serve
|
||||
* A folder strictly below `workingDir` is the plain answer: the file routes already serve
|
||||
* everything under it, so following a pane there widens nothing, and a planted `.git` file can
|
||||
* only move where the listing STARTS inside that same folder. A worktree outside it
|
||||
* (`git worktree add ../feature`) stays unfollowed, since listing it would need a second
|
||||
* confinement root.
|
||||
* only move where the listing STARTS inside that same folder.
|
||||
*
|
||||
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
|
||||
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
|
||||
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
|
||||
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep
|
||||
* judging every path against `workingDir`.
|
||||
* A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree that a
|
||||
* `WorktreeCreate` hook places beside the repository) is a second confinement root, so it is
|
||||
* answered only when the caller passes {@link OutsideWorktreeOptions}, and only when the session's
|
||||
* own repository vouches for it ({@link findOutsideWorktree}). The answer then carries
|
||||
* `outside: true`, and the file routes serve absolute paths inside it.
|
||||
*
|
||||
* For a worktree inside `workingDir` the answer is lexical and only as fresh as the last time the
|
||||
* pane moved: a symlink can take the folder's place afterwards without moving the pane. So
|
||||
* nothing there is a confinement decision. `GET /api/sessions/:id/files` resolves the folder on
|
||||
* every request and walks it only while it really lies inside `workingDir`
|
||||
* (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep judging every path against
|
||||
* `workingDir`.
|
||||
*
|
||||
* For an outside worktree {@link findOutsideWorktree} IS the confinement decision for the second
|
||||
* root. Its vouching, home and Docker rules, and the multi-user half of `allowRoot`, run only when
|
||||
* the pane moves. The routes re-check two things on each request: that the folder still realpaths
|
||||
* to the recorded path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), and the attachment
|
||||
* guard half of `allowRoot` (workspace confinement, blocked and sensitive trees:
|
||||
* `isOutsideWorktreeRootRefused()` through `withGuardedOutsideWorktree()` in file-routes.ts), so a
|
||||
* guard setting changed later applies at once.
|
||||
*
|
||||
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
|
||||
* `unknown` answer stops the walk rather than being read as "no `.git` here".
|
||||
@@ -32,15 +45,44 @@
|
||||
*/
|
||||
|
||||
import { constants, promises as fs } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
/** A linked worktree a session's pane is in. */
|
||||
export interface LiveWorktree {
|
||||
/** The worktree folder's name, for display. */
|
||||
name: string;
|
||||
/** Absolute path, spelled like the session's `workingDir`. */
|
||||
/**
|
||||
* Absolute path. Spelled like the session's `workingDir` for a worktree inside it; the real
|
||||
* path (no symlink in any component) for one outside it.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* The worktree lies outside `workingDir`, so it is a second root the file routes serve
|
||||
* ABSOLUTE paths from. Absent for a worktree inside `workingDir`.
|
||||
*/
|
||||
outside?: true;
|
||||
}
|
||||
|
||||
/** What {@link findLiveWorktree} needs to answer a worktree outside `workingDir`. */
|
||||
export interface OutsideWorktreeOptions {
|
||||
/** Overrides `os.homedir()` (tests). */
|
||||
home?: string;
|
||||
/** Host paths of every Docker case workspace: a container can write there, so none is followed. */
|
||||
dockerWorkspaces?: () => Promise<string[]>;
|
||||
/**
|
||||
* The caller's last word on which worktree may be followed. The server passes its attachment
|
||||
* guard rules (workspace confinement, blocked trees, sensitive folders) in every mode, plus the
|
||||
* owner's folder rule (`folderAllowedForUser`) in multi-user mode, so a regular user's session
|
||||
* cannot list another user's checkout. A rejection counts as a refusal.
|
||||
*/
|
||||
allowRoot?: (root: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/** The text after `gitdir:` in a `.git` file, as written (possibly relative). */
|
||||
function rawGitDir(text: string): string | undefined {
|
||||
return /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
|
||||
}
|
||||
|
||||
/** More than any real `.git` file. It is read only to recognise the first line. */
|
||||
@@ -52,12 +94,33 @@ const MAX_DOT_GIT_FILE_BYTES = 4096;
|
||||
* The path may be relative: `git worktree add --relative-paths` writes it that way.
|
||||
*/
|
||||
export function isLinkedWorktreeGitFile(text: string): boolean {
|
||||
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
|
||||
const gitDir = rawGitDir(text);
|
||||
if (!gitDir) return false;
|
||||
const segments = gitDir.split('/').filter(Boolean);
|
||||
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of a small file, opened so that nothing blocks or escapes. The probe a caller ran
|
||||
* follows symlinks and the file can change after it, so the open itself refuses: O_NOFOLLOW a
|
||||
* symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and hold a threadpool
|
||||
* worker for good. Whatever it did open is then judged on the handle. Null for anything but a
|
||||
* regular file it could read.
|
||||
*/
|
||||
async function readOpenedSmallFile(path: string): Promise<string | null> {
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
if (!(await handle.stat()).isFile()) return null;
|
||||
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
|
||||
return buffer.toString('utf8', 0, bytesRead);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
|
||||
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
|
||||
const dotGit = join(dir, '.git');
|
||||
@@ -65,32 +128,28 @@ async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'
|
||||
if (kind === 'absent') return 'none';
|
||||
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
|
||||
if (kind !== 'file') return 'stop';
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
// The probe follows symlinks and the file can change after it, so the open itself refuses:
|
||||
// O_NOFOLLOW a symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and
|
||||
// hold a threadpool worker for good. Whatever it did open is then judged on the handle.
|
||||
handle = await fs.open(dotGit, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
if (!(await handle.stat()).isFile()) return 'stop';
|
||||
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
|
||||
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
|
||||
} catch {
|
||||
return 'stop';
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
// `null` is a symlink, a FIFO, a non-regular file or an unreadable one: all stop the walk.
|
||||
const text = await readOpenedSmallFile(dotGit);
|
||||
return text !== null && isLinkedWorktreeGitFile(text) ? 'worktree' : 'stop';
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
|
||||
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
|
||||
* read. `workingDir` itself is never an answer: a session started in a worktree is already
|
||||
* listing it. Never throws.
|
||||
* The linked worktree that holds `cwd`, if any. Walks up from `cwd` and stops at `workingDir`,
|
||||
* at a repository or submodule of its own, or at anything it cannot read. `workingDir` itself is
|
||||
* never an answer: a session started in a worktree is already listing it. When `cwd` lies outside
|
||||
* `workingDir` and `outside` is given, {@link findOutsideWorktree} answers instead. Never throws.
|
||||
*/
|
||||
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
|
||||
export async function findLiveWorktree(
|
||||
workingDir: string,
|
||||
cwd: string | undefined,
|
||||
outside?: OutsideWorktreeOptions
|
||||
): Promise<LiveWorktree | undefined> {
|
||||
if (!cwd?.startsWith('/')) return undefined;
|
||||
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
|
||||
const under = root === '/' ? '/' : `${root}/`;
|
||||
if (cwd !== root && !cwd.startsWith(under)) {
|
||||
return outside ? findOutsideWorktree(root, cwd, outside).catch(() => undefined) : undefined;
|
||||
}
|
||||
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
|
||||
const found = await classifyDotGit(dir);
|
||||
if (found === 'worktree') return { name: basename(dir), path: dir };
|
||||
@@ -98,3 +157,136 @@ export async function findLiveWorktree(workingDir: string, cwd: string | undefin
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when `child` is `parent` or lies below it. Both must already be real paths. */
|
||||
const isSameOrUnder = (child: string, parent: string): boolean =>
|
||||
child === parent || child.startsWith(parent === sep ? parent : parent + sep);
|
||||
|
||||
/** The real path of `p` once a bounded probe has answered for it, else null. */
|
||||
async function realPathBounded(p: string): Promise<string | null> {
|
||||
const kind = await probePathKind(p);
|
||||
if (kind !== 'directory' && kind !== 'file') return null;
|
||||
try {
|
||||
return await fs.realpath(p);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The text of a small regular file that a bounded probe found, via {@link readOpenedSmallFile}. */
|
||||
async function readSmallFile(path: string): Promise<string | null> {
|
||||
return (await probePathKind(path)) === 'file' ? readOpenedSmallFile(path) : null;
|
||||
}
|
||||
|
||||
/** The `gitdir:` value of a `.git` file's text, resolved against the folder that holds it. */
|
||||
function gitDirOf(text: string, dir: string): string | null {
|
||||
const raw = rawGitDir(text);
|
||||
return raw ? resolve(dir, raw) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real path of the git common dir of the repository whose top level is `workingDir`: its
|
||||
* `.git` folder, or, for a linked worktree, the folder its `commondir` file names. Null when
|
||||
* `workingDir` is not the top level of a repository or worktree: a session in a subfolder
|
||||
* follows no worktree outside it.
|
||||
*/
|
||||
async function sessionCommonDir(workingDir: string): Promise<string | null> {
|
||||
const dotGit = join(workingDir, '.git');
|
||||
const kind = await probePathKind(dotGit);
|
||||
if (kind === 'directory') return realPathBounded(dotGit);
|
||||
if (kind !== 'file') return null;
|
||||
const text = await readSmallFile(dotGit);
|
||||
const gitDir = text ? gitDirOf(text, workingDir) : null;
|
||||
if (!gitDir) return null;
|
||||
// A linked worktree's git dir names the common dir in `commondir`; a submodule's git dir
|
||||
// has none and is its own common dir.
|
||||
const commonDir = await readSmallFile(join(gitDir, 'commondir'));
|
||||
return realPathBounded(commonDir ? resolve(gitDir, commonDir.trim()) : gitDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* The worktree outside `workingDir` that holds `cwd`, when the session's own repository vouches
|
||||
* for it. `cwd` comes from the pane, which a prompt-injected agent controls, so every rule below
|
||||
* keeps it from widening what the file routes serve beyond a worktree of the session's repository:
|
||||
*
|
||||
* - The walk up from `cwd` stops at the first `.git`. That folder is a candidate only when its
|
||||
* `.git` FILE names `<common dir>/worktrees/<id>` of the session's repository AND that
|
||||
* entry's own `gitdir` file names the candidate back. A `.git` FOLDER is a candidate only when
|
||||
* it IS the session's common dir (the main worktree, for a session started in a linked one).
|
||||
* These are the files `git worktree list` reads, read here without starting git: this runs
|
||||
* from the pane watcher on every directory change.
|
||||
* - `/`, the home folder and anything above it, and any folder holding `workingDir` are
|
||||
* refused, as is a folder at, inside or around a Docker case workspace.
|
||||
* - `allowRoot`, when given, has the last word (the server's attachment-guard and multi-user rules).
|
||||
*
|
||||
* Never rejects: an `allowRoot` that rejects refuses the worktree.
|
||||
*
|
||||
* Every path is compared as a real path, and the answer is the real path. The file routes check
|
||||
* on each request that the folder still resolves to itself, so a symlink swapped in later is
|
||||
* refused there. A forged registry entry needs write access to the session's own `.git`, which
|
||||
* an agent running as this user already has, along with read access to everything it could list.
|
||||
*/
|
||||
export async function findOutsideWorktree(
|
||||
workingDir: string,
|
||||
cwd: string,
|
||||
opts: OutsideWorktreeOptions
|
||||
): Promise<LiveWorktree | undefined> {
|
||||
// The nearest folder at or above `cwd` that holds a `.git`, and what that `.git` is.
|
||||
let dir = cwd;
|
||||
let kind: 'directory' | 'file';
|
||||
for (;;) {
|
||||
const probed = await probePathKind(join(dir, '.git'));
|
||||
if (probed === 'unknown') return undefined;
|
||||
if (probed === 'directory' || probed === 'file') {
|
||||
kind = probed;
|
||||
break;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return undefined;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
const commonDir = await sessionCommonDir(workingDir);
|
||||
if (!commonDir) return undefined;
|
||||
const real = await realPathBounded(dir);
|
||||
if (!real) return undefined;
|
||||
|
||||
if (kind === 'directory') {
|
||||
// The main worktree: its `.git` folder is the session's common dir itself. The folder must
|
||||
// really hold it: a `.git` symlink resolves elsewhere, so it fails this comparison.
|
||||
if (join(real, '.git') !== commonDir) return undefined;
|
||||
} else {
|
||||
const text = await readSmallFile(join(dir, '.git'));
|
||||
const gitDir = text && isLinkedWorktreeGitFile(text) ? gitDirOf(text, dir) : null;
|
||||
const realGitDir = gitDir ? await realPathBounded(gitDir) : null;
|
||||
if (!realGitDir || dirname(realGitDir) !== join(commonDir, 'worktrees')) return undefined;
|
||||
// The registry entry must name this folder back, as `git worktree list` would report it.
|
||||
const back = await readSmallFile(join(realGitDir, 'gitdir'));
|
||||
const backDir = back ? await realPathBounded(dirname(resolve(realGitDir, back.trim()))) : null;
|
||||
if (backDir !== real) return undefined;
|
||||
}
|
||||
|
||||
const realWorkingDir = await realPathBounded(workingDir);
|
||||
if (!realWorkingDir || isSameOrUnder(realWorkingDir, real) || isSameOrUnder(real, realWorkingDir)) return undefined;
|
||||
const home = (await realPathBounded(opts.home ?? homedir())) ?? resolve(opts.home ?? homedir());
|
||||
if (real === sep || isSameOrUnder(home, real)) return undefined;
|
||||
if (opts.dockerWorkspaces) {
|
||||
const workspaces = await opts.dockerWorkspaces().catch(() => null);
|
||||
// An unreadable list could hide a container's workspace, so it fails closed.
|
||||
if (!workspaces) return undefined;
|
||||
for (const workspace of workspaces) {
|
||||
const realWorkspace = (await realPathBounded(workspace)) ?? resolve(workspace);
|
||||
if (isSameOrUnder(real, realWorkspace) || isSameOrUnder(realWorkspace, real)) return undefined;
|
||||
}
|
||||
}
|
||||
if (opts.allowRoot) {
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed = await opts.allowRoot(real);
|
||||
} catch {
|
||||
// A callback that throws or rejects refuses the worktree.
|
||||
}
|
||||
if (!allowed) return undefined;
|
||||
}
|
||||
return { name: basename(real), path: real, outside: true };
|
||||
}
|
||||
@@ -12,8 +12,9 @@
|
||||
* - `needBytes()`: the PEAK, `bytes + heldBytesPerToken * ctx + overheadBytes +
|
||||
* peakExtraBytes` of the catalogue row: 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. Measured for one model on one Mac (see the catalogue header); every other row
|
||||
* carries the same figures and says `estimated`.
|
||||
* processed. Measured for one model on one Mac; every other row's figures are computed from
|
||||
* its own model's architecture for its runner and say `estimated` (see the catalogue
|
||||
* header, MEMORY).
|
||||
* - Fit: need over budget up to 0.85 is good, up to 0.98 is tight, above is out. The grade
|
||||
* decides whether a model is offered at all and what its row says. It does NOT sort: the
|
||||
* need is the worst moment at the full context, so a tight fit is a model that runs, and a
|
||||
|
||||
@@ -16,8 +16,39 @@
|
||||
* either case) are copied from the server's; HOME is the private one; and exactly these are
|
||||
* set: `OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`,
|
||||
* `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`,
|
||||
* `OLLAMA_MAX_LOADED_MODELS=1`. Nothing else gets through: no `CODEMAN_*` and none of the
|
||||
* user's own `OLLAMA_*`.
|
||||
* `OLLAMA_MAX_LOADED_MODELS=1`, and the four memory settings of the llama.cpp runner (see
|
||||
* MEMORY below). Nothing else gets through: no `CODEMAN_*` and none of the user's own
|
||||
* `OLLAMA_*` or `LLAMA_ARG_*`.
|
||||
* - MEMORY (what the catalogue's figures for its `llamacpp` rows assume, so change both
|
||||
* together: test/local-model-catalog.test.ts holds them to each other). Read in the
|
||||
* runtime's v0.40.15 source, not yet measured on a Mac:
|
||||
* - `OLLAMA_KV_CACHE_TYPE=q8_0` becomes `--cache-type-k q8_0 --cache-type-v q8_0` of
|
||||
* llama-server (llm/llama_server.go): the K/V cache takes 34 bytes per 32 values, 17/32
|
||||
* of f16. The runtime's FAQ calls the quality loss "very small". Never q4_0: its loss
|
||||
* grows with the context, and a coding agent works at long contexts.
|
||||
* - `OLLAMA_FLASH_ATTENTION=1` becomes `--flash-attn on`. A quantized V cache needs flash
|
||||
* attention, and left unset the runtime passes `auto`, or `off` where it thinks the GPU
|
||||
* cannot, which would make every load with a q8_0 cache fail.
|
||||
* - `LLAMA_ARG_CACHE_RAM=1024` bounds llama-server's host prompt cache (the copies of
|
||||
* earlier prompts it keeps in memory) to 1024 MiB. The runtime passes no `--cache-ram`
|
||||
* for a coding model, and llama-server reads the variable from the environment it
|
||||
* inherits (`cmd.Env = os.Environ()`). Unbounded, that cache can grow by gigabytes in a
|
||||
* session; 1024 MiB is counted in every `llamacpp` row's `overheadBytes`.
|
||||
* - `LLAMA_ARG_CTX_CHECKPOINTS=8` bounds the context checkpoints llama-server keeps per
|
||||
* slot (`--ctx-checkpoints`, read from the environment the same way). The llama.cpp the
|
||||
* runtime bundles (b12111, the runtime's LLAMA_CPP_VERSION) makes them for every model
|
||||
* of the catalogue's `llamacpp` rows: a checkpoint is a host copy of the state that
|
||||
* cannot be rolled back (the recurrent state of the linear-attention layers, or the
|
||||
* sliding-window cache), two or three per request, and llama-server keeps up to 32 by
|
||||
* default. 32 copies of the 27B's 157 MB state are 5 GB, most of which an agent never
|
||||
* uses: its next request restores the newest checkpoint of the previous one. 8
|
||||
* (llama.cpp's own default from b6800 to b8200; it became 32 at b8250, together with a
|
||||
* checkpoint every 8,192 tokens of a long prompt) keeps the last few requests. The cost:
|
||||
* a prompt that differs from the last checkpoints further back (a compaction, a cleared
|
||||
* old tool result) is processed again from the newest checkpoint before that point, or
|
||||
* from its start. 8 checkpoints are counted in every `llamacpp` row's `overheadBytes`.
|
||||
* The MLX runner reads none of the four (nothing under mlxrunner/ does): an `mlx` row's
|
||||
* figures do not depend on them.
|
||||
* - PORT (`chooseRuntimePort()`). Chosen ONCE by binding, from `RUNTIME_PORT_FIRST` across
|
||||
* `RUNTIME_PORT_SPAN`, then stored by the caller. Never searched again on a later start.
|
||||
* - SUPERVISOR (`createRuntimeSupervisor()`). The runtime is a NON-detached child of the
|
||||
@@ -83,6 +114,12 @@ export const RUNTIME_MAX_RESTARTS = 3;
|
||||
export const RUNTIME_DOWNLOAD_RETRIES = 3;
|
||||
/** `OLLAMA_KEEP_ALIVE`: how long the model stays loaded after the last request. */
|
||||
export const RUNTIME_KEEP_ALIVE = '10m';
|
||||
/** `OLLAMA_KV_CACHE_TYPE`: the K/V cache type of the llama.cpp runner. The catalogue's `llamacpp` rows are computed for it. */
|
||||
export const RUNTIME_KV_CACHE_TYPE = 'q8_0';
|
||||
/** `LLAMA_ARG_CACHE_RAM`, in MiB: the bound of llama-server's host prompt cache. Counted in every `llamacpp` row's overhead. */
|
||||
export const RUNTIME_HOST_PROMPT_CACHE_MIB = 1024;
|
||||
/** `LLAMA_ARG_CTX_CHECKPOINTS`: the context checkpoints llama-server keeps per slot. Counted in every `llamacpp` row's overhead. */
|
||||
export const RUNTIME_CTX_CHECKPOINTS = 8;
|
||||
|
||||
/** The name of the runtime's binary inside its folder and inside the archive. */
|
||||
const RUNTIME_BINARY_NAME = 'ollama';
|
||||
@@ -253,6 +290,11 @@ export function runtimeEnv(input: RuntimeEnvInput): NodeJS.ProcessEnv {
|
||||
env.OLLAMA_KEEP_ALIVE = RUNTIME_KEEP_ALIVE;
|
||||
env.OLLAMA_NUM_PARALLEL = '1';
|
||||
env.OLLAMA_MAX_LOADED_MODELS = '1';
|
||||
// The memory settings of the llama.cpp runner (file header, MEMORY). Inert for an MLX model.
|
||||
env.OLLAMA_KV_CACHE_TYPE = RUNTIME_KV_CACHE_TYPE;
|
||||
env.OLLAMA_FLASH_ATTENTION = '1';
|
||||
env.LLAMA_ARG_CACHE_RAM = String(RUNTIME_HOST_PROMPT_CACHE_MIB);
|
||||
env.LLAMA_ARG_CTX_CHECKPOINTS = String(RUNTIME_CTX_CHECKPOINTS);
|
||||
return env;
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -1657,10 +1657,13 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
|
||||
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
|
||||
* never a confinement root, like `paneCwd`: it is judged lexically when the pane moves, and the
|
||||
* listing route re-checks the real folder on every request.
|
||||
* The git worktree that {@link paneCwd} is in, when there is one (`live-worktree.ts`): a linked
|
||||
* worktree inside `workingDir`, or, for an outside answer, a linked worktree or the repository's
|
||||
* main worktree (from a session started in a linked one).
|
||||
* The Files panel lists it instead of the main checkout. Runtime-only, like `paneCwd`. One inside
|
||||
* `workingDir` is never a confinement root: it is judged lexically when the pane moves, and the
|
||||
* listing route re-checks the real folder on every request. One outside it (`outside: true`) is
|
||||
* a second root for the file routes, re-checked on every request the same way.
|
||||
*/
|
||||
get liveWorktree(): LiveWorktree | undefined {
|
||||
return this._liveWorktree;
|
||||
@@ -2452,7 +2455,9 @@ export class Session extends EventEmitter {
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
paneCwd: this._paneCwd,
|
||||
liveWorktree: this._liveWorktree,
|
||||
// The wire keeps `liveWorktree` inside `workingDir` only; an outside answer rides its own field.
|
||||
liveWorktree: this._liveWorktree?.outside ? undefined : this._liveWorktree,
|
||||
outsideWorktree: this._liveWorktree?.outside ? this._liveWorktree : undefined,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
adopt: this._adopt,
|
||||
|
||||
+37
-13
@@ -120,6 +120,9 @@ export interface TestedOn {
|
||||
tokensPerSecond?: number;
|
||||
}
|
||||
|
||||
/** The runner of the model runtime that runs a model on a Mac, by the registry's name for it. See `LocalModelEntry.runner`. */
|
||||
export type LocalModelRunner = 'mlx' | 'llamacpp';
|
||||
|
||||
/** One model of the catalogue. */
|
||||
export interface LocalModelEntry {
|
||||
/** `name:tag` exactly as the model registry lists it. Lowercase, one colon, no slash (`LOCAL_MODEL_ID_PATTERN`). */
|
||||
@@ -130,35 +133,56 @@ export interface LocalModelEntry {
|
||||
license: string;
|
||||
/** Parameter count in billions. When no model that fits has 12 or more, the verdict is `limited`, never `ok`. */
|
||||
paramsB: number;
|
||||
/**
|
||||
* The runner the runtime uses for this tag ON A MAC, as the registry names it: `mlx` (a
|
||||
* safetensors build, or a manifest list with an MLX child, which a Mac always takes) or
|
||||
* `llamacpp` (a GGUF build, run by llama-server on Metal). The two spend memory in different
|
||||
* ways, so a row's memory figures are only meaningful together with its runner: llama.cpp
|
||||
* allocates the whole K/V cache for the context when the model loads (in the runtime's
|
||||
* `OLLAMA_KV_CACHE_TYPE`), MLX grows it with the tokens actually used and keeps snapshots of
|
||||
* it besides.
|
||||
*/
|
||||
runner: LocalModelRunner;
|
||||
/**
|
||||
* Size of the weights in bytes: both the download and what they take in memory.
|
||||
*
|
||||
* It is the size of the build the runtime really downloads ON A MAC. That is not always
|
||||
* the sum of the registry manifest: `qwen3.6:35b-a3b` is an MLX build of 23,105,798,956 bytes
|
||||
* on Apple silicon, 2% more than the manifest's 22,621,314,161. A row whose Mac size nobody
|
||||
* measured keeps the manifest sum and says so in `figures`.
|
||||
* It is the size of the build the runtime really downloads ON A MAC: the build of `runner`,
|
||||
* every layer of its manifest plus its config, which is the `total` a real pull reports.
|
||||
* That is not always the first manifest the registry answers with: `qwen3.6:35b-a3b` is an
|
||||
* MLX build of 23,105,798,956 bytes on Apple silicon, 2% more than its GGUF build of
|
||||
* 22,621,314,381.
|
||||
*/
|
||||
bytes: number;
|
||||
/**
|
||||
* Bytes that stay HELD in memory per token of context once the token was processed (measured
|
||||
* on the MLX runner: 36,000 to 38,000, so about 37,000). A figure computed from a model's
|
||||
* config alone came out too low by almost half, so a row that was not measured carries the
|
||||
* measured row's figure and says so in `figures`.
|
||||
* Bytes that stay HELD in memory per token of context. For a `llamacpp` row: the K/V cache
|
||||
* of its attention layers in the runtime's cache type, which llama.cpp allocates for the
|
||||
* whole context at load (a multi-token-prediction layer's in f16). For an `mlx` row: the K/V cache in bf16 plus the snapshots of the
|
||||
* recurrent state the runner keeps every 8,192 tokens, which it grows with use. Measured
|
||||
* on one row (36,000 to 38,000 on the MLX runner, so about 37,000); computed from the
|
||||
* model's config for the others, see `figures` and the catalogue header.
|
||||
*/
|
||||
heldBytesPerToken: number;
|
||||
/** What the runner holds beside the weights and the context (measured: about 3 GB). */
|
||||
/**
|
||||
* What the runner holds beside the weights and the context, whatever the context: its own
|
||||
* buffers, the live recurrent or sliding-window caches, and the caches it keeps of earlier
|
||||
* prompts (for `llamacpp` the 1024 MiB host prompt cache `LLAMA_ARG_CACHE_RAM` allows and
|
||||
* the copies of the recurrent or sliding-window state in its `LLAMA_ARG_CTX_CHECKPOINTS`
|
||||
* context checkpoints, for an estimated `mlx` row the 8 GiB of prefix snapshots the runner
|
||||
* may keep). Measured on one row (about 3 GB); see the catalogue header for the others.
|
||||
*/
|
||||
overheadBytes: number;
|
||||
/**
|
||||
* The TRANSIENT extra the runner takes while it processes a long prompt, on top of
|
||||
* what stays held (measured: about 5 GB). The fit is judged on the peak:
|
||||
* what stays held (measured on the MLX runner: about 5 GB; 0 for `llamacpp`, which
|
||||
* allocates its buffers at load). The fit is judged on the peak:
|
||||
* `bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`. For
|
||||
* `qwen3.6:35b-a3b` at 131072 tokens that is about 36 GB, which is tight on a 48 GB Mac.
|
||||
*/
|
||||
peakExtraBytes: number;
|
||||
/**
|
||||
* Where `bytes` and the three memory figures above come from: `measured` on a Mac by
|
||||
* us, or `estimated` (the manifest size, and the per-token, overhead and peak figures of the
|
||||
* measured row applied to this one).
|
||||
* Where the three memory figures above come from: `measured` on a Mac by us, or
|
||||
* `estimated` (computed from the model's architecture for its runner, see the catalogue
|
||||
* header). `bytes` is the registry's size of the build a Mac pulls in both cases.
|
||||
*/
|
||||
figures: 'measured' | 'estimated';
|
||||
/** Context lengths (tokens) the model may be run at, largest first. */
|
||||
|
||||
+17
-6
@@ -934,20 +934,31 @@ export interface SessionState {
|
||||
* from disk, so a restart starts blank. Absent for remote and docker sessions
|
||||
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
|
||||
* rediscovered sessions. NOT a confinement root: the file routes stay confined
|
||||
* to `workingDir`, and a path resolved outside it goes through the attachment
|
||||
* routes like any other out-of-workspace path.
|
||||
* to `workingDir`, except for an absolute path inside an
|
||||
* {@link outsideWorktree}. Any other path resolved outside `workingDir` goes
|
||||
* through the attachment routes like any other out-of-workspace path.
|
||||
*/
|
||||
paneCwd?: string;
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
|
||||
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
|
||||
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
|
||||
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
|
||||
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
|
||||
* only starts its listing here, after `GET .../files` has checked that the real folder still
|
||||
* lies inside `workingDir`, since a symlink can replace it without moving the pane.
|
||||
* runtime-only and absent wherever `paneCwd` is, and always a folder strictly below
|
||||
* `workingDir`: a worktree elsewhere is reported in {@link outsideWorktree}, never here. NOT a
|
||||
* confinement root; the Files panel only starts its listing here, after `GET .../files` has
|
||||
* checked that the real folder still lies inside `workingDir`, since a symlink can replace it
|
||||
* without moving the pane.
|
||||
*/
|
||||
liveWorktree?: LiveWorktree;
|
||||
/**
|
||||
* The worktree of the session's repository outside `workingDir` that the pane is in NOW, when
|
||||
* there is one: a linked worktree, or the repository's main worktree for a session started in a
|
||||
* linked one. It carries `outside: true`, and its `path` is a real path that the repository
|
||||
* itself vouched for. The file routes serve absolute paths inside it as a second root while it
|
||||
* still resolves to that same path. Runtime-only, like {@link liveWorktree}; at most one of
|
||||
* the two fields is set.
|
||||
*/
|
||||
outsideWorktree?: LiveWorktree;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @fileoverview One-time boot migration that lets the 1.50.0 agent skill default reach installs
|
||||
* that saved App Settings before it.
|
||||
*
|
||||
* `agentSkillEnabled` is ON by default since 1.50.0 (an absent key is on, only an explicit false
|
||||
* is off). Up to 1.49.0 the App Settings switch loaded an absent key as off (`?? false`) and Save
|
||||
* sends the whole form, so every install whose App Settings were ever saved holds an explicit
|
||||
* `false` the user may never have chosen, and the new default would never reach it. On the first
|
||||
* start of a server that has this module, a stored `false` is switched on, exactly once per data
|
||||
* dir. It cannot tell a deliberate off from the old default; that is accepted and stated as a
|
||||
* behaviour change in the release notes. An off set after this ran is never touched again.
|
||||
*
|
||||
* Why a stored `false` becomes `true` rather than being deleted: deleting it would not reach the
|
||||
* page. Every browser that saved App Settings keeps the form in its localStorage blob, and
|
||||
* `loadAppSettingsFromServer()` only overwrites a synced key the server HAS: a key the server
|
||||
* lacks keeps the device's `false`, the switch loads off, and the next Save of any setting writes
|
||||
* `false` back (measured with the real page code in the JSDOM harness of
|
||||
* test/agent-skill-setting-ui.test.ts, which pins the shipped shape). A stored `true` wins on
|
||||
* every device, including a browser still running a cached settings-ui.js from before the upgrade.
|
||||
* Every reader treats `true` and an absent key alike (`!== false`).
|
||||
*
|
||||
* Why the "done" marker is its own file (`agent-skill-default-migrated.json` in the data dir)
|
||||
* and not a key in settings.json:
|
||||
* - it is not a setting, so `GET /api/settings` never returns it, the page never holds it, and
|
||||
* the page's Save (rebuilt from the form) can neither send it back nor drop it; the `.strict()`
|
||||
* `SettingsUpdateSchema` never sees it;
|
||||
* - a PUT or any other settings.json writer cannot erase it, which would re-arm the migration and
|
||||
* switch a deliberate later off back on;
|
||||
* - it is not in `CONFIG_BACKUP_FILES` (like `config-backup-id`), so a restore leaves it alone:
|
||||
* once the migration has run, a restored settings.json, even one from before 1.50.0 holding
|
||||
* `false`, is what the user asked for and stays as restored.
|
||||
* A data dir is one instance, and settings.json is one file for the whole server, so in
|
||||
* multi-user mode this runs once for the server, never per user.
|
||||
*
|
||||
* Order: the marker is checked first (present: nothing is read), the settings change goes through
|
||||
* `mutateSettingsFile()` (serialized, atomic, refuses a file that does not parse), and the marker
|
||||
* is written only after that resolves. A refused or failed settings write writes no marker, so the
|
||||
* migration runs again at the next start (after the user fixed the file). Until then it has not
|
||||
* run, so an off saved after the fix and before that start is switched on with the rest: the same
|
||||
* "cannot tell" trade as above, in a window that needs an unparseable settings.json at the first
|
||||
* 1.50.0 start. A crash between the two writes is harmless: the next start finds `true` (nothing
|
||||
* to change) and writes the marker.
|
||||
*
|
||||
* Called once from `WebServer.start()`, before the first settings read and before the server
|
||||
* listens, so no request path can see the old value.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { getErrorMessage } from '../types.js';
|
||||
import { mutateSettingsFile, SettingsWriteRefusedError } from './settings-writer.js';
|
||||
|
||||
/** The marker's file name in the data dir. Never in CONFIG_BACKUP_FILES (see the file comment). */
|
||||
export const AGENT_SKILL_DEFAULT_MARKER = 'agent-skill-default-migrated.json';
|
||||
|
||||
/**
|
||||
* - `switched-on`: settings.json held `agentSkillEnabled: false`; it now holds `true`.
|
||||
* - `nothing-to-change`: the key was absent or not `false` (or there is no settings.json); nothing was written to it.
|
||||
* - `already-done`: the marker exists; settings.json was not read.
|
||||
* - `refused`: settings.json could not be read, parsed or written, or the marker could not be checked.
|
||||
* Nothing changed and no marker was written, so the next start tries again.
|
||||
*/
|
||||
export type AgentSkillDefaultMigration = 'switched-on' | 'nothing-to-change' | 'already-done' | 'refused';
|
||||
|
||||
/** The log line of a migration that switched the skill on. */
|
||||
export const AGENT_SKILL_DEFAULT_MIGRATED_LOG =
|
||||
'[agent-skill] switched on the off stored before 1.50.0, so the new default applies (turn it off again under App Settings, Agents & CLIs, Codeman Agent Skill; that choice is kept)';
|
||||
|
||||
export async function migrateAgentSkillDefault(
|
||||
markerPath: string = dataPath(AGENT_SKILL_DEFAULT_MARKER)
|
||||
): Promise<AgentSkillDefaultMigration> {
|
||||
try {
|
||||
await fs.lstat(markerPath);
|
||||
return 'already-done';
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// Not knowing whether it ran must not switch a deliberate off on a second time.
|
||||
console.warn(`[agent-skill] default migration skipped: cannot check ${markerPath} (${getErrorMessage(err)})`);
|
||||
return 'refused';
|
||||
}
|
||||
}
|
||||
|
||||
let switched = false;
|
||||
try {
|
||||
await mutateSettingsFile((current) => {
|
||||
if (current.agentSkillEnabled !== false) return undefined;
|
||||
switched = true;
|
||||
return { ...current, agentSkillEnabled: true };
|
||||
});
|
||||
} catch (err) {
|
||||
// A refusal is already logged by the writer (`[settings] write refused: ...`), with the fix.
|
||||
const why = err instanceof SettingsWriteRefusedError ? 'settings.json was left as it is' : getErrorMessage(err);
|
||||
console.warn(`[agent-skill] default migration skipped (${why}); it runs again at the next start`);
|
||||
return 'refused';
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(dirname(markerPath), { recursive: true });
|
||||
const marker = { migratedAt: new Date().toISOString(), switchedOn: switched };
|
||||
await fs.writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
||||
} catch (err) {
|
||||
// settings.json is already right; without the marker the next start runs again, which
|
||||
// changes nothing unless the user has switched it off by then.
|
||||
console.warn(`[agent-skill] could not record the default migration at ${markerPath}: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
if (switched) console.log(AGENT_SKILL_DEFAULT_MIGRATED_LOG);
|
||||
return switched ? 'switched-on' : 'nothing-to-change';
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export interface ConfigPort {
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
||||
/** Synced `agentSkillEnabled` app setting (default ON, only an explicit false is off); gates per-case agent-skill injection. */
|
||||
getAgentSkillEnabled(): Promise<boolean>;
|
||||
/** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */
|
||||
getWorkspaceHooksEnabled(): Promise<boolean>;
|
||||
|
||||
@@ -2973,7 +2973,7 @@
|
||||
<div class="set-row" data-search="agent skill codeman api workers codex gemini opencode pi grok deepseek omp antigravity">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell.</span>
|
||||
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi, GitHub Copilot and a plain shell. On by default. The copy ignores itself in git, so your repository never lists it as a new file. Off stops new copies but removes none: <code>codeman skill uninstall --case <name></code> does that.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
|
||||
+53
-14
@@ -3293,6 +3293,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
|
||||
// The person asked for the session folder while the agent is in a worktree (#765).
|
||||
sessionRootPinned: false,
|
||||
// The worktree on screen when the pin was set: a pinned listing omits an outside worktree.
|
||||
pinnedWorktree: null,
|
||||
};
|
||||
}
|
||||
return this._fileBrowserState;
|
||||
@@ -3316,6 +3318,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
state.pinnedWorktree = null;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3360,6 +3363,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
state.pinnedWorktree = null;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3473,49 +3477,58 @@ Object.assign(CodemanApp.prototype, {
|
||||
_renderFileBrowserRoot(data) {
|
||||
const strip = this.$?.('fileBrowserRoot');
|
||||
if (!strip) return;
|
||||
const worktree = data?.worktree;
|
||||
const worktree =
|
||||
data?.worktree ?? (this._fileBrowserState?.sessionRootPinned ? this._fileBrowserState.pinnedWorktree : null);
|
||||
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
|
||||
strip.hidden = true;
|
||||
return;
|
||||
}
|
||||
const inWorktree = data.root === worktree.path;
|
||||
const inWorktree = data?.root === worktree.path;
|
||||
const label = this.$?.('fileBrowserRootLabel');
|
||||
const name = this.$?.('fileBrowserRootName');
|
||||
const button = this.$?.('fileBrowserRootSwitch');
|
||||
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
|
||||
if (name) name.textContent = inWorktree ? worktree.name : '';
|
||||
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
|
||||
strip.title = typeof data.root === 'string' ? data.root : '';
|
||||
strip.title = typeof data?.root === 'string' ? data.root : '';
|
||||
strip.hidden = false;
|
||||
},
|
||||
|
||||
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
|
||||
// a worktree, so the panel always asks unless the person chose the session folder.
|
||||
_fileBrowserRootParam() {
|
||||
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
|
||||
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live-any';
|
||||
},
|
||||
|
||||
toggleFileBrowserRoot() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
state.sessionRootPinned = !state.sessionRootPinned;
|
||||
const shown = this.fileBrowserData?.worktree;
|
||||
state.pinnedWorktree =
|
||||
state.sessionRootPinned && typeof shown?.path === 'string' && typeof shown?.name === 'string'
|
||||
? { name: shown.name, path: shown.path }
|
||||
: null;
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Called on every session update of the active session. Compares with what the listing on
|
||||
// screen reported rather than with the previous update, so a load already in flight when the
|
||||
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
|
||||
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
|
||||
// pane moved is corrected by the next update, and an answer that matches ends it. A listing
|
||||
// reports the session's worktree whichever folder it walked, except that a listing without
|
||||
// root=live-any omits an outside worktree. While the person has pinned the session folder, the
|
||||
// panel compares with the worktree it pinned away from, so this cannot loop.
|
||||
_followFileBrowserWorktree(session) {
|
||||
const state = this._fileBrowserState;
|
||||
if (!state || state.ownerSessionId !== session.id) return;
|
||||
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
|
||||
const normal = state.normalState;
|
||||
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
|
||||
const live = session.liveWorktree?.path ?? null;
|
||||
const listed = normal.data?.worktree?.path ?? null;
|
||||
const live = (session.liveWorktree ?? session.outsideWorktree)?.path ?? null;
|
||||
const listed = (normal.data?.worktree ?? (state.sessionRootPinned ? state.pinnedWorktree : null))?.path ?? null;
|
||||
if (live === listed) return;
|
||||
// The pin was about the worktree the agent has just left.
|
||||
state.sessionRootPinned = false;
|
||||
state.pinnedWorktree = null;
|
||||
this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
@@ -4256,10 +4269,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Whether a path is absolute and provably OUTSIDE this session's workspace.
|
||||
*
|
||||
* `file-content` / `file-raw` resolve every path against `workingDir` and
|
||||
* refuse anything that escapes it, so an absolute path elsewhere on the host
|
||||
* (an agent's `/tmp` scratchpad capture, a screenshot, another checkout) can
|
||||
* only ever 404 there — it has to go through the attachment routes instead.
|
||||
* `file-content` / `file-raw` serve paths inside `workingDir` and, as a second
|
||||
* root, an absolute path inside a followed worktree outside it
|
||||
* (`outsideWorktree`). They refuse every other path that escapes
|
||||
* `workingDir`, so an absolute path elsewhere on the host (an agent's `/tmp`
|
||||
* scratchpad capture, a screenshot, another checkout) can only ever 404 there
|
||||
* — it has to go through the attachment routes instead.
|
||||
*
|
||||
* A string compare is enough for ROUTING; the real containment decision stays
|
||||
* server-side (realpath + guard) on whichever route the request lands on. An
|
||||
@@ -4273,7 +4288,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
const workingDir = this.sessions.get(sessionId)?.workingDir;
|
||||
if (!workingDir) return false;
|
||||
const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`;
|
||||
return filePath !== workingDir && !filePath.startsWith(root);
|
||||
if (filePath === workingDir || filePath.startsWith(root)) return false;
|
||||
// A worktree outside `workingDir` that the agent is in (#765) is a second root the file
|
||||
// routes serve, so a path inside it previews (and edits) like a workspace path.
|
||||
return this._isOutsideWorktreePath(filePath, sessionId) !== true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether an absolute path lies inside the worktree OUTSIDE `workingDir` that the session's agent
|
||||
* is in (`outsideWorktree`, #765). The file routes serve it as a second root, while the log
|
||||
* viewer's `tail-file` stays confined to `workingDir`, so the terminal opens such a path in the
|
||||
* file preview.
|
||||
*/
|
||||
_isOutsideWorktreePath(filePath, sessionId) {
|
||||
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
|
||||
const worktree = this.sessions.get(sessionId)?.outsideWorktree;
|
||||
if (worktree?.outside !== true || typeof worktree.path !== 'string' || !worktree.path) return false;
|
||||
const root = worktree.path.endsWith('/') ? worktree.path : `${worktree.path}/`;
|
||||
return filePath === worktree.path || filePath.startsWith(root);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5206,6 +5238,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
||||
const unresolvable = !!attachmentId && !filePath.startsWith('/');
|
||||
// A root-relative ref (`/docs/x.png`) resolves from the workspace root, as on GitHub, or from
|
||||
// the top of the worktree outside the workspace that the agent is in (#765).
|
||||
const worktree = this.sessions.get(sessionId)?.outsideWorktree;
|
||||
const rootPrefix =
|
||||
worktree?.outside === true && this._isOutsideWorktreePath(filePath, sessionId)
|
||||
? `${worktree.path.replace(/\/+$/, '')}/`
|
||||
: '';
|
||||
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
|
||||
const isWorkspaceRef = (ref) =>
|
||||
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
|
||||
@@ -5225,7 +5264,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* malformed escape: keep the ref as written */
|
||||
}
|
||||
const parts = [];
|
||||
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
|
||||
for (const seg of (rel.startsWith('/') ? rootPrefix + rel.slice(1) : dir + rel).split('/')) {
|
||||
if (seg === '.' || (seg === '' && parts.length)) continue;
|
||||
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
|
||||
else parts.push(seg);
|
||||
|
||||
@@ -711,7 +711,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyCodexSettingsVisibility();
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false;
|
||||
// Default ON (since 1.50.0), like the workspace hooks below: the server reads an
|
||||
// absent key as on, so the switch must too, or the next Save would write false.
|
||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled !== false;
|
||||
// Default ON: an absent key is a user who has never seen this setting, and OFF
|
||||
// for them means no tab alerts in any workspace Codeman did not scaffold.
|
||||
document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false;
|
||||
@@ -5270,6 +5272,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
// The agent skill is a synced key that is ON when absent (since 1.50.0). A server
|
||||
// without the key means that default, so this device's own copy goes rather than
|
||||
// standing in for it: up to 1.49.0 the switch loaded an absent key as off, so a
|
||||
// device whose save never reached the server still holds `false`, and its next
|
||||
// Save would write it back. The server's one-time boot migration stores `true` for
|
||||
// installs that had saved `false` (agent-skill-default-migration.ts).
|
||||
if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled;
|
||||
this.saveAppSettingsToStorage(merged);
|
||||
|
||||
// Apply notification prefs from server if present (only if localStorage has none)
|
||||
|
||||
@@ -2367,7 +2367,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
// An HTML file renders as a page in the preview, never as source
|
||||
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
|
||||
if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) {
|
||||
// The log viewer's tail-file stays confined to workingDir, so a path in a worktree outside it previews.
|
||||
if (
|
||||
previewsInFileViewer(target) ||
|
||||
rendersAsPage(target) ||
|
||||
self._isExternalPreviewPath(target, sessionId) ||
|
||||
self._isOutsideWorktreePath(target, sessionId)
|
||||
) {
|
||||
self.openFilePreview(target, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"highlights": [
|
||||
"Open the tmux sessions you started yourself (#510)",
|
||||
"Install without administrator rights (#768)",
|
||||
"Docker Compose starts in two commands (#573)",
|
||||
"Codex sessions can drive other sessions (#753)",
|
||||
"Persian, Arabic and Hebrew read right to left (#715)",
|
||||
"Edit history for remote sessions (#766)",
|
||||
"The Files panel follows the agent into a git worktree (#772, #765)",
|
||||
"One big folder no longer hides the rest (#614)"
|
||||
"Codeman for Mac (#763)",
|
||||
"Your MacBook's Apple logo blinks with your agents (#781)",
|
||||
"Install from the app, with no Terminal and no password (#773)",
|
||||
"The Files panel follows a worktree outside the session's folder (#780)",
|
||||
"A coding model that runs on your Mac (#784, experimental)",
|
||||
"Claude in the cloud, a model on your Mac (#784, experimental)",
|
||||
"Ralph starts from Session Options (#783)",
|
||||
"The Codeman agent skill is on by default (#787)"
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type { WaitCapacityError } from './session-wait-registry.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { isSensitivePath } from './sensitive-path.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
|
||||
@@ -101,6 +102,46 @@ export function validateSessionFilePath(
|
||||
return confineToRoot(resolvedWorkingDir, resolvedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* The outside worktree (#765) a LOCAL session's file routes may serve `filePath` from, else null:
|
||||
* a recorded worktree with `outside` set, an absolute `filePath` lexically inside it, and a
|
||||
* folder that still resolves to the very path that was recorded. `findOutsideWorktree()` records
|
||||
* the real path, so a symlink that took the folder's place (or a parent's) since then fails the
|
||||
* last check and the path is judged against `workingDir` alone.
|
||||
*/
|
||||
export function outsideWorktreeRootFor(
|
||||
liveWorktree: { path: string; outside?: true } | undefined,
|
||||
filePath: string
|
||||
): string | null {
|
||||
if (!liveWorktree?.outside || !isAbsolute(filePath)) return null;
|
||||
const root = liveWorktree.path;
|
||||
const rel = relative(root, resolve(filePath));
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) return null;
|
||||
try {
|
||||
return realpathSync(root) === root ? root : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link validateSessionFilePath} for a LOCAL session that may have a worktree outside its folder
|
||||
* (the session's outside worktree, `outside: true`): an absolute path inside that worktree is confined to it, and, when
|
||||
* that refuses it (a worktree file that is a symlink into the session's own folder), to
|
||||
* `workingDir`, which is served anyway. A file there that `isSensitivePath()` blocklists (`.env`,
|
||||
* `.aws/credentials`, `users.json`, ...) is never served from the worktree root. Every other
|
||||
* path is judged against `workingDir` alone.
|
||||
*/
|
||||
export function validateLocalSessionFilePath(
|
||||
scope: { workingDir: string; liveWorktree?: { path: string; outside?: true } },
|
||||
filePath: string
|
||||
): { resolvedPath: string; relativePath: string } | null {
|
||||
const root = outsideWorktreeRootFor(scope.liveWorktree, filePath);
|
||||
const viaRoot = root ? validateSessionFilePath(root, filePath) : null;
|
||||
if (viaRoot && !isSensitivePath(viaRoot.resolvedPath)) return viaRoot;
|
||||
return validateSessionFilePath(scope.workingDir, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
|
||||
* WITHOUT touching the filesystem.
|
||||
|
||||
@@ -49,7 +49,13 @@ import {
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import {
|
||||
isBlockedAttachmentDirectory,
|
||||
isBlockedAttachmentPath,
|
||||
isOutsideWorktreeRootRefused,
|
||||
isUnderTree,
|
||||
loadAttachmentGuardConfig,
|
||||
} from '../../config/attachment-guard.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
@@ -58,11 +64,13 @@ import {
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validateLocalSessionFilePath,
|
||||
validateSessionFilePath,
|
||||
validateSessionFilePathLexical,
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionRemote, SessionState } from '../../types/session.js';
|
||||
import type { LiveWorktree } from '../../live-worktree.js';
|
||||
import {
|
||||
RemoteFileAccessError,
|
||||
remoteCreateReadStream,
|
||||
@@ -574,6 +582,11 @@ function getKnownSessionWorkingDir(
|
||||
/** Where a session's files live: this host, or a host reachable over ssh. */
|
||||
interface SessionFileScope {
|
||||
workingDir: string;
|
||||
/**
|
||||
* A live local session's worktree (#765). Only one with `outside` set changes anything here: it
|
||||
* is a second root that ABSOLUTE paths inside it are confined to (`validateLocalSessionFilePath`).
|
||||
*/
|
||||
liveWorktree?: LiveWorktree;
|
||||
remote?: SessionRemote;
|
||||
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
|
||||
docker?: boolean;
|
||||
@@ -606,7 +619,12 @@ function getKnownSessionFileScope(
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) {
|
||||
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
liveWorktree: liveSession.liveWorktree,
|
||||
remote: liveSession.remote,
|
||||
docker: !!liveSession.docker,
|
||||
};
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
@@ -648,6 +666,19 @@ type FileTargetResolution =
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The scope with an outside worktree (#765) dropped when the attachment guard refuses its root
|
||||
* right now. `findOutsideWorktree()` judged the guard when the pane moved, and an admin can turn
|
||||
* on workspace confinement or block the folder afterwards, so every request judges it again. A
|
||||
* scope without an outside worktree comes back unchanged and reads no settings.
|
||||
*/
|
||||
async function withGuardedOutsideWorktree<T extends { liveWorktree?: LiveWorktree }>(scope: T): Promise<T> {
|
||||
const worktree = scope.liveWorktree;
|
||||
if (!worktree?.outside) return scope;
|
||||
if (!isOutsideWorktreeRootRefused(worktree.path, await loadAttachmentGuardConfig())) return scope;
|
||||
return { ...scope, liveWorktree: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request's `?path=` against the session's file scope.
|
||||
*
|
||||
@@ -657,7 +688,7 @@ type FileTargetResolution =
|
||||
*/
|
||||
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
|
||||
if (!scope.remote) {
|
||||
const validated = validateSessionFilePath(scope.workingDir, filePath);
|
||||
const validated = validateLocalSessionFilePath(await withGuardedOutsideWorktree(scope), filePath);
|
||||
if (!validated) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -876,6 +907,22 @@ async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `root=live-any` listing may start at a worktree OUTSIDE `workingDir` (`outside: true`,
|
||||
* published as `outsideWorktree`).
|
||||
* `findOutsideWorktree()` recorded its real path, and a symlink can take the folder's place, or a
|
||||
* parent's, without moving the pane, so the folder must still resolve to exactly that path. Probed
|
||||
* first (#516).
|
||||
*/
|
||||
async function canWalkOutsideWorktree(worktreePath: string): Promise<boolean> {
|
||||
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
|
||||
try {
|
||||
return (await fs.realpath(worktreePath)) === worktreePath;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||
return roots
|
||||
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||
@@ -944,11 +991,7 @@ function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>):
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
||||
// opened and used to enumerate those filenames.
|
||||
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||
return directory ? isBlockedAttachmentDirectory(path, blockedTrees) : isBlockedAttachmentPath(path, blockedTrees);
|
||||
}
|
||||
|
||||
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
|
||||
@@ -1708,11 +1751,28 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
|
||||
// paths against the same root as any other. Only the search predicate reads a path relative
|
||||
// to the listing, since that is the tree the person is looking at.
|
||||
const liveWorktree = session.liveWorktree;
|
||||
const walkRoot =
|
||||
root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
|
||||
? liveWorktree.path
|
||||
: workingDir;
|
||||
// A worktree OUTSIDE `workingDir` (the session's `outsideWorktree` on the wire) is a second
|
||||
// root, and walking it is opt-in: only `root=live-any` does. `root=live` never walks it (every
|
||||
// `path` stays relative to `workingDir`), so with an outside worktree it walks `workingDir`. The outside
|
||||
// walk happens only while the folder still resolves to the real path recorded for it, and it
|
||||
// lists ABSOLUTE `path`s, since a relative one resolves against `workingDir` on every other
|
||||
// route. Those routes confine an absolute path inside it to it (`validateLocalSessionFilePath`).
|
||||
const liveWorktree = (await withGuardedOutsideWorktree({ liveWorktree: session.liveWorktree })).liveWorktree;
|
||||
const walksOutside =
|
||||
root === 'live-any' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
|
||||
const walksInside =
|
||||
(root === 'live' || root === 'live-any') &&
|
||||
!!liveWorktree &&
|
||||
!liveWorktree.outside &&
|
||||
(await canWalkLiveWorktree(workingDir, liveWorktree.path));
|
||||
const walkRoot = liveWorktree && (walksOutside || walksInside) ? liveWorktree.path : workingDir;
|
||||
// Only `root=live-any` reports an outside worktree: it is the client that opted in to it.
|
||||
// A listing keeps reporting a worktree the guard refuses to walk, or the panel would reload on
|
||||
// every session update.
|
||||
const sessionWorktree = session.liveWorktree;
|
||||
const reportedWorktree =
|
||||
sessionWorktree && (!sessionWorktree.outside || root === 'live-any') ? sessionWorktree : undefined;
|
||||
const nodePath = (fullPath: string): string => (walksOutside ? fullPath : fullPath.slice(workingDir.length + 1));
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
const matcher = compileFileQuery(q ?? '');
|
||||
@@ -1802,7 +1862,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
|
||||
const entry = cursor.entries[cursor.index++];
|
||||
const fullPath = join(cursor.dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
const relativePath = walksOutside ? fullPath : relative(workingDir, fullPath);
|
||||
const listedPath = relative(walkRoot, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
@@ -1840,7 +1900,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
success: true,
|
||||
data: {
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
...(reportedWorktree && { worktree: reportedWorktree }),
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
@@ -1893,7 +1953,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) {
|
||||
const entry = cursor.entries[cursor.index++];
|
||||
const fullPath = join(cursor.dirPath, entry.name);
|
||||
const relativePath = fullPath.slice(workingDir.length + 1);
|
||||
const relativePath = nodePath(fullPath);
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
const node: FileTreeNode = {
|
||||
@@ -1931,7 +1991,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
success: true,
|
||||
data: {
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
...(reportedWorktree && { worktree: reportedWorktree }),
|
||||
tree,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
@@ -1958,7 +2018,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
// For a remote (SSH) case the same boundary is resolved on the remote host (#415),
|
||||
// where the path actually lives.
|
||||
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
||||
const resolution = await resolveFileTarget(
|
||||
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
|
||||
filePath
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
// An unreachable remote is thrown so the shared handler answers a real 502 (and
|
||||
// the test can assert it); a refusal keeps this route's historical returned
|
||||
@@ -2230,7 +2293,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(session.workingDir, body.path);
|
||||
const validated = validateLocalSessionFilePath(
|
||||
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
|
||||
body.path
|
||||
);
|
||||
if (!validated) {
|
||||
// Covers missing files, traversal, and symlink escapes alike — a write
|
||||
// target that fails confinement is reported identically to a missing
|
||||
@@ -2333,7 +2399,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
||||
const resolution = await resolveFileTarget(
|
||||
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
|
||||
filePath
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
||||
return;
|
||||
@@ -2972,7 +3041,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId, req);
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
const validated = validateLocalSessionFilePath(
|
||||
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
|
||||
filePath
|
||||
);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
|
||||
@@ -33,7 +33,14 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
validateLocalSessionFilePath,
|
||||
validateSessionFilePath,
|
||||
} from '../route-helpers.js';
|
||||
import { HtmlViewOpenSchema } from '../schemas.js';
|
||||
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
|
||||
import { getUserSpacesDir } from '../../config/multiuser.js';
|
||||
@@ -175,9 +182,15 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// Inside the workspace: the same check every file route uses. Outside it: the
|
||||
// attachment guard, exactly as an external file preview is admitted.
|
||||
let resolvedPath: string;
|
||||
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
|
||||
const inWorkspace = validateLocalSessionFilePath(session, requestedPath);
|
||||
// A path the live-worktree root admitted (an outside worktree, a second served root) is
|
||||
// inside that root but not inside `workingDir`: it still answers to the attachment guard.
|
||||
let viaOutsideWorktree = false;
|
||||
if (inWorkspace) {
|
||||
resolvedPath = inWorkspace.resolvedPath;
|
||||
if (!validateSessionFilePath(session.workingDir, requestedPath)) {
|
||||
viaOutsideWorktree = true;
|
||||
}
|
||||
} else {
|
||||
if (!isAbsolute(requestedPath)) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
@@ -189,6 +202,9 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// One gate for every page outside `workingDir`: an external file and one in an outside worktree.
|
||||
if (!inWorkspace || viaOutsideWorktree) {
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
||||
@@ -233,7 +249,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
return;
|
||||
}
|
||||
|
||||
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
|
||||
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace || viaOutsideWorktree);
|
||||
return {
|
||||
success: true,
|
||||
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
|
||||
|
||||
@@ -979,8 +979,8 @@ export function _paneDeathWatcherCount(): number {
|
||||
*
|
||||
* `applyAgentSkill` declines two shapes rather than writing through them ('foreign':
|
||||
* an unmarked skills/codeman the user authored; 'symlink': the skill dir or its
|
||||
* parent is a link). Both were silent: the user flips `agentSkillEnabled` on, nothing
|
||||
* appears in the case, and there is nowhere to look for why. The ordinary outcomes
|
||||
* parent is a link). Both were silent: `agentSkillEnabled` is on (the default since
|
||||
* 1.50.0), nothing appears in the case, and there is nowhere to look for why. The ordinary outcomes
|
||||
* ('installed'/'refreshed'/'unchanged') stay unlogged since they would print on every
|
||||
* single session create.
|
||||
*
|
||||
@@ -1004,7 +1004,7 @@ async function injectAgentSkill(casePath: string, dir: AgentSkillDir): Promise<v
|
||||
);
|
||||
} else if (result === 'symlink') {
|
||||
console.warn(
|
||||
`[agent-skill] not injected: ${skillDir} (or its parent) is a symlink, refusing to write through it. Replace it with a real directory to let Codeman install the skill.`
|
||||
`[agent-skill] not injected: ${skillDir}, its parent or its reference folder is a symlink, refusing to write through it. Replace it with a real directory to let Codeman install the skill.`
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
|
||||
+7
-5
@@ -1471,11 +1471,13 @@ export const SettingsUpdateSchema = z
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Inject the Codeman agent skill (`skills/codeman`) into `<case>/.claude/skills/`
|
||||
* on Claude session create, so an agent inside the session can drive the API
|
||||
* (see docs/agent-control-plan.md §2). SYNCED, default OFF: every skill's
|
||||
* name+description costs context on every turn, so it is opt-in. Injection is
|
||||
* add-only at create; a marker keeps user-authored copies untouched.
|
||||
* Inject the Codeman agent skill (`skills/codeman`) into each new session's case, in
|
||||
* the directory its CLI scans for skills (`capabilities.agentSkillDir`), so an agent
|
||||
* inside the session can drive the API (see docs/agent-control-plan.md §2). SYNCED,
|
||||
* default ON since 1.50.0: an absent key is on, only an explicit false is off, and a
|
||||
* false stored by 1.49.0 or earlier is switched on once at the first 1.50.0 start
|
||||
* (agent-skill-default-migration.ts). Injection is add-only at create; a marker keeps
|
||||
* user-authored copies untouched.
|
||||
*/
|
||||
agentSkillEnabled: z.boolean().optional(),
|
||||
/**
|
||||
|
||||
+55
-7
@@ -35,7 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { findLiveWorktree } from '../live-worktree.js';
|
||||
import { findLiveWorktree, type OutsideWorktreeOptions } from '../live-worktree.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
|
||||
@@ -80,7 +80,8 @@ import {
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { folderGrants } from '../folder-listing.js';
|
||||
import { folderAllowedForUser, folderGrants } from '../folder-listing.js';
|
||||
import { isOutsideWorktreeRootRefused, loadAttachmentGuardConfig } from '../config/attachment-guard.js';
|
||||
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
|
||||
import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js';
|
||||
@@ -196,6 +197,7 @@ import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
|
||||
import { mutateSettingsFile } from './settings-writer.js';
|
||||
import { migrateAgentSkillDefault } from './agent-skill-default-migration.js';
|
||||
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
|
||||
import { telemetrySignature } from '../usage-telemetry.js';
|
||||
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
|
||||
@@ -214,7 +216,7 @@ import {
|
||||
sendLostWebviewFramePage,
|
||||
} from './middleware/auth.js';
|
||||
import { isMultiUserMode } from '../config/multiuser.js';
|
||||
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { bootstrapInitialAdmin, findUser, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
import { installEmptyApiPostParser } from './request-body-parser.js';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
|
||||
@@ -2464,11 +2466,15 @@ export class WebServer extends EventEmitter {
|
||||
return resolveTerminalHistoryConfig(settings);
|
||||
}
|
||||
|
||||
// Whether the Codeman agent skill is injected into cases on Claude session create
|
||||
// (synced `agentSkillEnabled` setting, default OFF; docs/agent-control-plan.md §2).
|
||||
// Whether the Codeman agent skill is injected into a session's workspace on create
|
||||
// (synced `agentSkillEnabled` setting; docs/agent-control-plan.md §2). Default ON
|
||||
// since 1.50.0, like `workspaceHooksEnabled` below: an absent key means a user who
|
||||
// has never touched the switch, and only an explicit false turns it off. The copy
|
||||
// ignores itself in git (`.gitignore` of `*`, see installAgentSkillInto), so the
|
||||
// default leaves a user's `git status` clean.
|
||||
private async getAgentSkillEnabled(): Promise<boolean> {
|
||||
const settings = await this.readSettings();
|
||||
return settings.agentSkillEnabled === true;
|
||||
return settings.agentSkillEnabled !== false;
|
||||
}
|
||||
|
||||
// Whether pending agent-inbox mail is snapshotted to disk (synced
|
||||
@@ -3035,11 +3041,45 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private async refreshLiveWorktree(session: Session): Promise<void> {
|
||||
const cwd = session.paneCwd;
|
||||
const worktree = await findLiveWorktree(session.workingDir, cwd);
|
||||
const worktree = await findLiveWorktree(session.workingDir, cwd, this.outsideWorktreeOptions(session));
|
||||
if (session.paneCwd !== cwd) return;
|
||||
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules a worktree OUTSIDE the session's folder must pass to be followed (#765 follow-up):
|
||||
*
|
||||
* - none in a Docker case workspace (the list is read only when a candidate needs it, and an
|
||||
* unreadable list follows nothing);
|
||||
* - none while attachment workspace confinement is on (`attachmentConfineToWorkspace` /
|
||||
* `CODEMAN_ATTACHMENT_CONFINE`), since an outside worktree is a second served root;
|
||||
* - none at, inside or around an attachment blocked tree (`attachmentBlockedPaths` /
|
||||
* `CODEMAN_ATTACHMENT_BLOCKED_PATHS`, plus the defaults), or at a sensitive path;
|
||||
* - in multi-user mode, only inside the owner's space or `workingDir`.
|
||||
*
|
||||
* Codeman judges these rules when the pane moves. The file routes judge the two guard rules
|
||||
* again on every request (`isOutsideWorktreeRootRefused()`), so a setting changed later applies
|
||||
* at once. The Docker and multi-user rules are judged only when the pane moves. Undefined for a remote, Docker or
|
||||
* adopted session, whose pane directory is not a path the agent works in on this host (an
|
||||
* adopted session's pane runs our tmux attach client). None of the three gets a `paneCwd`
|
||||
* either (`paneExitApplies`), so this is belt and braces; an adopted session needs its own
|
||||
* test because `session.remote` and `session.docker` are undefined on one.
|
||||
*/
|
||||
private outsideWorktreeOptions(session: Session): OutsideWorktreeOptions | undefined {
|
||||
if (session.remote || session.docker || session.isAdopted) return undefined;
|
||||
return {
|
||||
dockerWorkspaces: defaultDockerWorkspaces,
|
||||
allowRoot: async (root: string): Promise<boolean> => {
|
||||
if (isOutsideWorktreeRootRefused(root, await loadAttachmentGuardConfig())) return false;
|
||||
if (!isMultiUserMode()) return true;
|
||||
const owner = session.owner ? await findUser(session.owner) : undefined;
|
||||
return folderAllowedForUser(root, owner ? { username: owner.username, role: owner.role } : undefined, {
|
||||
workingDir: session.workingDir,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every session whose agent exited cleanly, through the same
|
||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
||||
@@ -3411,6 +3451,14 @@ export class WebServer extends EventEmitter {
|
||||
reconcileUpdateOnBoot();
|
||||
}
|
||||
|
||||
// One-time, once per data dir: a stored `agentSkillEnabled: false` written by the switch's
|
||||
// old default (up to 1.49.0) becomes true, so the 1.50.0 default reaches installs that ever
|
||||
// saved App Settings (agent-skill-default-migration.ts). Before the restore below reads
|
||||
// settings (2 s cache) and before listen, so no session create can see the old value.
|
||||
if (!this.testMode && (await migrateAgentSkillDefault()) === 'switched-on') {
|
||||
this._settingsCache = null;
|
||||
}
|
||||
|
||||
// Restore mux sessions BEFORE accepting connections
|
||||
// This prevents race conditions where clients connect before state is ready
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @fileoverview The one-time boot migration that lets the 1.50.0 agent skill default reach
|
||||
* installs that saved App Settings before it (src/web/agent-skill-default-migration.ts).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - An absent key, a stored `true` or no settings.json at all: settings.json is not written
|
||||
* (byte-identical, odd formatting kept) and the marker is set.
|
||||
* - A stored `false` (what every 1.49 save wrote, chosen or not) becomes `true`, every other key
|
||||
* kept, exactly once: the log line prints once, and a second start reports `already-done`
|
||||
* without reading the file.
|
||||
* - An off set AFTER the migration stays off: through the real `PUT /api/settings` (what the
|
||||
* page's Save sends), then a second start. The PUT neither writes nor drops the marker, and
|
||||
* `GET /api/settings` never returns it, so the page cannot hold it to send it back.
|
||||
* - A settings.json that does not parse is left byte-identical, no marker is written, nothing
|
||||
* throws, and the next start (after the fix) does the migration.
|
||||
* - A marker that cannot be checked switches nothing on (a deliberate off must not be cleared
|
||||
* twice).
|
||||
* - The marker is not a config-backup file, and `WebServer.start()` runs the migration before
|
||||
* its first settings read and before it listens (source wiring guard: the boot call is skipped
|
||||
* in test mode, like the other boot-time writes).
|
||||
*
|
||||
* The page's side (a device blob holding the old `false`) is pinned in
|
||||
* test/agent-skill-setting-ui.test.ts.
|
||||
*
|
||||
* Port: 0 (one WebServer for the PUT/GET cases).
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
import { dataPath } from '../src/config/instance.js';
|
||||
import { CONFIG_BACKUP_FILES } from '../src/config-backup.js';
|
||||
import {
|
||||
AGENT_SKILL_DEFAULT_MARKER,
|
||||
AGENT_SKILL_DEFAULT_MIGRATED_LOG,
|
||||
migrateAgentSkillDefault,
|
||||
} from '../src/web/agent-skill-default-migration.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const MARKER = dataPath(AGENT_SKILL_DEFAULT_MARKER);
|
||||
|
||||
/** Written with a formatting no writer of ours produces, so any rewrite shows in the bytes. */
|
||||
const seedRaw = (text: string) => writeFileSync(SETTINGS_PATH, text);
|
||||
const seed = (value: unknown) => seedRaw(JSON.stringify(value));
|
||||
const raw = () => readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
const stored = () => JSON.parse(raw()) as Record<string, unknown>;
|
||||
const marker = () => JSON.parse(readFileSync(MARKER, 'utf-8')) as { migratedAt: string; switchedOn: boolean };
|
||||
|
||||
/** The bytes of a torn settings.json (test/settings-json-write-serialization.test.ts). */
|
||||
const TORN = '{\n "agentSkillEnabled": false\n}' + 'rt-1761000000000"\n}';
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
const migratedLogs = () => logSpy.mock.calls.filter(([line]) => line === AGENT_SKILL_DEFAULT_MIGRATED_LOG).length;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
rmSync(MARKER, { force: true });
|
||||
logSpy = vi.spyOn(console, 'log');
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('nothing to switch on', () => {
|
||||
it('an absent key: settings.json is not written, the marker is set', async () => {
|
||||
seed({ lastUsedCase: 'demo' });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
expect(migratedLogs()).toBe(0);
|
||||
});
|
||||
|
||||
it('no settings.json at all: none is created, the marker is set', async () => {
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
|
||||
it('a stored true is left untouched', async () => {
|
||||
seed({ agentSkillEnabled: true, caseOrder: ['a'] });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stored false from before 1.50.0', () => {
|
||||
it('is switched on once, every other key kept, with one log line', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(marker()).toMatchObject({ switchedOn: true });
|
||||
expect(Number.isNaN(Date.parse(marker().migratedAt))).toBe(false);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
|
||||
// A second start finds the marker and reads nothing.
|
||||
const after = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(raw()).toBe(after);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
});
|
||||
|
||||
it('a false written by hand after the migration is never switched on again', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a settings.json that does not parse', () => {
|
||||
it('is left alone, writes no marker, does not throw, and the next start after the fix migrates', async () => {
|
||||
seedRaw(TORN);
|
||||
expect(await migrateAgentSkillDefault()).toBe('refused');
|
||||
expect(raw()).toBe(TORN);
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
expect(
|
||||
warnSpy.mock.calls.some(([line]) => String(line).startsWith('[agent-skill] default migration skipped'))
|
||||
).toBe(true);
|
||||
|
||||
// The user repairs the file (or restores a backup): the migration has not run yet, so it runs now.
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a marker that cannot be checked', () => {
|
||||
it('switches nothing on and writes no marker', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
// A path below a regular file: lstat answers ENOTDIR, not ENOENT.
|
||||
expect(await migrateAgentSkillDefault(join(SETTINGS_PATH, 'marker.json'))).toBe('refused');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the page and the server (port 0)', () => {
|
||||
let server: WebServer;
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
}, 30000);
|
||||
|
||||
it('an off saved through PUT /api/settings after the migration stays off at the next start, and the PUT leaves the marker alone', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo' });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
const markerBytes = readFileSync(MARKER, 'utf-8');
|
||||
|
||||
// What the page sends when the user turns the switch off (plus another synced key).
|
||||
const res = await fetch(url('/api/settings'), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: false, autoNameSessions: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false, lastUsedCase: 'demo', autoNameSessions: true });
|
||||
expect(readFileSync(MARKER, 'utf-8')).toBe(markerBytes);
|
||||
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/settings never carries the marker, so the page cannot hold or send it', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
await migrateAgentSkillDefault();
|
||||
const res = await fetch(url('/api/settings'));
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const data = (body.success === true ? body.data : body) as Record<string, unknown>;
|
||||
expect(data).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('placement', () => {
|
||||
it('the marker is not a config-backup file, so a restore never re-arms or drops it', () => {
|
||||
expect(CONFIG_BACKUP_FILES as readonly string[]).not.toContain(AGENT_SKILL_DEFAULT_MARKER);
|
||||
});
|
||||
|
||||
it('WebServer.start() runs it before its first settings read, the restore and listen, and drops the settings cache', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
|
||||
const start = source.slice(source.indexOf(' async start(): Promise<void> {'));
|
||||
const call = start.indexOf('await migrateAgentSkillDefault()');
|
||||
expect(call).toBeGreaterThan(0);
|
||||
for (const later of [
|
||||
'await this.getAgentInboxPersistEnabled()',
|
||||
'await this.restoreMuxSessions()',
|
||||
'await this.app.listen(',
|
||||
]) {
|
||||
expect(start.indexOf(later), later).toBeGreaterThan(call);
|
||||
}
|
||||
expect(start.slice(call, call + 200)).toContain('this._settingsCache = null;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* @fileoverview The codeman skill's local model helpers: `local_model`, the `local` worker kind
|
||||
* of `spawn_worker`, and `sendmark`.
|
||||
*
|
||||
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub that plays a
|
||||
* fake Codeman: it records every request (method, route, query, body) in order and answers from
|
||||
* files the test wrote, one per route and optionally one per call of that route. So these check
|
||||
* what the functions put on the wire and what they make of each answer, never a copy of their
|
||||
* logic, and nothing here talks to a real server.
|
||||
*
|
||||
* Port: N/A (no server; the stub answers every request).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
|
||||
const API = 'http://127.0.0.1:9';
|
||||
const SELF = 'caller-session-0000';
|
||||
const SID = 'worker-local-0001';
|
||||
|
||||
// The preamble builds every request body with jq.
|
||||
let hasJq = true;
|
||||
try {
|
||||
execFileSync('jq', ['--version'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
hasJq = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fake Codeman. Each call is appended to $CALLS as one JSON line, then answered from
|
||||
* $ANSWERS/<route>.<n> (the n-th call of that route) or $ANSWERS/<route>. `<file>.code` is the
|
||||
* HTTP status `-w` reports (default 200) and `<file>.delay` seconds to sleep first. A wait-output
|
||||
* for `DONE_<token>` writes $WD/codeman-answer-<token>.md first when $ANSWER_TEXT is set, or a
|
||||
* symlink to $ANSWER_SYMLINK, the way a worker would before printing its marker; with
|
||||
* $ANSWER_APPEND it appends that line on every such call, a worker still writing its answer.
|
||||
*
|
||||
* Written without a single dollar-brace, because this is a JS template literal.
|
||||
*/
|
||||
const STUB = String.raw`
|
||||
stub() {
|
||||
local m=GET u='' d='' q='' w='' route key n f tok
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) m=$2; shift ;;
|
||||
-d|--data-binary) d=$2; shift ;;
|
||||
--data-urlencode) q="$q&$2"; shift ;;
|
||||
-w) w=$2; shift ;;
|
||||
-H|-u|-o) shift ;;
|
||||
http*) u=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
route=$(printf '%s' "$u" | sed "s|^$CODEMAN_API_URL/api/v1/||")
|
||||
case "$route" in
|
||||
local-model) key=lm-get ;;
|
||||
local-model/runtime) key=lm-runtime ;;
|
||||
quick-start) key=quick-start ;;
|
||||
sessions/*/*) key=$(basename "$route") ;;
|
||||
sessions/*) if [ "$m" = DELETE ]; then key=delete; else key=session; fi ;;
|
||||
*) key=other ;;
|
||||
esac
|
||||
jq -nc --arg m "$m" --arg k "$key" --arg q "$q" --arg d "$d" '{m:$m,key:$k,q:$q,d:$d}' >> "$CALLS"
|
||||
n=$(grep -cF "\"key\":\"$key\"" "$CALLS")
|
||||
f="$ANSWERS/$key.$n"; [ -f "$f" ] || f="$ANSWERS/$key"
|
||||
if [ "$key" = wait-output ]; then
|
||||
case "$q" in *match=DONE_*)
|
||||
tok=$(printf '%s' "$q" | sed -e 's/.*match=DONE_//' -e 's/&.*//')
|
||||
if [ -n "$ANSWER_TEXT" ]; then printf '%s' "$ANSWER_TEXT" > "$WD/codeman-answer-$tok.md"; fi
|
||||
if [ -n "$ANSWER_APPEND" ]; then printf '%s\n' "$ANSWER_APPEND" >> "$WD/codeman-answer-$tok.md"; fi
|
||||
if [ -n "$ANSWER_SYMLINK" ]; then ln -s "$ANSWER_SYMLINK" "$WD/codeman-answer-$tok.md"; fi ;;
|
||||
esac
|
||||
fi
|
||||
[ -f "$f.delay" ] && sleep "$(cat "$f.delay")"
|
||||
if [ -f "$f" ]; then cat "$f"; else printf '%s' '{"success":false,"error":"no stub answer","errorCode":"NOT_FOUND"}'; fi
|
||||
if [ -n "$w" ]; then printf '\n%s' "$(cat "$f.code" 2>/dev/null || echo 200)"; fi
|
||||
}
|
||||
CURL=(stub)
|
||||
`;
|
||||
|
||||
interface Call {
|
||||
m: string;
|
||||
key: string;
|
||||
q: string;
|
||||
d: string;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
rc: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
calls: Call[];
|
||||
wd: string;
|
||||
}
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Sources the preamble, installs the stub, runs `body`, and returns what happened. */
|
||||
function runSkill(body: string, answers: Record<string, string> = {}, env: Record<string, string> = {}): Run {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-local-skill-'));
|
||||
dirs.push(dir);
|
||||
const answerDir = join(dir, 'answers');
|
||||
const wd = join(dir, 'wd');
|
||||
const calls = join(dir, 'calls');
|
||||
mkdirSync(answerDir);
|
||||
mkdirSync(wd);
|
||||
writeFileSync(calls, '');
|
||||
for (const [name, content] of Object.entries(answers)) writeFileSync(join(answerDir, name), content);
|
||||
const res = spawnSync('bash', ['-c', `. "$PREAMBLE" >/dev/null\n${STUB}\n${body}`], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 20_000,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
PREAMBLE,
|
||||
CALLS: calls,
|
||||
ANSWERS: answerDir,
|
||||
WD: wd,
|
||||
CODEMAN_API_URL: API,
|
||||
CODEMAN_SESSION_ID: SELF,
|
||||
...env,
|
||||
},
|
||||
});
|
||||
const callList = readFileSync(calls, 'utf-8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Call);
|
||||
return { rc: res.status, stdout: res.stdout, stderr: res.stderr, calls: callList, wd };
|
||||
}
|
||||
|
||||
/** The `data` of GET /api/local-model, as the server builds it. */
|
||||
function status(install: Record<string, unknown>, recommendation: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
hardware: { platform: 'darwin' },
|
||||
recommendation: { verdict: 'ok', headline: { text: 'A model fits.' }, ...recommendation },
|
||||
catalogUpdated: '2026-11-06',
|
||||
install: { running: false, actions: [], ...install },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const INSTALLED = {
|
||||
state: 'installed',
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
endpointId: 'local-ollama',
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
};
|
||||
const RUNNING = status({ ...INSTALLED, running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
const STOPPED = status({ ...INSTALLED, running: false, actions: ['start', 'remove'] });
|
||||
const QUICK_START = JSON.stringify({ success: true, data: { sessionId: SID, casePath: '/tmp/summ' } });
|
||||
const MATCHED = JSON.stringify({ success: true, data: { wait: { matched: true, timedOut: false, ended: false } } });
|
||||
const NOT_MATCHED = JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true, ended: false } } });
|
||||
|
||||
function lastJson(stdout: string): Record<string, unknown> {
|
||||
const lines = stdout.trim().split('\n');
|
||||
return JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe.skipIf(!hasJq)('preamble local_model', () => {
|
||||
const cases: Array<{ name: string; body: string; code?: string; rc: number; expect: Record<string, unknown> }> = [
|
||||
{
|
||||
name: 'a running model: spawnable now, with the harness, endpoint and model',
|
||||
body: RUNNING,
|
||||
rc: 0,
|
||||
expect: {
|
||||
spawnable: true,
|
||||
running: true,
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
endpointId: 'local-ollama',
|
||||
modelId: 'qwen3.6:35b-a3b',
|
||||
modelLabel: 'Qwen3.6 35B-A3B',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'an installed model whose runtime is stopped: spawnable once started',
|
||||
body: STOPPED,
|
||||
rc: 0,
|
||||
expect: { spawnable: true, running: false, cli: 'opencode' },
|
||||
},
|
||||
{
|
||||
name: 'start offered beside an error (another program holds the port): blocked, with the sentence',
|
||||
body: status({
|
||||
...INSTALLED,
|
||||
actions: ['start', 'remove'],
|
||||
error: {
|
||||
code: 'port-in-use',
|
||||
next: 'other-port',
|
||||
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
|
||||
},
|
||||
}),
|
||||
rc: 1,
|
||||
expect: {
|
||||
spawnable: false,
|
||||
reason: 'blocked',
|
||||
errorCode: 'port-in-use',
|
||||
message: 'Port 11435 is in use by another program.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'not a Mac',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
hardware: { platform: 'linux' },
|
||||
recommendation: { verdict: 'none', reason: 'not-macos', headline: { text: 'x' } },
|
||||
install: { state: 'idle', running: false, actions: ['install'] },
|
||||
},
|
||||
}),
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'not-mac' },
|
||||
},
|
||||
{
|
||||
name: 'nothing installed',
|
||||
body: status({ state: 'idle', actions: ['install'] }),
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'not-installed' },
|
||||
},
|
||||
{
|
||||
name: 'nothing installed on a Mac that fits none: the reason is the server sentence',
|
||||
body: status(
|
||||
{ state: 'idle', actions: ['install'] },
|
||||
{
|
||||
verdict: 'none',
|
||||
reason: 'memory',
|
||||
headline: { text: 'Nothing fits in {budget} GB.', vars: { budget: '12' } },
|
||||
}
|
||||
),
|
||||
rc: 1,
|
||||
expect: {
|
||||
spawnable: false,
|
||||
reason: 'not-installed',
|
||||
message: 'No local model is installed. Nothing fits in 12 GB.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'an install that has not finished',
|
||||
body: status({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] }),
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'install-unfinished', state: 'paused', step: 'model' },
|
||||
},
|
||||
{
|
||||
name: 'admin only in multi-user mode (403)',
|
||||
body: JSON.stringify({ success: false, error: 'Admin only in multi-user mode', errorCode: 'FORBIDDEN' }),
|
||||
code: '403',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'admin-only' },
|
||||
},
|
||||
{
|
||||
name: 'a Codeman without local model support (404)',
|
||||
body: JSON.stringify({ message: 'Route GET:/api/local-model not found', error: 'Not Found', statusCode: 404 }),
|
||||
code: '404',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'unsupported' },
|
||||
},
|
||||
{
|
||||
name: 'refused credentials (a plain-text 401)',
|
||||
body: 'Unauthorized',
|
||||
code: '401',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'unauthorized' },
|
||||
},
|
||||
{
|
||||
name: 'no answer at all',
|
||||
body: '',
|
||||
code: '000',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'unreachable' },
|
||||
},
|
||||
{
|
||||
name: 'any other failure carries its status and error',
|
||||
body: JSON.stringify({ success: false, error: 'boom', errorCode: 'INTERNAL_ERROR' }),
|
||||
code: '500',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'error', status: '500', message: 'boom' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(c.name, () => {
|
||||
const answers: Record<string, string> = { 'lm-get': c.body };
|
||||
if (c.code) answers['lm-get.code'] = c.code;
|
||||
const run = runSkill('local_model', answers);
|
||||
expect(run.rc).toBe(c.rc);
|
||||
expect(run.stdout.trim().split('\n')).toHaveLength(1);
|
||||
expect(lastJson(run.stdout)).toMatchObject(c.expect);
|
||||
// One read-only GET of the alias path, and nothing else.
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET lm-get']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
|
||||
it('starts a stopped runtime, then quick-starts the harness on the endpoint and waits for its composer', () => {
|
||||
const run = runSkill(
|
||||
'spawn_worker summ local',
|
||||
{
|
||||
'lm-get.1': STOPPED,
|
||||
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
|
||||
'lm-get.2': RUNNING,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': MATCHED,
|
||||
},
|
||||
// A cloud advisor must never be attached to a worker on the local model.
|
||||
{ CODEMAN_WORKER_ADVISOR: 'opus' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(run.stdout).toBe(`${SID}\n`);
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
|
||||
'GET lm-get',
|
||||
'POST lm-runtime',
|
||||
'GET lm-get',
|
||||
'POST quick-start',
|
||||
'GET wait-output',
|
||||
]);
|
||||
expect(JSON.parse(run.calls[1].d)).toEqual({ action: 'start' });
|
||||
expect(JSON.parse(run.calls[3].d)).toEqual({
|
||||
caseName: 'summ',
|
||||
mode: 'opencode',
|
||||
parentSessionId: SELF,
|
||||
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
|
||||
});
|
||||
expect(run.calls[4].q).toContain('match=┃');
|
||||
expect(run.calls[4].q).toContain('from=buffer');
|
||||
});
|
||||
|
||||
it('does not touch a runtime that already runs', () => {
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': MATCHED,
|
||||
});
|
||||
expect(run.rc).toBe(0);
|
||||
expect(run.stdout).toBe(`${SID}\n`);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
|
||||
});
|
||||
|
||||
for (const answer of [
|
||||
{ requiresContextWarning: true, modelId: 'qwen3.6:35b-a3b', contextLength: 32768, minSafeContextTokens: 40000 },
|
||||
{ requiresConfirmation: true, currentlyLoadedModel: 'other', affectedSessions: [{ id: 'x', name: 'w9' }] },
|
||||
]) {
|
||||
it(`never confirms on anyone's behalf: ${Object.keys(answer)[0]} fails the spawn with empty stdout`, () => {
|
||||
// The route returns these bare; the server's envelope wraps them as success data.
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': JSON.stringify({ success: true, data: answer }),
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/wants a person to confirm/);
|
||||
expect(run.stderr).toContain(Object.keys(answer)[0]);
|
||||
// One quick-start, sent without any confirmation flag, and nothing after it.
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
|
||||
expect(JSON.parse(run.calls[1].d).customModel).toEqual({
|
||||
endpointId: 'local-ollama',
|
||||
modelId: 'qwen3.6:35b-a3b',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('reports a refused launch (the runtime check in quick-start) and spawns nothing', () => {
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': JSON.stringify({
|
||||
success: false,
|
||||
error: 'The model on this Mac is not running.',
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
}),
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toContain('OPERATION_FAILED');
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
|
||||
});
|
||||
|
||||
it('refuses before any quick-start when no local model is installed', () => {
|
||||
const run = runSkill('spawn_worker summ local', { 'lm-get': status({ state: 'idle', actions: ['install'] }) });
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/no local model to spawn on \(not-installed\)/);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
});
|
||||
|
||||
it('reports a runtime that was started but did not come up, and spawns nothing', () => {
|
||||
const failed = status({
|
||||
...INSTALLED,
|
||||
actions: ['start', 'remove'],
|
||||
error: { code: 'runtime-start-failed', next: 'start', message: { text: 'The runtime did not start.' } },
|
||||
});
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get.1': STOPPED,
|
||||
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
|
||||
'lm-get.2': failed,
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/started, but \(blocked\): The runtime did not start\./);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'lm-runtime', 'lm-get']);
|
||||
});
|
||||
|
||||
it('deletes a worker whose composer never drew and hands back nothing', () => {
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true } } }),
|
||||
delete: JSON.stringify({ success: true, data: {} }),
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/never drew/);
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
|
||||
'GET lm-get',
|
||||
'POST quick-start',
|
||||
'GET wait-output',
|
||||
'DELETE delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a harness it knows no composer mark for, unless LOCAL_READY_MARK names one', () => {
|
||||
const pi = status({ ...INSTALLED, cli: 'pi', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
const refused = runSkill('spawn_worker summ local', { 'lm-get': pi });
|
||||
expect(refused.rc).toBe(1);
|
||||
expect(refused.stdout).toBe('');
|
||||
expect(refused.stderr).toMatch(/LOCAL_READY_MARK/);
|
||||
expect(refused.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
|
||||
const marked = runSkill(
|
||||
'spawn_worker summ local',
|
||||
{ 'lm-get': pi, 'quick-start': QUICK_START, 'wait-output': MATCHED },
|
||||
{ LOCAL_READY_MARK: 'pi-ready' }
|
||||
);
|
||||
expect(marked.rc).toBe(0);
|
||||
expect(marked.stdout).toBe(`${SID}\n`);
|
||||
expect(JSON.parse(marked.calls[1].d).mode).toBe('pi');
|
||||
expect(marked.calls[2].q).toContain('match=pi-ready');
|
||||
});
|
||||
|
||||
it('runs a claude harness through the claude readiness path (hooks vetted, shift+tab)', () => {
|
||||
const claude = status({ ...INSTALLED, cli: 'claude', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
const run = runSkill(
|
||||
`mkdir -p "$WD/.claude" && printf '%s' '{"hooks":{"Stop":[{"command":"curl /api/hook-event"}]}}' > "$WD/.claude/settings.local.json"
|
||||
printf '%s' "{\\"success\\":true,\\"data\\":{\\"sessionId\\":\\"${SID}\\",\\"casePath\\":\\"$WD\\"}}" > "$ANSWERS/quick-start"
|
||||
spawn_worker summ local`,
|
||||
{ 'lm-get': claude, 'wait-output': MATCHED },
|
||||
{ CODEMAN_WORKER_ADVISOR: 'opus' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(run.stdout).toBe(`${SID}\n`);
|
||||
const body = JSON.parse(run.calls[1].d);
|
||||
expect(body.mode).toBe('claude');
|
||||
expect(body.advisorModel).toBeUndefined();
|
||||
expect(body.customModel).toEqual({ endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
|
||||
expect(run.calls[2].q).toContain('match=shift+tab');
|
||||
// The advisor read-back is skipped too: no GET of the session.
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJq)('preamble sendmark', () => {
|
||||
const delivered = JSON.stringify({
|
||||
success: true,
|
||||
data: { delivered: true, duplicate: false, wait: { timedOut: true, ended: false, signal: null } },
|
||||
});
|
||||
|
||||
/** Answers GET /api/sessions/:id with the run's own working directory, then runs `body`. */
|
||||
function withSession(body: string): string {
|
||||
return `printf '%s' "{\\"success\\":true,\\"data\\":{\\"id\\":\\"${SID}\\",\\"workingDir\\":\\"$WD\\"}}" > "$ANSWERS/session"; ${body}`;
|
||||
}
|
||||
|
||||
it('sends once, waits for the split marker, and returns the answer file', () => {
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} $'Summarize these files:\\n- a.md\\n- b.md' 30`),
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ ANSWER_TEXT: 'a.md: setup. b.md: usage.' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
const out = lastJson(run.stdout);
|
||||
expect(out).toMatchObject({
|
||||
delivered: true,
|
||||
matched: true,
|
||||
answer: 'a.md: setup. b.md: usage.',
|
||||
truncated: false,
|
||||
});
|
||||
const token = out.token as string;
|
||||
expect(token).toMatch(/^m[0-9a-f]+$/);
|
||||
expect(out.answerFile).toBe(join(run.wd, `codeman-answer-${token}.md`));
|
||||
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET session', 'POST input', 'GET wait-output']);
|
||||
const input = JSON.parse(run.calls[1].d) as Record<string, unknown>;
|
||||
// The exactly-once body: the per-worker clientId, an epoch-second seq, one Enter at the end.
|
||||
expect(input.clientId).toBe(`codeman-agent-1-${SID}`);
|
||||
expect(typeof input.seq).toBe('number');
|
||||
expect(input).toMatchObject({ useMux: true, wait: 'exit', waitTimeout: 1000 });
|
||||
const text = input.input as string;
|
||||
expect(text.endsWith('\r')).toBe(true);
|
||||
expect(text.slice(0, -1)).not.toMatch(/[\r\n]/);
|
||||
expect(text.startsWith('Summarize these files: - a.md - b.md (When you are done')).toBe(true);
|
||||
expect(text).toContain(`codeman-answer-${token}.md`);
|
||||
expect(text).toContain(`the word DONE immediately followed by _${token}`);
|
||||
// Split: the typed prompt never holds the joined marker, so its echo cannot match.
|
||||
expect(text).not.toContain(`DONE_${token}`);
|
||||
expect(run.calls[2].q).toContain(`match=DONE_${token}`);
|
||||
expect(run.calls[2].q).toContain('from=buffer');
|
||||
});
|
||||
|
||||
it('answers 2 when no marker prints before the timeout, and deletes nothing', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 1`), {
|
||||
input: delivered,
|
||||
'wait-output': NOT_MATCHED,
|
||||
'wait-output.delay': '1',
|
||||
});
|
||||
expect(run.rc).toBe(2);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: null });
|
||||
expect(run.calls.some((call) => call.key === 'delete')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the answer file without a marker once it held still for a round (a full-screen pane draws the marker in pieces)', () => {
|
||||
// Measured on opencode 1.3.0: a reply streamed a character or two at a time reaches the
|
||||
// stripped stream as D, spinner, O, spinner, N, ... and never matches, while the screen
|
||||
// shows the whole token. The file must still end the wait.
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 30`),
|
||||
{ input: delivered, 'wait-output': NOT_MATCHED },
|
||||
{ ANSWER_TEXT: 'one two three' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: 'one two three' });
|
||||
// The first round finds the file, the second finds it unchanged: two marker waits, then done.
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait-output']);
|
||||
for (const call of run.calls.slice(2)) expect(call.q).toContain('timeout=10000');
|
||||
});
|
||||
|
||||
it('does not read an answer file that is still changing, and answers 2 at the timeout', () => {
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 3`),
|
||||
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' },
|
||||
{ ANSWER_APPEND: 'one more line' }
|
||||
);
|
||||
expect(run.rc).toBe(2);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: false, answer: null });
|
||||
expect(run.calls.filter((call) => call.key === 'wait-output').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('answers 3 when the worker ends while it waits for the answer file after the marker', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
|
||||
input: delivered,
|
||||
'wait-output': MATCHED,
|
||||
wait: JSON.stringify({ success: true, data: { wait: { signal: 'exit', ended: false, timedOut: false } } }),
|
||||
});
|
||||
expect(run.rc).toBe(3);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null, ended: true });
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
|
||||
});
|
||||
|
||||
it('still waits two seconds per check for the answer file when the exit wait gets no answer', () => {
|
||||
const started = Date.now();
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 30`),
|
||||
// No answer file for `wait`: the stub answers success:false, as a server that is gone.
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ SENDMARK_FILE_WAIT: '2' }
|
||||
);
|
||||
expect(run.rc).toBe(4);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('answers 4 when the marker printed but no answer file turned up', () => {
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 30`),
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ SENDMARK_FILE_WAIT: '0' }
|
||||
);
|
||||
expect(run.rc).toBe(4);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
|
||||
});
|
||||
|
||||
it('refuses to read an answer file that is a symlink', () => {
|
||||
const secret = join(tmpdir(), 'codeman-sendmark-not-read');
|
||||
const run = runSkill(
|
||||
withSession(
|
||||
`printf 'private' > "${secret}"; sendmark ${SID} 'count to three' 30; rc=$?; rm -f "${secret}"; exit $rc`
|
||||
),
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ ANSWER_SYMLINK: secret, SENDMARK_FILE_WAIT: '0' }
|
||||
);
|
||||
expect(run.rc).toBe(4);
|
||||
expect(run.stderr).toMatch(/is a symlink, not read/);
|
||||
expect(run.stdout).not.toContain('private');
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
|
||||
});
|
||||
|
||||
it('answers 3 when the pane did not take the prompt because the worker ended', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
|
||||
input: JSON.stringify({
|
||||
success: true,
|
||||
data: { delivered: false, duplicate: false, wait: { ended: true, aborted: false } },
|
||||
}),
|
||||
});
|
||||
expect(run.rc).toBe(3);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ delivered: false, matched: false });
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input']);
|
||||
});
|
||||
|
||||
it('answers 3 when the worker ends while it waits for the marker', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
|
||||
input: delivered,
|
||||
'wait-output': JSON.stringify({
|
||||
success: true,
|
||||
data: { wait: { matched: false, timedOut: false, ended: true } },
|
||||
}),
|
||||
});
|
||||
expect(run.rc).toBe(3);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: false, ended: true });
|
||||
});
|
||||
|
||||
it('with SENDMARK_ANSWER_FILE=0 asks for the marker alone and reads no session', () => {
|
||||
const run = runSkill(
|
||||
`sendmark ${SID} 'review this' 30`,
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ SENDMARK_ANSWER_FILE: '0' }
|
||||
);
|
||||
expect(run.rc).toBe(0);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answerFile: null, answer: null });
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['input', 'wait-output']);
|
||||
const text = (JSON.parse(run.calls[0].d) as { input: string }).input;
|
||||
expect(text).not.toContain('codeman-answer-');
|
||||
expect(text).toContain('the word DONE immediately followed by _');
|
||||
});
|
||||
|
||||
it('refuses an empty prompt before sending anything', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} $' \\n\\t\\n'`));
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stderr).toMatch(/sendmark: empty prompt/);
|
||||
expect(run.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview The Codeman Agent Skill switch (`agentSkillEnabled`) in App Settings,
|
||||
* on by default since 1.50.0.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The REAL openAppSettings() reads an ABSENT key as on, like the server's
|
||||
* getAgentSkillEnabled() (`!== false`). The page and the server must agree: a
|
||||
* page that showed an absent key as off would write `false` on the next Save of
|
||||
* any other setting, and the default would be gone without the user touching it.
|
||||
* - Only an explicit false shows the switch off, wherever it came from (this
|
||||
* device's blob or the server's settings.json, merged as a synced key).
|
||||
* - saveAppSettings() sends the switch in the settings PUT, and the body passes the
|
||||
* .strict() SettingsUpdateSchema (an undeclared key 400s every save).
|
||||
* - A device that saved on 1.49.0 or earlier keeps `false` in its own blob. After the
|
||||
* server's one-time boot migration (stored true) its switch shows on and a Save keeps it
|
||||
* on; on a server without the key the device's copy is dropped for the default; a later
|
||||
* explicit off on the server stays off. The Save body never carries the migration marker.
|
||||
* - The row says it is on by default, that the copy stays out of git, and how to
|
||||
* remove copies already written (turning it off sweeps nothing).
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), with the
|
||||
* harness of test/tab-cli-logos-setting.test.ts.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
|
||||
type Settings = Record<string, unknown>;
|
||||
interface SettingsApp {
|
||||
openAppSettings(): void;
|
||||
saveAppSettings(): Promise<void>;
|
||||
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
||||
_apiPut: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/** Methods that run for real; every other method of the app is a no-op here. */
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
// Real so the save builds the body a browser would send (the schema check).
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
'_displaySettingKeySet',
|
||||
]);
|
||||
|
||||
/** Methods the load/save paths call that other modules add to the prototype. */
|
||||
const ELSEWHERE = [
|
||||
'showToast',
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
||||
async function boot(stored?: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
||||
windows.push(win);
|
||||
// After load, so app.js's DOMContentLoaded boot never runs; only the prototype is under test.
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
if (stored) win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => 'desktop',
|
||||
isHandheldDevice: () => false,
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as SettingsApp;
|
||||
const checkbox = () => win.document.getElementById('appSettingsAgentSkill') as HTMLInputElement;
|
||||
const putBody = () => {
|
||||
const calls = app._apiPut.mock.calls as [string, Settings][];
|
||||
const call = calls.find(([path]) => path === '/api/settings');
|
||||
expect(call, 'a settings PUT').toBeDefined();
|
||||
return call![1];
|
||||
};
|
||||
return { app, checkbox, putBody };
|
||||
}
|
||||
|
||||
describe('the switch loads on by default', () => {
|
||||
it('an absent key shows on, an explicit false off, an explicit true on', async () => {
|
||||
for (const [stored, expected] of [
|
||||
[{}, true],
|
||||
[{ agentSkillEnabled: false }, false],
|
||||
[{ agentSkillEnabled: true }, true],
|
||||
] as const) {
|
||||
const { app, checkbox } = await boot(stored);
|
||||
checkbox().checked = !expected;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('a fresh device on a server with no stored value shows on', async () => {
|
||||
const { app, checkbox } = await boot();
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
||||
checkbox().checked = false;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("the server's explicit false wins over this device's blob (a synced key)", async () => {
|
||||
const { app, checkbox } = await boot({ agentSkillEnabled: true });
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save', () => {
|
||||
it('saving other settings on a never-touched install keeps it on, and the body passes the schema', async () => {
|
||||
const { app, putBody } = await boot({});
|
||||
app.openAppSettings();
|
||||
await app.saveAppSettings();
|
||||
const body = putBody();
|
||||
expect(body.agentSkillEnabled).toBe(true);
|
||||
// The whole body, not just this key: an undeclared key 400s every save.
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('switching it off sends an explicit false', async () => {
|
||||
const { app, checkbox, putBody } = await boot({});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Every device that saved App Settings on 1.49.0 or earlier holds `agentSkillEnabled: false` in
|
||||
// its own blob (the switch loaded an absent key as off). The server's boot migration
|
||||
// (src/web/agent-skill-default-migration.ts) switches a stored false on by storing TRUE, because
|
||||
// the merge only overwrites a synced key the server has: a key it deleted would leave the
|
||||
// device's false in place, and the next Save would write it back.
|
||||
describe('a device that saved before 1.50.0', () => {
|
||||
const OLD_BLOB = { agentSkillEnabled: false, autoNameSessions: false };
|
||||
|
||||
it("after the server's migration (stored true) the switch shows on, and a Save keeps it on and passes the schema", async () => {
|
||||
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: true, lastUsedCase: 'demo' }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
await app.saveAppSettings();
|
||||
const body = putBody();
|
||||
expect(body.agentSkillEnabled).toBe(true);
|
||||
// The migration's marker is a file of its own, never a setting: the Save can neither send it
|
||||
// back nor be refused for it by the .strict() schema.
|
||||
expect(Object.keys(body).filter((key) => /migrat/i.test(key))).toEqual([]);
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('on a server without the key (a save that never reached it) the switch shows the default, on', async () => {
|
||||
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('an off the user saved after the migration stays off on every device', async () => {
|
||||
const { app, checkbox, putBody } = await boot({ agentSkillEnabled: true });
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('without a server answer the device keeps what it has', async () => {
|
||||
const { app, checkbox } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve(null as unknown as Settings));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the App Settings row', () => {
|
||||
const doc = new JSDOM(INDEX).window.document;
|
||||
const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement;
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
|
||||
it('is a synced switch', () => {
|
||||
expect((doc.getElementById('appSettingsAgentSkill') as HTMLInputElement).type).toBe('checkbox');
|
||||
expect(row.closest('.set-group')!.querySelector('.set-scope')!.textContent).toBe('synced');
|
||||
});
|
||||
|
||||
it('says it is on by default, that git does not list the copy, and how to remove copies', () => {
|
||||
expect(desc).toContain('On by default');
|
||||
expect(desc).toContain('ignores itself in git');
|
||||
expect(desc).toContain('codeman skill uninstall --case <name>');
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,8 @@ describe('applyAgentSkill with a directory', () => {
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
// It ignores itself in git there too, like the Claude Code copy.
|
||||
expect(await readFile(join(skillDir, '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
||||
});
|
||||
|
||||
+168
-1
@@ -7,12 +7,14 @@
|
||||
* publish without them would be caught here before the `files` entry silently
|
||||
* ignores the missing directory.
|
||||
*
|
||||
* Pure filesystem tests in a per-test temp dir. Port: N/A.
|
||||
* Filesystem tests in a per-test temp dir; the self-ignoring block also runs real
|
||||
* `git` there (no global or system config). Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import {
|
||||
@@ -92,6 +94,8 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
|
||||
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
|
||||
// Not even the ignore file: a foreign copy is the user's to track or not.
|
||||
expect(existsSync(join(skillDir(), '.gitignore'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
|
||||
@@ -107,9 +111,61 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
|
||||
});
|
||||
|
||||
// A cloned repository can commit `reference` as a link, and a Docker case's container
|
||||
// can plant one in the bind-mounted workspace the host injects into (on by default).
|
||||
it('refuses a symlinked reference/ folder inside the copy, so nothing is written where it points', async () => {
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'history.md'), 'my notes\n');
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['history.md']);
|
||||
expect(await readFile(join(outside, 'history.md'), 'utf-8')).toBe('my notes\n');
|
||||
// Refused before the first write: no SKILL.md, no ignore file.
|
||||
expect(await readdir(skillDir())).toEqual(['reference']);
|
||||
});
|
||||
|
||||
it('refuses a symlinked reference/ folder planted beside an existing Codeman copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'recipes.md'), 'my recipes\n');
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['recipes.md']);
|
||||
expect(await readFile(join(outside, 'recipes.md'), 'utf-8')).toBe('my recipes\n');
|
||||
// The stale copy is left as found, not refreshed through the link.
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).not.toBe(skillMd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
it('refuses to delete through a symlinked reference/ folder inside the copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
// Files named like the packaged reference files: an unlink through the link would take them.
|
||||
for (const name of await readdir(join(skillDir(), 'reference'))) {
|
||||
await writeFile(join(outside, name), 'not yours\n');
|
||||
}
|
||||
const names = (await readdir(outside)).sort();
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('symlink');
|
||||
expect((await readdir(outside)).sort()).toEqual(names);
|
||||
for (const name of names) expect(await readFile(join(outside, name), 'utf-8')).toBe('not yours\n');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes our copy and prunes the emptied directories', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
@@ -139,6 +195,117 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
|
||||
// The user's file and the directories holding it survive.
|
||||
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
||||
// And so does the ignore file, so their notes stay out of `git status` as before.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the injected copy ignores itself in git', () => {
|
||||
const ENV = {
|
||||
...process.env,
|
||||
LC_ALL: 'C',
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, { cwd: casePath, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
/** Exit status of `git check-ignore -q -- <path>`: 0 ignored, 1 not ignored. */
|
||||
const checkIgnore = (path: string): number => {
|
||||
try {
|
||||
git('check-ignore', '-q', '--', path);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return (err as { status: number }).status;
|
||||
}
|
||||
};
|
||||
/** A repository with one commit and a tracked `.gitignore` of its own. */
|
||||
const initRepo = async () => {
|
||||
git('init', '-q', '-b', 'main');
|
||||
await writeFile(join(casePath, 'README.md'), 'hello\n');
|
||||
await writeFile(join(casePath, '.gitignore'), 'node_modules/\n');
|
||||
git('add', 'README.md', '.gitignore');
|
||||
git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init');
|
||||
};
|
||||
|
||||
it('writes a .gitignore of `*` into the copy on install', async () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
// Never part of the skill content: a second run has nothing to do.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('keeps `git status` of the user repository clean, and leaves its own ignore file alone', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
await applyAgentSkill(casePath, true, '.agents/skills');
|
||||
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
expect(await readFile(join(casePath, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
|
||||
// Nor the repository's local exclude file (git's template may or may not create one).
|
||||
const exclude = join(casePath, '.git', 'info', 'exclude');
|
||||
if (existsSync(exclude)) expect(await readFile(exclude, 'utf-8')).not.toContain('codeman');
|
||||
});
|
||||
|
||||
it('ignores the copy contents, never the folder above it (Claude Code checks only those)', async () => {
|
||||
// Claude Code 2.1.319 skips a nested skills folder when `git check-ignore` says its
|
||||
// project directory is ignored; it never checks a skill folder or its SKILL.md.
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(checkIgnore('.claude/skills/codeman/SKILL.md')).toBe(0);
|
||||
expect(checkIgnore('.claude/skills/codeman')).toBe(1);
|
||||
expect(checkIgnore('.claude/skills')).toBe(1);
|
||||
expect(checkIgnore('.claude')).toBe(1);
|
||||
});
|
||||
|
||||
it('never overwrites a .gitignore that is already in the folder', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), '# mine\nSKILL.md\n');
|
||||
// A stale copy: the refresh rewrites the skill but not the ignore file.
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('# mine\nSKILL.md\n');
|
||||
});
|
||||
|
||||
it('a copy injected before the ignore file existed gains it on the next install', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await rm(join(skillDir(), '.gitignore'));
|
||||
// Every skill file is current, so only the ignore file is new: still a refresh.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('does not write through a symlink planted at the ignore file name', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
const outside = join(casePath, 'outside.txt');
|
||||
await writeFile(outside, 'untouched\n');
|
||||
await symlink(outside, join(skillDir(), '.gitignore'));
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(outside, 'utf-8')).toBe('untouched\n');
|
||||
});
|
||||
|
||||
it('uninstall removes the ignore file with the copy, so no folder is left behind', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
});
|
||||
|
||||
it("uninstall keeps an ignore file that is not Codeman's", async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), 'SKILL.md\n');
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('SKILL.md\n');
|
||||
});
|
||||
|
||||
it('the packaged source carries no ignore file (the plugin mirror pins its file set)', () => {
|
||||
expect(existsSync(join(process.cwd(), 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), 'plugins', 'codeman', 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* @fileoverview scripts/check-local-model-catalog.mjs, the weekly check of the local model
|
||||
* catalogue: which build a Mac pulls from a manifest list, the pull total, how a row's drift is
|
||||
* classified, the exit code, the report text, and that the IO side only ever asks for
|
||||
* manifests, child manifests and configs (never a layer). The network is never used: the IO
|
||||
* tests drive a fake registry and a fake GitHub API.
|
||||
*
|
||||
* The fixtures under test/fixtures/local-model-catalog/ are real bodies the registry served on
|
||||
* 2026-11-08 for `qwen3.6:35b-a3b`, byte for byte, so their digests are the real ones.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CheckFailure,
|
||||
MANIFEST_ACCEPT,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MEDIA_TYPE_MANIFEST,
|
||||
MEDIA_TYPE_MANIFEST_LIST,
|
||||
SIZE_TOLERANCE_FRACTION,
|
||||
boundedGet,
|
||||
catalogueRunner,
|
||||
classifyModel,
|
||||
classifyRuntime,
|
||||
compareVersions,
|
||||
exitCodeFor,
|
||||
formatPercent,
|
||||
groupDigits,
|
||||
inspectModel,
|
||||
metadataForConfig,
|
||||
normalizeDigest,
|
||||
parseVersion,
|
||||
pullTotalBytes,
|
||||
renderReport,
|
||||
runCheck,
|
||||
selectMacChild,
|
||||
sha256Digest,
|
||||
splitModelId,
|
||||
withinTolerance,
|
||||
} from '../scripts/check-local-model-catalog.mjs';
|
||||
|
||||
const fixtureDir = resolve(import.meta.dirname, 'fixtures/local-model-catalog');
|
||||
const fixture = (name: string): Buffer => readFileSync(resolve(fixtureDir, name));
|
||||
|
||||
const REAL_LIST = fixture('qwen3.6-35b-a3b.list.json');
|
||||
const REAL_GGUF_MANIFEST = fixture('qwen3.6-35b-a3b.gguf-manifest.json');
|
||||
const REAL_MLX_CONFIG = fixture('qwen3.6-35b-a3b.mlx-config.json');
|
||||
|
||||
/** The digest of the MLX child the real list names, which a Mac pulled on 2026-11-06. */
|
||||
const REAL_MLX_CHILD = 'sha256:0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
|
||||
const REAL_GGUF_CHILD = 'sha256:a7eb95c53bcf96b4bdd008d0fab4a5dac88047d9c1a7a9ab88ed453423fbd87c';
|
||||
/** The pull total a Mac reported for `qwen3.6:35b-a3b`, which the catalogue row stores. */
|
||||
const MAC_PULL_TOTAL_35B = 23_105_798_956;
|
||||
|
||||
const PIN = {
|
||||
version: '0.40.15',
|
||||
url: 'https://github.com/ollama/ollama/releases/download/v0.40.15/ollama-darwin.tgz',
|
||||
bytes: 185_971_671,
|
||||
sha256: '607c79d5d6315ef9d896db83adde736a4c0057196329843544e79b95afb260a8',
|
||||
};
|
||||
|
||||
const hex = (char: string): string => `sha256:${char.repeat(64)}`;
|
||||
|
||||
describe('the real registry bodies', () => {
|
||||
it('carry the digests the registry and a Mac reported for them', () => {
|
||||
expect(sha256Digest(REAL_LIST)).toBe('sha256:86b2d171ce90bbb2dcb081933565ec867ce523cdd274bd24c8cf1ef9185feb4f');
|
||||
expect(sha256Digest(REAL_GGUF_MANIFEST)).toBe(REAL_GGUF_CHILD);
|
||||
expect(sha256Digest(REAL_MLX_CONFIG)).toBe(
|
||||
'sha256:577d7d4694896a245c8b00f62bf53a8f4720ded4eba83a8e2da30bb87583bd6b'
|
||||
);
|
||||
});
|
||||
|
||||
it('a Mac selects the mlx child of the real list, the one whose digest /api/tags reported', () => {
|
||||
const list = JSON.parse(REAL_LIST.toString('utf8'));
|
||||
expect(list.mediaType).toBe(MEDIA_TYPE_MANIFEST_LIST);
|
||||
expect(selectMacChild(list.manifests).digest).toBe(REAL_MLX_CHILD);
|
||||
});
|
||||
|
||||
it('the plain GGUF manifest totals its layers plus its config, 2% below what a Mac pulls', () => {
|
||||
const manifest = JSON.parse(REAL_GGUF_MANIFEST.toString('utf8'));
|
||||
expect(pullTotalBytes(manifest)).toBe(22_621_314_161 + 220);
|
||||
expect(classifyModel(
|
||||
{ id: 'qwen3.6:35b-a3b', bytes: MAC_PULL_TOTAL_35B },
|
||||
{ mac: { bytes: pullTotalBytes(manifest), digest: REAL_GGUF_CHILD } },
|
||||
'0.40.15'
|
||||
)).toEqual([expect.objectContaining({ kind: 'size' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitModelId', () => {
|
||||
it('splits name and tag', () => {
|
||||
expect(splitModelId('qwen3.6:35b-a3b')).toEqual({ name: 'qwen3.6', tag: '35b-a3b' });
|
||||
});
|
||||
|
||||
it.each(['qwen3.6', ':35b', 'qwen3.6:', 'evil.example/x:y'])('refuses %s', (id) => {
|
||||
expect(() => splitModelId(id)).toThrow(CheckFailure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullTotalBytes', () => {
|
||||
const manifest = (layers: unknown, config: unknown = { size: 250 }) => ({ config, layers });
|
||||
|
||||
it('adds every layer and the config', () => {
|
||||
expect(pullTotalBytes(manifest([{ size: 1000 }, { size: 24 }]))).toBe(1274);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no layers', manifest([])],
|
||||
['layers that are not an array', manifest('x')],
|
||||
['a layer without a size', manifest([{ digest: 'x' }])],
|
||||
['a negative size', manifest([{ size: -1 }])],
|
||||
['a size given as text', manifest([{ size: '1000' }])],
|
||||
['a fractional size', manifest([{ size: 1.5 }])],
|
||||
['no config size', manifest([{ size: 1 }], {})],
|
||||
['a total past the safe integers', manifest([{ size: Number.MAX_SAFE_INTEGER }, { size: 10 }])],
|
||||
])('refuses %s', (_label, value) => {
|
||||
expect(() => pullTotalBytes(value)).toThrow(CheckFailure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectMacChild', () => {
|
||||
const child = (runner: string, digest: string, mediaType?: string) => ({
|
||||
...(mediaType === undefined ? { mediaType: MEDIA_TYPE_MANIFEST } : { mediaType }),
|
||||
digest,
|
||||
runner,
|
||||
});
|
||||
|
||||
it('prefers mlx, then llamacpp, then ggml, whatever the list order', () => {
|
||||
expect(selectMacChild([child('ggml', 'g'), child('llamacpp', 'l'), child('mlx', 'm')]).digest).toBe('m');
|
||||
expect(selectMacChild([child('ggml', 'g'), child('llamacpp', 'l')]).digest).toBe('l');
|
||||
expect(selectMacChild([child('ggml', 'g')]).digest).toBe('g');
|
||||
});
|
||||
|
||||
it('takes the first child of a runner and compares runners as Ollama does', () => {
|
||||
expect(selectMacChild([child(' MLX ', 'first'), child('mlx', 'second')]).digest).toBe('first');
|
||||
});
|
||||
|
||||
it('skips a nested list and a runner a Mac does not run', () => {
|
||||
expect(selectMacChild([child('mlx', 'nested', MEDIA_TYPE_MANIFEST_LIST), child('llamacpp', 'l')]).digest).toBe('l');
|
||||
expect(selectMacChild([child('vllm', 'v')])).toBeUndefined();
|
||||
expect(selectMacChild('not a list')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a child without a mediaType', () => {
|
||||
expect(selectMacChild([{ digest: 'm', runner: 'mlx' }]).digest).toBe('m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('small helpers', () => {
|
||||
it('metadataForConfig mirrors Ollama: safetensors runs on mlx, gguf on ggml', () => {
|
||||
expect(metadataForConfig({ model_format: 'safetensors' })).toEqual({ runner: 'mlx', format: 'safetensors' });
|
||||
expect(metadataForConfig({ model_format: 'gguf' })).toEqual({ runner: 'ggml', format: 'gguf' });
|
||||
expect(metadataForConfig({})).toEqual({ runner: '', format: '' });
|
||||
});
|
||||
|
||||
it('catalogueRunner names a build in the catalogue words: mlx, or llamacpp for any GGUF build', () => {
|
||||
expect(catalogueRunner('mlx')).toBe('mlx');
|
||||
expect(catalogueRunner(' MLX ')).toBe('mlx');
|
||||
expect(catalogueRunner('llamacpp')).toBe('llamacpp');
|
||||
// A GGUF manifest that names no runner is `ggml` to Ollama, and llama-server runs it all the same.
|
||||
expect(catalogueRunner('ggml')).toBe('llamacpp');
|
||||
expect(catalogueRunner(metadataForConfig({ model_format: 'gguf' }).runner)).toBe('llamacpp');
|
||||
for (const other of ['', 'vllm', '<img>', undefined, 3]) expect(catalogueRunner(other)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizeDigest accepts the prefixed and the bare form and nothing else', () => {
|
||||
const bare = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
|
||||
expect(normalizeDigest(bare)).toBe(REAL_MLX_CHILD);
|
||||
expect(normalizeDigest(REAL_MLX_CHILD.toUpperCase().replace('SHA256', 'sha256'))).toBe(REAL_MLX_CHILD);
|
||||
expect(normalizeDigest('sha256:abc')).toBeUndefined();
|
||||
expect(normalizeDigest('sha512:' + 'a'.repeat(64))).toBeUndefined();
|
||||
expect(normalizeDigest(42)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses and compares versions', () => {
|
||||
expect(parseVersion('v0.40.15')).toEqual([0, 40, 15]);
|
||||
expect(parseVersion('0.32.12')).toEqual([0, 32, 12]);
|
||||
expect(parseVersion('0.40.15-rc1')).toBeUndefined();
|
||||
expect(compareVersions('0.40.15', 'v0.40.15')).toBe(0);
|
||||
expect(compareVersions('0.41.0', '0.40.15')).toBeGreaterThan(0);
|
||||
expect(compareVersions('0.9.0', '0.40.15')).toBeLessThan(0);
|
||||
expect(() => compareVersions('latest', '0.40.15')).toThrow(CheckFailure);
|
||||
});
|
||||
|
||||
it('formats numbers without the locale', () => {
|
||||
expect(groupDigits(23_105_798_956)).toBe('23,105,798,956');
|
||||
expect(groupDigits(250)).toBe('250');
|
||||
expect(formatPercent(0.16614)).toBe('+16.61%');
|
||||
expect(formatPercent(-0.02097)).toBe('-2.10%');
|
||||
});
|
||||
|
||||
it('the tolerance is a small fraction of the row', () => {
|
||||
expect(SIZE_TOLERANCE_FRACTION).toBeLessThanOrEqual(0.01);
|
||||
expect(withinTolerance(1_000_000, 1_001_000)).toBe(true);
|
||||
expect(withinTolerance(1_000_000, 1_001_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyModel', () => {
|
||||
const observed = (bytes: number, extra: Record<string, unknown> = {}) => ({
|
||||
mac: { bytes, digest: REAL_MLX_CHILD, runner: 'mlx', format: 'safetensors', ...extra },
|
||||
});
|
||||
|
||||
it('a row that holds the pull total itself is clean', () => {
|
||||
expect(classifyModel({ id: 'qwen3.6:35b-a3b', bytes: MAC_PULL_TOTAL_35B }, observed(MAC_PULL_TOTAL_35B), '0.40.15')).toEqual([]);
|
||||
});
|
||||
|
||||
it('a row sized from its layers alone is clean: the config is a few hundred bytes', () => {
|
||||
expect(classifyModel({ id: 'gpt-oss:20b', bytes: 13_793_441_427 }, observed(13_793_441_566), '0.40.15')).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags the two real drifts of 2026-11-08: the Mac gets the mlx build', () => {
|
||||
const nine = classifyModel({ id: 'qwen3.5:9b', bytes: 6_550_825_373 }, observed(7_639_229_431), '0.40.15');
|
||||
expect(nine).toEqual([expect.objectContaining({ kind: 'size', expected: 6_550_825_373, actual: 7_639_229_431 })]);
|
||||
expect(nine[0].fraction).toBeCloseTo(0.1661, 3);
|
||||
const dense = classifyModel({ id: 'qwen3.8:27b', bytes: 17_741_871_939 }, observed(18_174_721_847), '0.40.15');
|
||||
expect(dense[0].kind).toBe('size');
|
||||
expect(dense[0].fraction).toBeCloseTo(0.0244, 3);
|
||||
});
|
||||
|
||||
it('a tag that is gone is only that', () => {
|
||||
expect(classifyModel({ id: 'x:y', bytes: 1 }, { gone: true }, '0.40.15')).toEqual([{ kind: 'gone' }]);
|
||||
});
|
||||
|
||||
it('compares a pinned digest in either form', () => {
|
||||
const bare = REAL_MLX_CHILD.slice('sha256:'.length);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, digest: bare }, observed(100), '0.40.15')).toEqual([]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, digest: hex('1') }, observed(100), '0.40.15')).toEqual([
|
||||
{ kind: 'digest', expected: hex('1'), actual: REAL_MLX_CHILD },
|
||||
]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, digest: 'garbage' }, observed(100), '0.40.15')[0]).toMatchObject({
|
||||
kind: 'digest',
|
||||
expected: 'not a sha256 digest',
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a row that names its runner to the runner of the build a Mac pulls', () => {
|
||||
// The same size, another runner: the row's memory figures no longer describe what runs.
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'llamacpp' }, observed(100), '0.40.15')).toEqual([
|
||||
{ kind: 'runner', expected: 'llamacpp', actual: 'mlx' },
|
||||
]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, observed(100), '0.40.15')).toEqual([]);
|
||||
// A GGUF build is a llamacpp row's, whether its manifest says llamacpp or nothing (ggml).
|
||||
for (const runner of ['llamacpp', 'ggml']) {
|
||||
const gguf = observed(100, { runner, format: 'gguf' });
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'llamacpp' }, gguf, '0.40.15'), runner).toEqual([]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, gguf, '0.40.15'), runner).toEqual([
|
||||
{ kind: 'runner', expected: 'mlx', actual: 'llamacpp' },
|
||||
]);
|
||||
}
|
||||
// A runner Ollama would not run on a Mac is never taken for the row's.
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, observed(100, { runner: '' }), '0.40.15')).toEqual([
|
||||
{ kind: 'runner', expected: 'mlx', actual: 'unknown' },
|
||||
]);
|
||||
// A catalogue without the field (before rows named their runner) is never held to one.
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { runner: 'llamacpp' }), '0.40.15')).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a build that needs a newer runtime than the pin, and only that', () => {
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { requires: '0.41.0' }), '0.40.15')).toEqual([
|
||||
{ kind: 'requires', requires: '0.41.0', pinned: '0.40.15' },
|
||||
]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { requires: '0.40.15' }), '0.40.15')).toEqual([]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100), '0.40.15')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRuntime', () => {
|
||||
const release = (version: string, asset?: { size: number; digest?: string }) => ({ version, publishedOn: '2026-11-03', asset });
|
||||
const matching = { size: PIN.bytes, digest: `sha256:${PIN.sha256}` };
|
||||
|
||||
it('is clean when the pin is the newest release and its archive is unchanged', () => {
|
||||
expect(classifyRuntime(PIN, { latest: release('0.40.15', matching), pinned: release('0.40.15', matching) })).toEqual([]);
|
||||
});
|
||||
|
||||
it('a size that matches and no digest in the API is not drift', () => {
|
||||
expect(
|
||||
classifyRuntime(PIN, { latest: release('0.40.15'), pinned: release('0.40.15', { size: PIN.bytes }) })
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a newer release', () => {
|
||||
expect(classifyRuntime(PIN, { latest: release('0.41.2'), pinned: release('0.40.15', matching) })).toEqual([
|
||||
{ kind: 'behind', pinned: '0.40.15', latest: '0.41.2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a pinned release or archive that is gone or changed', () => {
|
||||
const latest = release('0.40.15');
|
||||
expect(classifyRuntime(PIN, { latest, pinned: { gone: true } })).toEqual([{ kind: 'pinned-release-gone' }]);
|
||||
expect(classifyRuntime(PIN, { latest, pinned: release('0.40.15') })).toEqual([{ kind: 'pinned-asset-gone' }]);
|
||||
expect(
|
||||
classifyRuntime(PIN, { latest, pinned: release('0.40.15', { size: PIN.bytes + 1, digest: matching.digest }) })[0].kind
|
||||
).toBe('pinned-asset-changed');
|
||||
expect(
|
||||
classifyRuntime(PIN, { latest, pinned: release('0.40.15', { size: PIN.bytes, digest: hex('2') }) })[0].kind
|
||||
).toBe('pinned-asset-changed');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Exit code and report ----------
|
||||
|
||||
function baseResult(overrides: Record<string, unknown> = {}) {
|
||||
const mac = { bytes: 100, digest: hex('a'), runner: 'mlx', format: 'safetensors', requires: '0.32.4' };
|
||||
return {
|
||||
checkedAt: '2026-11-09 06:00 UTC',
|
||||
catalogue: { updated: '2026-11-06' },
|
||||
pin: { version: '0.40.15' },
|
||||
errors: [] as string[],
|
||||
models: [{ id: 'a:b', bytes: 100, findings: [] as unknown[], observed: { tagDigest: hex('b'), variants: [mac], mac } }],
|
||||
runtime: {
|
||||
assetName: 'ollama-darwin.tgz',
|
||||
findings: [] as unknown[],
|
||||
observed: {
|
||||
latest: { version: '0.40.15', publishedOn: '2026-11-03', asset: { size: PIN.bytes, digest: `sha256:${PIN.sha256}` } },
|
||||
pinned: { version: '0.40.15', publishedOn: '2026-11-03', asset: { size: PIN.bytes, digest: `sha256:${PIN.sha256}` } },
|
||||
},
|
||||
} as Record<string, unknown>,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('exitCodeFor', () => {
|
||||
it('is 0 when nothing drifted', () => {
|
||||
expect(exitCodeFor(baseResult())).toBe(0);
|
||||
});
|
||||
|
||||
it('is 1 for drift in a model or in the runtime', () => {
|
||||
const result = baseResult();
|
||||
result.models[0].findings = [{ kind: 'gone' }];
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
const runtime = baseResult();
|
||||
(runtime.runtime.findings as unknown[]).push({ kind: 'behind', pinned: '0.40.15', latest: '0.41.0' });
|
||||
expect(exitCodeFor(runtime)).toBe(1);
|
||||
});
|
||||
|
||||
it('is 2 when any part failed, even with drift elsewhere', () => {
|
||||
const result = baseResult();
|
||||
result.models[0].findings = [{ kind: 'gone' }];
|
||||
result.models.push({ id: 'c:d', bytes: 1, findings: [], error: 'timed out' } as never);
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
expect(exitCodeFor(baseResult({ runtime: { error: 'GitHub answered 403', findings: [] } }))).toBe(2);
|
||||
expect(exitCodeFor(baseResult({ errors: ['the catalogue did not load'] }))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReport', () => {
|
||||
it('says clean, and lists the digests to pin in full with the build a Mac pulls', () => {
|
||||
const report = renderReport(baseResult());
|
||||
expect(report).toMatch(/^# Local model catalogue: weekly check\n/);
|
||||
expect(report).toContain('**Clean**');
|
||||
expect(report).toContain(`| \`a:b\` | mlx, safetensors | \`${hex('b')}\` | \`${hex('a')}\` |`);
|
||||
expect(report).toContain('as the row field `digest`');
|
||||
expect(report).not.toContain('Pin first');
|
||||
expect(report).toContain('## Not checked');
|
||||
});
|
||||
|
||||
it('asks to pin a row with measured figures first', () => {
|
||||
const result = baseResult();
|
||||
(result.models[0] as Record<string, unknown>).figures = 'measured';
|
||||
expect(renderReport(result)).toContain('Pin first: `a:b`.');
|
||||
});
|
||||
|
||||
it('names a size drift with both figures and every build the tag offers', () => {
|
||||
const gguf = { bytes: 90, digest: hex('c'), runner: 'llamacpp', format: 'gguf' };
|
||||
const mac = { bytes: 7_639_229_431, digest: hex('a'), runner: 'mlx', format: 'safetensors' };
|
||||
const result = baseResult({
|
||||
models: [
|
||||
{
|
||||
id: 'qwen3.5:9b',
|
||||
bytes: 6_550_825_373,
|
||||
findings: classifyModel({ id: 'qwen3.5:9b', bytes: 6_550_825_373 }, { mac }, '0.40.15'),
|
||||
observed: { tagDigest: hex('b'), variants: [gguf, mac], mac },
|
||||
},
|
||||
],
|
||||
});
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Drift found**: 1 of 1 models.');
|
||||
expect(report).toContain('| `qwen3.5:9b` | 6,550,825,373 | 7,639,229,431 | +16.61% | mlx, safetensors |');
|
||||
expect(report).toContain('mlx, safetensors 7,639,229,431 (a Mac takes this one)');
|
||||
expect(report).toContain('llamacpp, gguf 90');
|
||||
});
|
||||
|
||||
it('names a runner change, what it means for the figures, and every build the tag offers', () => {
|
||||
const gguf = { bytes: 100, digest: hex('c'), runner: 'llamacpp', format: 'gguf' };
|
||||
const mac = { bytes: 100, digest: hex('a'), runner: 'mlx', format: 'safetensors' };
|
||||
const result = baseResult({
|
||||
models: [
|
||||
{
|
||||
id: 'qwen3.6:35b-a3b-mtp-q4_k_m',
|
||||
bytes: 100,
|
||||
findings: classifyModel({ id: 'x:y', bytes: 100, runner: 'llamacpp' }, { mac }, '0.40.15'),
|
||||
observed: { tagDigest: hex('b'), variants: [gguf, mac], mac },
|
||||
},
|
||||
],
|
||||
});
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Drift found**: 1 of 1 models.');
|
||||
expect(report).toContain('| runner changed |');
|
||||
expect(report).toContain(
|
||||
"`qwen3.6:35b-a3b-mtp-q4_k_m`: a Mac now pulls the mlx, safetensors build, which runs on `mlx`, while the row's `runner` says `llamacpp`."
|
||||
);
|
||||
expect(report).toContain('recompute them for the new runner');
|
||||
expect(report).toContain('mlx, safetensors 100 (a Mac takes this one)');
|
||||
});
|
||||
|
||||
it('says the check failed and prints no verdict of clean', () => {
|
||||
const result = baseResult({ runtime: { error: 'GitHub answered 403 for the newest release', findings: [] } });
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**The check failed**');
|
||||
expect(report).not.toContain('**Clean**');
|
||||
expect(report).toContain('GitHub answered 403');
|
||||
});
|
||||
|
||||
it('never prints a runner or format outside the known names', () => {
|
||||
const mac = { bytes: 100, digest: hex('a'), runner: '<img src=x>', format: '@someone' };
|
||||
const report = renderReport(baseResult({ models: [{ id: 'a:b', bytes: 100, findings: [], observed: { tagDigest: hex('b'), variants: [mac], mac } }] }));
|
||||
expect(report).toContain('unknown, unknown');
|
||||
expect(report).not.toContain('<img');
|
||||
expect(report).not.toContain('@someone');
|
||||
});
|
||||
|
||||
it('keeps an error on one line and without backticks', () => {
|
||||
const result = baseResult({ errors: ['line one\nline `two`\u0007'] });
|
||||
expect(renderReport(result)).toContain("- Check failed: line one line 'two'\n");
|
||||
});
|
||||
|
||||
it('contains no em-dash', () => {
|
||||
expect(renderReport(baseResult())).not.toContain(String.fromCharCode(0x2014));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- IO against a fake registry ----------
|
||||
|
||||
interface Seen {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
signal: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake registry and GitHub API. `routes` maps a URL to a body (string or Buffer) or a status
|
||||
* number. Every request is recorded.
|
||||
*/
|
||||
function fakeFetch(routes: Record<string, string | Buffer | number | (() => Response)>) {
|
||||
const seen: Seen[] = [];
|
||||
const fetchFn = async (url: string, init: { headers: Record<string, string>; signal: unknown }) => {
|
||||
seen.push({ url, headers: init.headers, signal: init.signal });
|
||||
const route = routes[url];
|
||||
if (route === undefined) return new Response('not found', { status: 404 });
|
||||
if (typeof route === 'number') return new Response('', { status: route });
|
||||
if (typeof route === 'function') return route();
|
||||
return new Response(route, { status: 200 });
|
||||
};
|
||||
return { fetchFn, seen };
|
||||
}
|
||||
|
||||
const REG = 'https://registry.ollama.ai/v2/library';
|
||||
const GH = 'https://api.github.com/repos/ollama/ollama/releases';
|
||||
|
||||
/** A small registry: one tag that is a list (mlx + llamacpp), one plain tag. Digests computed. */
|
||||
function buildRegistry() {
|
||||
const json = (value: unknown): Buffer => Buffer.from(JSON.stringify(value));
|
||||
const mlxConfig = json({ model_format: 'safetensors', requires: '0.32.4' });
|
||||
const ggufConfig = json({ model_format: 'gguf', requires: '0.30.0' });
|
||||
const layers = (sizes: number[], tag: string) =>
|
||||
sizes.map((size, i) => ({ mediaType: 'application/vnd.ollama.image.tensor', digest: hex(String((i + tag.length) % 10)), size }));
|
||||
const mlxChild = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST,
|
||||
config: { digest: sha256Digest(mlxConfig), size: mlxConfig.length },
|
||||
layers: layers([700, 300], 'mlx'),
|
||||
});
|
||||
const ggufChild = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST,
|
||||
config: { digest: sha256Digest(ggufConfig), size: ggufConfig.length },
|
||||
layers: layers([600], 'gguf'),
|
||||
runner: 'llamacpp',
|
||||
format: 'gguf',
|
||||
});
|
||||
const list = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST_LIST,
|
||||
manifests: [
|
||||
{ mediaType: MEDIA_TYPE_MANIFEST, digest: sha256Digest(mlxChild), runner: 'mlx', format: 'safetensors' },
|
||||
{ mediaType: MEDIA_TYPE_MANIFEST, digest: sha256Digest(ggufChild), runner: 'llamacpp', format: 'gguf' },
|
||||
],
|
||||
});
|
||||
const plain = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST,
|
||||
config: { digest: sha256Digest(ggufConfig), size: ggufConfig.length },
|
||||
layers: layers([500], 'plain'),
|
||||
});
|
||||
const release = (tag: string) =>
|
||||
json({
|
||||
tag_name: tag,
|
||||
published_at: '2026-11-03T22:52:31Z',
|
||||
assets: [{ name: 'ollama-darwin.tgz', size: PIN.bytes, digest: `sha256:${PIN.sha256}` }],
|
||||
});
|
||||
const routes: Record<string, string | Buffer | number | (() => Response)> = {
|
||||
[`${REG}/listed/manifests/1b`]: list,
|
||||
[`${REG}/listed/blobs/${sha256Digest(mlxChild)}`]: mlxChild,
|
||||
[`${REG}/listed/blobs/${sha256Digest(ggufChild)}`]: ggufChild,
|
||||
[`${REG}/listed/blobs/${sha256Digest(mlxConfig)}`]: mlxConfig,
|
||||
[`${REG}/plain/manifests/2b`]: plain,
|
||||
[`${REG}/plain/blobs/${sha256Digest(ggufConfig)}`]: ggufConfig,
|
||||
[`${GH}/latest`]: release('v0.40.15'),
|
||||
[`${GH}/tags/v0.40.15`]: release('v0.40.15'),
|
||||
};
|
||||
const layerDigests = new Set(
|
||||
[mlxChild, ggufChild, plain].flatMap((body) => JSON.parse(body.toString()).layers.map((l: { digest: string }) => l.digest))
|
||||
);
|
||||
return {
|
||||
routes,
|
||||
layerDigests,
|
||||
sizes: { mlx: 1000 + mlxConfig.length, gguf: 600 + ggufConfig.length, plain: 500 + ggufConfig.length },
|
||||
digests: { list: sha256Digest(list), mlxChild: sha256Digest(mlxChild), plain: sha256Digest(plain) },
|
||||
};
|
||||
}
|
||||
|
||||
const catalogOf = (models: { id: string; bytes: number; digest?: string; figures?: string; runner?: string }[]) => ({
|
||||
updated: '2026-11-06',
|
||||
models,
|
||||
});
|
||||
const NOW = new Date('2026-11-09T06:00:00Z');
|
||||
|
||||
describe('inspectModel', () => {
|
||||
it('reads a list the way a Mac does: the mlx child, its total, its requires', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn, seen } = fakeFetch(registry.routes);
|
||||
const observed = await inspectModel(fetchFn, 'listed:1b');
|
||||
expect(observed.tagDigest).toBe(registry.digests.list);
|
||||
expect(observed.mac).toMatchObject({
|
||||
runner: 'mlx',
|
||||
format: 'safetensors',
|
||||
digest: registry.digests.mlxChild,
|
||||
bytes: registry.sizes.mlx,
|
||||
requires: '0.32.4',
|
||||
});
|
||||
expect(observed.variants.map((variant: { runner: string }) => variant.runner)).toEqual(['mlx', 'llamacpp']);
|
||||
expect(seen[0].headers.Accept).toBe(MANIFEST_ACCEPT);
|
||||
expect(seen[0].headers.Accept).toContain(MEDIA_TYPE_MANIFEST_LIST);
|
||||
expect(seen.every((request) => request.signal instanceof AbortSignal)).toBe(true);
|
||||
});
|
||||
|
||||
it('fills a plain manifest without runner metadata from its config', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const observed = await inspectModel(fetchFn, 'plain:2b');
|
||||
expect(observed.mac).toMatchObject({ runner: 'ggml', format: 'gguf', digest: registry.digests.plain, bytes: registry.sizes.plain });
|
||||
expect(observed.tagDigest).toBe(observed.mac.digest);
|
||||
});
|
||||
|
||||
it('a 404 for the manifest is a tag that is gone', async () => {
|
||||
const { fetchFn } = fakeFetch({});
|
||||
expect(await inspectModel(fetchFn, 'missing:tag')).toEqual({ gone: true });
|
||||
});
|
||||
|
||||
it('refuses a blob whose body does not match its digest', async () => {
|
||||
const registry = buildRegistry();
|
||||
const mlxUrl = `${REG}/listed/blobs/${registry.digests.mlxChild}`;
|
||||
const { fetchFn } = fakeFetch({ ...registry.routes, [mlxUrl]: '{"tampered":true}' });
|
||||
await expect(inspectModel(fetchFn, 'listed:1b')).rejects.toThrow(/does not match its digest/);
|
||||
});
|
||||
|
||||
it('refuses a list with no build a Mac runs', async () => {
|
||||
const list = JSON.stringify({ mediaType: MEDIA_TYPE_MANIFEST_LIST, manifests: [{ digest: hex('1'), runner: 'vllm' }] });
|
||||
const { fetchFn } = fakeFetch({ [`${REG}/odd/manifests/x`]: list });
|
||||
await expect(inspectModel(fetchFn, 'odd:x')).rejects.toThrow(/no build a Mac runs/);
|
||||
});
|
||||
|
||||
it('a server error is a failed check, not a tag that is gone', async () => {
|
||||
const { fetchFn } = fakeFetch({ [`${REG}/flaky/manifests/x`]: 503 });
|
||||
await expect(inspectModel(fetchFn, 'flaky:x')).rejects.toThrow(/answered 503/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundedGet', () => {
|
||||
it('refuses a declared body over the cap before reading it', async () => {
|
||||
const { fetchFn } = fakeFetch({
|
||||
'https://x.test/big': () => new Response('{}', { status: 200, headers: { 'content-length': String(MAX_MANIFEST_BYTES + 1) } }),
|
||||
});
|
||||
await expect(boundedGet(fetchFn, 'https://x.test/big', { maxBytes: MAX_MANIFEST_BYTES })).rejects.toThrow(/over the cap/);
|
||||
});
|
||||
|
||||
it('refuses a streamed body that grows past the cap', async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(600));
|
||||
controller.enqueue(new Uint8Array(600));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const { fetchFn } = fakeFetch({ 'https://x.test/stream': () => new Response(stream, { status: 200 }) });
|
||||
await expect(boundedGet(fetchFn, 'https://x.test/stream', { maxBytes: 1000 })).rejects.toThrow(/over the cap/);
|
||||
});
|
||||
|
||||
it('turns a network error into a CheckFailure', async () => {
|
||||
const fetchFn = async () => {
|
||||
throw new TypeError('fetch failed', { cause: new Error('getaddrinfo ENOTFOUND registry.ollama.ai') });
|
||||
};
|
||||
await expect(boundedGet(fetchFn, 'https://registry.ollama.ai/v2/', { maxBytes: 10 })).rejects.toThrow(/ENOTFOUND/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCheck', () => {
|
||||
it('reports drift, and fetches only manifests, child manifests, configs and the two releases', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn, seen } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
// Sized from the llamacpp child: a Mac pulls the mlx one, so this is drift.
|
||||
{ id: 'listed:1b', bytes: registry.sizes.gguf },
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain },
|
||||
{ id: 'missing:tag', bytes: 1000 },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
githubToken: 'token-value',
|
||||
});
|
||||
|
||||
expect(result.models.map((model: { findings: { kind: string }[] }) => model.findings.map((f) => f.kind))).toEqual([
|
||||
['size'],
|
||||
[],
|
||||
['gone'],
|
||||
]);
|
||||
expect(result.runtime.findings).toEqual([]);
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
|
||||
for (const request of seen) {
|
||||
const blob = /\/blobs\/(sha256:[0-9a-f]{64})$/.exec(request.url);
|
||||
if (blob) expect(registry.layerDigests.has(blob[1])).toBe(false);
|
||||
const isGitHub = request.url.startsWith('https://api.github.com/');
|
||||
expect(request.headers.Authorization === 'Bearer token-value').toBe(isGitHub);
|
||||
}
|
||||
expect(seen.some((request) => request.url === `${GH}/latest`)).toBe(true);
|
||||
expect(seen.some((request) => request.url === `${GH}/tags/v0.40.15`)).toBe(true);
|
||||
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Drift found**: 2 of 3 models.');
|
||||
expect(report).toContain('`missing:tag`: the registry no longer has this tag');
|
||||
});
|
||||
|
||||
it('holds rows that name their runner to the build a Mac pulls, a plain GGUF manifest included', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
// The right size, the wrong runner: a Mac pulls the mlx child of this list.
|
||||
{ id: 'listed:1b', bytes: registry.sizes.mlx, runner: 'llamacpp' },
|
||||
// A manifest that names no runner is a GGUF build: a llamacpp row, clean.
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain, runner: 'llamacpp' },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.models.map((model: { findings: unknown[] }) => model.findings)).toEqual([
|
||||
[{ kind: 'runner', expected: 'llamacpp', actual: 'mlx' }],
|
||||
[],
|
||||
]);
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
expect(renderReport(result)).toContain('| runner changed |');
|
||||
});
|
||||
|
||||
it('sends no Authorization header at all without a token', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn, seen } = fakeFetch(registry.routes);
|
||||
await runCheck({ catalog: catalogOf([{ id: 'plain:2b', bytes: registry.sizes.plain }]), pin: PIN, fetchFn, now: NOW });
|
||||
expect(seen.some((request) => 'Authorization' in request.headers)).toBe(false);
|
||||
});
|
||||
|
||||
it('is clean when every row matches what a Mac pulls', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
{ id: 'listed:1b', bytes: registry.sizes.mlx, digest: registry.digests.mlxChild, figures: 'measured' },
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain, figures: 'measured' },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(exitCodeFor(result)).toBe(0);
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Clean**');
|
||||
// The pinned row is not offered for pinning again; the unpinned one is.
|
||||
expect(report).not.toContain('| `listed:1b` | mlx');
|
||||
expect(report).toContain(`| \`plain:2b\` | ggml, gguf | \`${registry.digests.plain}\``);
|
||||
// Only the measured row that has no digest yet is asked for first.
|
||||
expect(report).toContain('Pin first: `plain:2b`.');
|
||||
});
|
||||
|
||||
it('a newer runtime release is drift', async () => {
|
||||
const registry = buildRegistry();
|
||||
registry.routes[`${GH}/latest`] = JSON.stringify({ tag_name: 'v0.41.0', published_at: '2026-11-10T00:00:00Z', assets: [] });
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({ catalog: catalogOf([{ id: 'plain:2b', bytes: registry.sizes.plain }]), pin: PIN, fetchFn, now: NOW });
|
||||
expect(result.runtime.findings).toEqual([{ kind: 'behind', pinned: '0.40.15', latest: '0.41.0' }]);
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
expect(renderReport(result)).toContain('Ollama 0.41.0 is out; the pin is 0.40.15.');
|
||||
});
|
||||
|
||||
it('a rate-limited GitHub API fails the check (2) and keeps what the models showed', async () => {
|
||||
const registry = buildRegistry();
|
||||
registry.routes[`${GH}/latest`] = 403;
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([{ id: 'listed:1b', bytes: registry.sizes.gguf }]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
expect(result.models[0].findings[0].kind).toBe('size');
|
||||
expect(renderReport(result)).toContain('**The check failed**');
|
||||
});
|
||||
|
||||
it('a release tag that is not vX.Y.Z fails the check instead of reaching the report', async () => {
|
||||
const registry = buildRegistry();
|
||||
registry.routes[`${GH}/latest`] = JSON.stringify({ tag_name: 'v1.0.0 @everyone', assets: [] });
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({ catalog: catalogOf([]), pin: PIN, fetchFn, now: NOW });
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
expect(renderReport(result)).not.toContain('@everyone');
|
||||
});
|
||||
|
||||
it('a network failure for one row fails the whole check', async () => {
|
||||
const registry = buildRegistry();
|
||||
const fetchFn = async (url: string, init: { headers: Record<string, string>; signal: unknown }) => {
|
||||
if (url.includes('/plain/')) throw new TypeError('fetch failed');
|
||||
return fakeFetch(registry.routes).fetchFn(url, init);
|
||||
};
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
{ id: 'listed:1b', bytes: registry.sizes.mlx },
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.models[1].error).toMatch(/fetch failed/);
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@ const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.m
|
||||
// every other test in this file stubs _showCenterStatus itself and has no need of it.
|
||||
const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
// The warning formats counts with the runtime's own locale (a German user reads 131.072), so the
|
||||
// expectations ask the same runtime instead of hard-coding the en-US grouping.
|
||||
const num = (n: number) => n.toLocaleString();
|
||||
|
||||
function resp(body: unknown, ok = true) {
|
||||
return { ok, json: async () => body };
|
||||
}
|
||||
@@ -1770,8 +1774,8 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
|
||||
expect(message).toContain('4 parallel');
|
||||
expect(message).toContain('131,072');
|
||||
expect(message).toContain('32,768'); // per slot
|
||||
expect(message).toContain(num(131072));
|
||||
expect(message).toContain(num(32768)); // per slot
|
||||
expect(message).toContain('--parallel 1');
|
||||
expect(message).toContain('Context size has been exceeded');
|
||||
// this is not the "first message fails outright" case, so it must not claim that
|
||||
@@ -1784,7 +1788,9 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
|
||||
const { win, app } = bootApp({});
|
||||
void app._confirmContextWarning('qwen3', 65536, 40000, 4);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('65,536 tokens of context are split across 4 parallel server slots, about 16,384 each');
|
||||
expect(message).toContain(
|
||||
`${num(65536)} tokens of context are split across 4 parallel server slots, about ${num(16384)} each`
|
||||
);
|
||||
app._resolveContextWarningConfirm(false);
|
||||
});
|
||||
|
||||
@@ -1792,7 +1798,7 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
|
||||
const { win, app } = bootApp({});
|
||||
void app._confirmContextWarning('qwen3', 16384, 40000, 4);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('16,384');
|
||||
expect(message).toContain(num(16384));
|
||||
expect(message).toMatch(/first message will fail/i);
|
||||
expect(message).toContain('4 parallel slots');
|
||||
expect(message).toContain('--parallel 1');
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-edge-cases-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
let server: WebServer;
|
||||
@@ -279,7 +285,7 @@ describe('Concurrent Session Handling', () => {
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
}).then((r) => r.json())
|
||||
);
|
||||
|
||||
@@ -312,7 +318,7 @@ describe('Concurrent Session Handling', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @fileoverview The browser side of a worktree OUTSIDE the session folder (#765 follow-up).
|
||||
*
|
||||
* The server lists such a worktree with absolute paths and serves them as a second root. The
|
||||
* browser has to route those paths to the workspace file routes (preview and edit), not to the
|
||||
* attachment routes that serve an outside file read-only; the terminal has to open them in the
|
||||
* preview, since the log viewer's `tail-file` stays confined to `workingDir`; and a root-relative
|
||||
* markdown ref has to resolve from the top of the worktree. Real frontend code in a `vm` context.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||||
|
||||
const WORKING_DIR = '/base/myapp';
|
||||
const SIBLING = '/base/myapp-feature';
|
||||
const outsideSession = () => ({
|
||||
workingDir: WORKING_DIR,
|
||||
outsideWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
|
||||
});
|
||||
|
||||
type Proto = {
|
||||
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
||||
_isOutsideWorktreePath: (p: string, id: string) => boolean;
|
||||
};
|
||||
|
||||
const loadRouting = (session: Record<string, unknown>) => {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
||||
context
|
||||
);
|
||||
const proto = (context as unknown as { __proto: Proto }).__proto;
|
||||
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', session]]) }) as Proto;
|
||||
return {
|
||||
external: (p: string) => app._isExternalPreviewPath(p, 's1'),
|
||||
outside: (p: string) => app._isOutsideWorktreePath(p, 's1'),
|
||||
};
|
||||
};
|
||||
|
||||
describe('preview routing for a worktree outside the session folder', () => {
|
||||
it('keeps a path inside the outside worktree on the workspace routes', () => {
|
||||
const app = loadRouting(outsideSession());
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(false);
|
||||
expect(app.external(SIBLING)).toBe(false);
|
||||
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
});
|
||||
|
||||
it('still sends every other outside path through the attachment routes', () => {
|
||||
const app = loadRouting(outsideSession());
|
||||
expect(app.external('/base/myapp-feature-2/src/a.ts')).toBe(true);
|
||||
expect(app.external('/tmp/shot.png')).toBe(true);
|
||||
expect(app.outside('/base/myapp-feature-2/src/a.ts')).toBe(false);
|
||||
expect(app.outside('relative/a.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves workspace paths and an inside worktree as they were', () => {
|
||||
const app = loadRouting({
|
||||
workingDir: WORKING_DIR,
|
||||
liveWorktree: { name: 'probe', path: `${WORKING_DIR}/.claude/worktrees/probe` },
|
||||
});
|
||||
expect(app.external(`${WORKING_DIR}/src/a.ts`)).toBe(false);
|
||||
expect(app.outside(`${WORKING_DIR}/.claude/worktrees/probe/a.ts`)).toBe(false);
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a sibling path as external once the agent has left the worktree', () => {
|
||||
const app = loadRouting({ workingDir: WORKING_DIR });
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal links into a worktree outside the session folder', () => {
|
||||
type Link = { text: string; activate: (ev: unknown, text: string) => void };
|
||||
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
|
||||
|
||||
const makeApp = (line: string) => {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
activeElement: null,
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: () => null,
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: { now: () => 0 },
|
||||
requestAnimationFrame: () => 1,
|
||||
setTimeout: () => 1,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||||
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||
app.activeSessionId = 's1';
|
||||
app.sessions = new Map([['s1', outsideSession()]]);
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
const providers: Provider[] = [];
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
registerLinkProvider: (p: Provider) => providers.push(p),
|
||||
buffer: {
|
||||
active: {
|
||||
length: 1,
|
||||
getLine: (row: number) =>
|
||||
row === 0
|
||||
? { isWrapped: false, translateToString: (trim?: boolean) => (trim === false ? line.padEnd(80) : line) }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
app.registerFilePathLinkProvider();
|
||||
let links: Link[] = [];
|
||||
providers[0].provideLinks(1, (found) => {
|
||||
links = found || [];
|
||||
});
|
||||
return { app, link: links[0] };
|
||||
};
|
||||
|
||||
it('opens a log in the outside worktree in the preview, since tail-file stays in the session folder', () => {
|
||||
const { app, link } = makeApp(`tail -f ${SIBLING}/logs/server.log`);
|
||||
link.activate({}, link.text);
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith(`${SIBLING}/logs/server.log`, 's1');
|
||||
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a log in the session folder in the log viewer', () => {
|
||||
const { app, link } = makeApp(`tail -f ${WORKING_DIR}/logs/server.log`);
|
||||
link.activate({}, link.text);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith(`${WORKING_DIR}/logs/server.log`, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown in a worktree outside the session folder', () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const { document } = dom.window;
|
||||
|
||||
it('resolves root-relative and relative refs from the worktree, not the session folder', () => {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
document,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
||||
context
|
||||
);
|
||||
const proto = (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', outsideSession()]]) });
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<img src="/assets/root.png" alt="root"><img src="img/a.png" alt="rel">';
|
||||
|
||||
app._rebaseFilePreviewMarkdownRefs(root, { sessionId: 's1', filePath: `${SIBLING}/docs/README.md` });
|
||||
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
const src = (alt: string) => root.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
|
||||
expect(src('root')).toBe(raw(`${SIBLING}/assets/root.png`));
|
||||
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Files panel following an agent in a worktree outside the session folder', () => {
|
||||
const OUTSIDE = { name: 'myapp-feature', path: SIBLING };
|
||||
const update = (extra: Record<string, unknown>) => ({ id: 'sess-1', ...extra });
|
||||
|
||||
const panelElement = () => {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
};
|
||||
|
||||
const loadPanel = () => {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (x: string) => String(x),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (path: string) => path },
|
||||
});
|
||||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, panelElement()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route. The agent is in the outside worktree when `outside` is set, and
|
||||
// a listing then reports it only when the request asks for root=live-any.
|
||||
const server = { outside: null as typeof OUTSIDE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.outside;
|
||||
const data = {
|
||||
root: live ? server.outside!.path : WORKING_DIR,
|
||||
...(live && { worktree: server.outside }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
};
|
||||
|
||||
it('reloads when an update carries only outsideWorktree and the listing reports none', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
server.outside = OUTSIDE;
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reload when the listing already reports that outside worktree', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the session folder pinned while the agent stays in the outside worktree', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
|
||||
expect(app._fileBrowserState.normalState.data.worktree).toBeUndefined();
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(true);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(false);
|
||||
expect(elements.fileBrowserRootLabel.textContent).toBe('Session folder');
|
||||
expect(elements.fileBrowserRootSwitch.textContent).toBe('Back to worktree');
|
||||
});
|
||||
|
||||
it('drops the pin and reloads once the agent leaves the outside worktree', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
server.outside = null;
|
||||
app._followFileBrowserWorktree(update({}));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(app._fileBrowserState.pinnedWorktree).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -387,7 +387,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
|
||||
expect(pending[0].url).toBe(
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live-any'
|
||||
);
|
||||
});
|
||||
|
||||
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
|
||||
@@ -399,7 +401,9 @@ describe('File Viewer server search', () => {
|
||||
app.activeSessionId = 'session/B';
|
||||
app.filterFileBrowser('after-session');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
|
||||
expect(pending[0].url).toBe(
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live-any'
|
||||
);
|
||||
|
||||
pending[0].reply.resolve(response(successfulData()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -450,7 +454,7 @@ describe('File Viewer server search', () => {
|
||||
expect(pending).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live-any',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -659,7 +663,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live-any`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 257 trimmed characters without a request', async () => {
|
||||
@@ -786,7 +790,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any');
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||
await toggle;
|
||||
@@ -794,7 +798,7 @@ describe('File Viewer server search', () => {
|
||||
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live-any');
|
||||
pending[2].reply.resolve(
|
||||
response(
|
||||
successfulData({
|
||||
@@ -877,8 +881,8 @@ describe('File Viewer server search', () => {
|
||||
const firstToggle = app.toggleFileBrowserHidden();
|
||||
const secondToggle = app.toggleFileBrowserHidden();
|
||||
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||
@@ -891,7 +895,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(4);
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live-any');
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1674,7 +1678,9 @@ describe('File Viewer server search', () => {
|
||||
expect(immediateState.activeSessionId).toBe('session/B');
|
||||
expect(immediateState.ownerSessionId).toBe('session/B');
|
||||
expect(immediateState.resizeCalls).toBe(1);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
expect(immediateState.fileUrls).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
expect(filePending).toHaveLength(1);
|
||||
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||
@@ -1755,7 +1761,9 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -1797,8 +1805,8 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live-any',
|
||||
]);
|
||||
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
|
||||
});
|
||||
@@ -1815,9 +1823,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
expect(secondA).not.toBe(firstA);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||
|
||||
@@ -244,6 +244,7 @@ function loadApp() {
|
||||
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' }));
|
||||
app.showToast = vi.fn();
|
||||
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ function loadApp(prefs: Record<string, string> = {}) {
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app._resetFilePreviewEdit = () => {};
|
||||
app._isExternalPreviewPath = () => false;
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
app.sessions = new Map();
|
||||
app.formatFileSize = (n: number) => `${n} B`;
|
||||
app.showToast = vi.fn();
|
||||
app.filePreviewContent = '';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:99afa5bbf1e7038bda7d028e100ee76b7337c943468d52590de3282db7b52adb","size":220},"layers":[{"mediaType":"application/vnd.ollama.image.model","digest":"sha256:d372de8e934898a59e6ccfabc3368474711384d8f1fd4d22d87a3f0a45400cdc","size":21718480960,"from":"qwen3.6-source:35b-a3b-mtp-q4_K_M-20260824"},{"mediaType":"application/vnd.ollama.image.projector","digest":"sha256:a62390d25b4b4a2d8afd7cc3c90021c11935c1fdd48d7cb0723ab71cd02a598e","size":902821728,"from":"qwen3.6-source:35b-a3b-mtp-q4_K_M-20260824"},{"mediaType":"application/vnd.ollama.image.license","digest":"sha256:5f3a3c817e78f5b8a4ad2d2c458a3e4b2cce470d6c12642c4eeb12cb8a9bf51d","size":11357,"from":"qwen3.6-source:35b-a3b-mtp-q4_K_M-20260824"},{"mediaType":"application/vnd.ollama.image.params","digest":"sha256:ce44c0f3ca1f7a126724e5285be7e1afdab202186c6379515ab0c1ffd40d3ecc","size":116}],"runner":"llamacpp","format":"gguf"}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":2,"mediaType":"application/vnd.ollama.manifest.list.v2+json","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba","runner":"mlx","format":"safetensors"},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:a7eb95c53bcf96b4bdd008d0fab4a5dac88047d9c1a7a9ab88ed453423fbd87c","runner":"llamacpp","format":"gguf"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"architecture":"","capabilities":["completion","vision","tools","thinking"],"file_type":"nvfp4","model_families":null,"model_family":"","model_format":"safetensors","model_type":"","os":"","parser":"qwen3.5","renderer":"qwen3.5","requires":"0.32.4"}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @fileoverview A frozen model catalogue for the RULE tests of src/local-model/recommend.ts:
|
||||
* the stock catalogue exactly as it shipped on 2026-11-06 (seven rows that all carry the
|
||||
* figures measured on one model), with the `runner` field the schema gained since.
|
||||
*
|
||||
* Why a frozen copy and not the stock catalogue: the stock catalogue is meant to change every
|
||||
* week or so (new models, new measurements). The tests of a RULE (the ranking, the disk
|
||||
* filter, the tags, the `limited` line, the harness fallback) need realistic rows whose
|
||||
* answers do not move with it, or every catalogue update would rewrite a hundred rule tests
|
||||
* and hide the few answers that really changed. What the STOCK catalogue answers for each Mac
|
||||
* is pinned separately, in test/local-model-recommend.test.ts ("every memory size") and the
|
||||
* two real-Mac blocks, and that is the table a catalogue change must update on purpose.
|
||||
*
|
||||
* Its figures are NOT a statement about any model: the rows borrow the measured row's figures,
|
||||
* which is exactly why the stock catalogue replaced them. Validated by the real schema.
|
||||
*/
|
||||
|
||||
import type { LocalModelCatalog } from '../../src/types.js';
|
||||
|
||||
const MEASURED_MEMORY = {
|
||||
/** 36,000 to 38,000 bytes stayed held per token of context after a prompt of 89,000 and one of 167,000 tokens. */
|
||||
heldBytesPerToken: 37_000,
|
||||
/** The runner held 2.5 to 3 GB beside the weights. */
|
||||
overheadBytes: 3_000_000_000,
|
||||
/** While a long prompt was processed the peak stood about 5 GB above what stayed held. */
|
||||
peakExtraBytes: 5_000_000_000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The catalogue as it shipped on 2026-11-06, with the `runner` field added. Frozen: a fixture,
|
||||
* not the stock catalogue.
|
||||
*/
|
||||
export const RULES_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
// The oldest macOS the model runtime supports.
|
||||
minMacosMajor: 14,
|
||||
// Ordered by rank. The higher rank wins among models that fit equally well and are equally
|
||||
// fast. `flint:40b` is left out on purpose: its plain tag is the slow build on a Mac and it
|
||||
// has an open bug with tool arguments.
|
||||
models: [
|
||||
{
|
||||
id: 'qwen3.5:9b',
|
||||
label: 'Qwen3.5 9B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 9,
|
||||
runner: 'mlx',
|
||||
bytes: 6_550_825_373,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [65536, 32768],
|
||||
rank: 1,
|
||||
// 37.1 tokens per second measured on 400 GB/s.
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.5:9b-q8_0',
|
||||
label: 'Qwen3.5 9B (Q8)',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 9,
|
||||
runner: 'llamacpp',
|
||||
bytes: 10_449_217_949,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [65536, 32768],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above scaled by file size.
|
||||
speedPer100Gbps: 5.8,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'gpt-oss:20b',
|
||||
label: 'GPT-OSS 20B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 20,
|
||||
runner: 'llamacpp',
|
||||
bytes: 13_793_441_427,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 3,
|
||||
// No speed figure: its speed is unknown on every chip.
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 35,
|
||||
runner: 'mlx',
|
||||
// What a Mac really downloads: an MLX build, 2% above the 22,621,314,161 of the manifest.
|
||||
bytes: 23_105_798_956,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'measured',
|
||||
contexts: [131072, 65536],
|
||||
rank: 4,
|
||||
// 54.5 tokens per second measured on 400 GB/s (3 billion parameters are active per token).
|
||||
// The MLX build ran faster than this figure predicts on the one Mac we tested (67.7
|
||||
// against 41.8), so on other chips the estimate is on the low side.
|
||||
speedPer100Gbps: 13.6,
|
||||
// Run by us with OpenCode on the MacBook Pro (Mac17,13): a four-step task with tool calls.
|
||||
tested: [{ chip: 'Apple M5 Pro', memGb: 48, cli: 'opencode', date: '2026-11-06', tokensPerSecond: 67.7 }],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.8:27b',
|
||||
label: 'Qwen3.8 27B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 27.8,
|
||||
runner: 'mlx',
|
||||
bytes: 17_741_871_939,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536, 32768],
|
||||
rank: 5,
|
||||
// 11.8 tokens per second measured on 400 GB/s.
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.8:27b-q8_0',
|
||||
label: 'Qwen3.8 27B (Q8)',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 27.8,
|
||||
runner: 'llamacpp',
|
||||
bytes: 29_978_241_837,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072],
|
||||
rank: 6,
|
||||
// Not measured: the 2.95 of the row above scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.5:122b-a10b',
|
||||
label: 'Qwen3.5 122B-A10B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 122,
|
||||
runner: 'llamacpp',
|
||||
bytes: 81_370_035_877,
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'estimated',
|
||||
contexts: [131072],
|
||||
rank: 7,
|
||||
// No speed figure: its speed is unknown on every chip.
|
||||
tested: [],
|
||||
},
|
||||
],
|
||||
// The higher rank wins when a model can run with more than one harness.
|
||||
harnesses: [
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
|
||||
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
|
||||
],
|
||||
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
|
||||
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
|
||||
// M5 Max 460 or 614) the LOWER one stands, so a speed computed from it is a floor.
|
||||
chips: [
|
||||
{ match: 'M5 Ultra', gbps: 1228 },
|
||||
{ match: 'M3 Ultra', gbps: 800 },
|
||||
{ match: 'M2 Ultra', gbps: 800 },
|
||||
{ match: 'M1 Ultra', gbps: 800 },
|
||||
{ match: 'M5 Max', gbps: 460 },
|
||||
{ match: 'M4 Max', gbps: 410 },
|
||||
{ match: 'M3 Max', gbps: 300 },
|
||||
{ match: 'M2 Max', gbps: 400 },
|
||||
{ match: 'M1 Max', gbps: 400 },
|
||||
{ match: 'M5 Pro', gbps: 307 },
|
||||
{ match: 'M4 Pro', gbps: 273 },
|
||||
{ match: 'M3 Pro', gbps: 150 },
|
||||
{ match: 'M2 Pro', gbps: 200 },
|
||||
{ match: 'M1 Pro', gbps: 200 },
|
||||
{ match: 'M5', gbps: 154 },
|
||||
{ match: 'M4', gbps: 120 },
|
||||
{ match: 'M3', gbps: 100 },
|
||||
{ match: 'M2', gbps: 100 },
|
||||
{ match: 'M1', gbps: 68 },
|
||||
],
|
||||
};
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
agentSkillExcludeLine,
|
||||
createCaseWorktree,
|
||||
@@ -50,6 +50,7 @@ const ENV = {
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
XDG_CONFIG_HOME: '/nonexistent-xdg',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
@@ -205,6 +206,13 @@ describe('git worktrees on a real repository', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-worktrees-')));
|
||||
// The code under test runs git with the real process env. git reads the global ignore file
|
||||
// from $XDG_CONFIG_HOME/git/ignore, which GIT_CONFIG_GLOBAL does not cover: a user who
|
||||
// ignores `.claude/settings.local.json` globally would see that line silently not appended.
|
||||
// A core.excludesFile in the system gitconfig does the same, hence GIT_CONFIG_NOSYSTEM.
|
||||
vi.stubEnv('XDG_CONFIG_HOME', join(dir, 'xdg'));
|
||||
vi.stubEnv('GIT_CONFIG_GLOBAL', '/dev/null');
|
||||
vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1');
|
||||
repoDir = join(dir, 'repo');
|
||||
mkdirSync(repoDir);
|
||||
git(repoDir, 'init', '-q', '-b', 'main');
|
||||
@@ -216,6 +224,7 @@ describe('git worktrees on a real repository', () => {
|
||||
repo = resolved.repo;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @fileoverview `findLiveWorktree()` for a worktree OUTSIDE the session's folder (#765 follow-up).
|
||||
*
|
||||
* Such a worktree becomes a second root the file routes serve, so what matters is what must NOT be
|
||||
* followed: a folder the session's repository does not list, a forged `.git` file with no registry
|
||||
* entry naming it back, another repository, the home folder or anything above it, a folder holding
|
||||
* the session's folder, a Docker case workspace, and a folder the multi-user rule refuses. Runs
|
||||
* against worktrees real git made, since the registry files are the thing being read.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, relative as posixRelative } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { findLiveWorktree, findOutsideWorktree } from '../src/live-worktree.js';
|
||||
|
||||
const git = (cwd: string, ...args: string[]): void => {
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
|
||||
};
|
||||
|
||||
describe('findLiveWorktree outside the session folder', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let sibling: string;
|
||||
/** Not the user's real home: the base folder must not count as "home or above". */
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-')));
|
||||
home = join(base, 'home');
|
||||
mkdirSync(home);
|
||||
repo = join(base, 'myapp');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
writeFileSync(join(repo, 'README.md'), 'main');
|
||||
git(repo, 'add', 'README.md');
|
||||
git(repo, 'commit', '-q', '-m', 'init');
|
||||
sibling = join(base, 'myapp-feature');
|
||||
git(repo, 'worktree', 'add', '-q', sibling);
|
||||
mkdirSync(join(sibling, 'src', 'deep'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const expected = () => ({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
|
||||
it('follows a sibling worktree of the session repository, from its root and from below it', async () => {
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toEqual(expected());
|
||||
expect(await findLiveWorktree(repo, join(sibling, 'src', 'deep'), { home })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('follows nothing outside the folder unless the caller passes the outside rules', async () => {
|
||||
expect(await findLiveWorktree(repo, sibling)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows a worktree whose gitdir links are relative paths, whatever the git version', async () => {
|
||||
// Hand-written, since git older than 2.48 cannot make this layout itself.
|
||||
const relative = join(base, 'myapp-relative');
|
||||
const entry = join(repo, '.git', 'worktrees', 'myapp-relative');
|
||||
mkdirSync(relative);
|
||||
mkdirSync(entry, { recursive: true });
|
||||
writeFileSync(join(entry, 'gitdir'), `${posixRelative(entry, join(relative, '.git'))}\n`);
|
||||
writeFileSync(join(relative, '.git'), `gitdir: ${posixRelative(relative, entry)}\n`);
|
||||
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
|
||||
name: 'myapp-relative',
|
||||
path: relative,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('follows a worktree made with --relative-paths', async (ctx) => {
|
||||
const relative = join(base, 'myapp-relative-git');
|
||||
try {
|
||||
git(repo, 'worktree', 'add', '-q', '--relative-paths', relative);
|
||||
} catch {
|
||||
ctx.skip(); // git older than 2.48 has no --relative-paths
|
||||
return;
|
||||
}
|
||||
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
|
||||
name: 'myapp-relative-git',
|
||||
path: relative,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('follows the main worktree and other worktrees from a session started in a linked one', async () => {
|
||||
const other = join(base, 'myapp-other');
|
||||
git(repo, 'worktree', 'add', '-q', other);
|
||||
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
|
||||
expect(await findLiveWorktree(sibling, other, { home })).toEqual({
|
||||
name: 'myapp-other',
|
||||
path: other,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers with the real path when the pane reached the worktree through a symlink', async () => {
|
||||
const link = join(base, 'link-to-feature');
|
||||
symlinkSync(sibling, link);
|
||||
expect(await findLiveWorktree(repo, join(link, 'src'), { home })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('refuses a folder in another repository, and one in no repository at all', async () => {
|
||||
const otherRepo = join(base, 'other');
|
||||
mkdirSync(otherRepo);
|
||||
git(otherRepo, 'init', '-q');
|
||||
expect(await findLiveWorktree(repo, otherRepo, { home })).toBeUndefined();
|
||||
const plain = join(base, 'plain');
|
||||
mkdirSync(plain);
|
||||
expect(await findLiveWorktree(repo, plain, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a worktree of ANOTHER repository, even one laid out the same way', async () => {
|
||||
const otherRepo = join(base, 'other');
|
||||
mkdirSync(otherRepo);
|
||||
git(otherRepo, 'init', '-q');
|
||||
writeFileSync(join(otherRepo, 'a'), 'a');
|
||||
git(otherRepo, 'add', 'a');
|
||||
git(otherRepo, 'commit', '-q', '-m', 'a');
|
||||
const otherWorktree = join(base, 'other-feature');
|
||||
git(otherRepo, 'worktree', 'add', '-q', otherWorktree);
|
||||
expect(await findLiveWorktree(repo, otherWorktree, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a forged .git file that the registry does not name back', async () => {
|
||||
const forged = join(base, 'forged');
|
||||
mkdirSync(forged);
|
||||
// Points at the real registry entry of the sibling, whose `gitdir` names the sibling.
|
||||
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
|
||||
writeFileSync(join(forged, '.git'), `gitdir: ${entry}\n`);
|
||||
expect(await findLiveWorktree(repo, forged, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a registry entry that names a .git symlink back', async () => {
|
||||
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
|
||||
// The symlink leads to a copy of the original `gitdir` file, whose text is `<sibling>/.git`,
|
||||
// so only the refusal of a symlink can stop it: followed, it would name the sibling back.
|
||||
const copy = join(base, 'gitdir-copy');
|
||||
writeFileSync(copy, readFileSync(join(entry, 'gitdir')));
|
||||
rmSync(join(entry, 'gitdir'));
|
||||
symlinkSync(copy, join(entry, 'gitdir'));
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a .git file that is a symlink', async () => {
|
||||
const text = execFileSync('cat', [join(sibling, '.git')], { encoding: 'utf8' });
|
||||
const copy = join(base, 'dot-git-copy');
|
||||
writeFileSync(copy, text);
|
||||
rmSync(join(sibling, '.git'));
|
||||
symlinkSync(copy, join(sibling, '.git'));
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a folder whose .git is a symlink to the repository’s .git folder', async () => {
|
||||
const fake = join(base, 'fake');
|
||||
mkdirSync(fake);
|
||||
symlinkSync(join(repo, '.git'), join(fake, '.git'));
|
||||
// From a session in the main checkout.
|
||||
expect(await findLiveWorktree(repo, fake, { home })).toBeUndefined();
|
||||
// From a session in a linked worktree.
|
||||
expect(await findLiveWorktree(sibling, fake, { home })).toBeUndefined();
|
||||
// The real main worktree is still followed from the same session.
|
||||
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
|
||||
});
|
||||
|
||||
it('follows nothing from a session started in a subfolder of its repository', async () => {
|
||||
const sub = join(repo, 'sub');
|
||||
mkdirSync(sub);
|
||||
expect(await findLiveWorktree(sub, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a worktree at or above the home folder', async () => {
|
||||
expect(await findLiveWorktree(repo, sibling, { home: join(sibling, 'src') })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, sibling, { home: sibling })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a worktree that holds the session folder', async () => {
|
||||
// A registry entry forged around the session's own folder: `base` holds `repo`. Both halves
|
||||
// of the link are in place, so only the containment rule can refuse it.
|
||||
const entry = join(repo, '.git', 'worktrees', 'around');
|
||||
mkdirSync(entry, { recursive: true });
|
||||
writeFileSync(join(entry, 'gitdir'), `${join(base, '.git')}\n`);
|
||||
writeFileSync(join(base, '.git'), `gitdir: ${entry}\n`);
|
||||
const elsewhereHome = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-home-')));
|
||||
try {
|
||||
expect(await findOutsideWorktree(repo, base, { home: elsewhereHome })).toBeUndefined();
|
||||
// The same forged link around a folder that does NOT hold the session folder is followed,
|
||||
// which shows the refusal above came from the containment rule.
|
||||
const loose = join(base, 'loose');
|
||||
mkdirSync(loose);
|
||||
const looseEntry = join(repo, '.git', 'worktrees', 'loose');
|
||||
mkdirSync(looseEntry, { recursive: true });
|
||||
writeFileSync(join(looseEntry, 'gitdir'), `${join(loose, '.git')}\n`);
|
||||
writeFileSync(join(loose, '.git'), `gitdir: ${looseEntry}\n`);
|
||||
expect(await findOutsideWorktree(repo, loose, { home: elsewhereHome })).toEqual({
|
||||
name: 'loose',
|
||||
path: loose,
|
||||
outside: true,
|
||||
});
|
||||
} finally {
|
||||
rmSync(elsewhereHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a worktree at, inside or around a Docker case workspace', async () => {
|
||||
const at = async () => [sibling];
|
||||
const around = async () => [join(sibling, 'src')];
|
||||
const above = async () => [base];
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: at })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: around })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: above })).toBeUndefined();
|
||||
const elsewhere = async () => [join(base, 'elsewhere')];
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: elsewhere })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('follows nothing while the Docker case list cannot be read', async () => {
|
||||
const unreadable = async (): Promise<string[]> => {
|
||||
throw new Error('docker-cases.json mid-write');
|
||||
};
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: unreadable })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets allowRoot refuse the worktree, and passes it the real path', async () => {
|
||||
const seen: string[] = [];
|
||||
const refuse = (root: string) => {
|
||||
seen.push(root);
|
||||
return false;
|
||||
};
|
||||
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: refuse })).toBeUndefined();
|
||||
expect(seen).toEqual([sibling]);
|
||||
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: async () => true })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('treats an allowRoot that rejects as a refusal', async () => {
|
||||
const reject = async () => {
|
||||
throw new Error('settings unreadable');
|
||||
};
|
||||
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: reject })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears when the pane comes back to the session folder', async () => {
|
||||
expect(await findLiveWorktree(repo, repo, { home })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, join(repo, 'src'), { home })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,12 @@
|
||||
* hides a more specific one).
|
||||
* 3. The chip lookup never guesses: a chip that no row names has no bandwidth, including one
|
||||
* whose name merely contains a known name ('Apple M10', 'Apple M6 Pro').
|
||||
* 4. Which figures were MEASURED on a Mac and which are carried over from the measured row:
|
||||
* one row says `measured` (its size is what a Mac really downloads, and it alone has a
|
||||
* tested entry), every other row says `estimated`.
|
||||
* 4. Which figures were MEASURED on a Mac and which were computed: one row says `measured`
|
||||
* (it alone has a tested entry), every other row says `estimated`, and every estimated
|
||||
* row's three memory figures are recomputed here from its OWN model's architecture (a
|
||||
* table read from each model's config.json) for its runner and the runtime's settings. Two
|
||||
* rows may share figures only when they share the architecture and the runner, so a row
|
||||
* can never again carry another model's measurement without saying so.
|
||||
*
|
||||
* Port: N/A (pure data and functions).
|
||||
*/
|
||||
@@ -28,6 +31,12 @@ import {
|
||||
LocalModelCatalogSchema,
|
||||
STOCK_LOCAL_MODEL_CATALOG,
|
||||
} from '../src/config/local-model-catalog.js';
|
||||
import {
|
||||
RUNTIME_CTX_CHECKPOINTS,
|
||||
RUNTIME_HOST_PROMPT_CACHE_MIB,
|
||||
RUNTIME_KV_CACHE_TYPE,
|
||||
runtimeEnv,
|
||||
} from '../src/local-model/runtime.js';
|
||||
import type { LocalModelCatalog, LocalModelEntry } from '../src/types.js';
|
||||
|
||||
/** A deep copy of the stock catalogue that a test may break. */
|
||||
@@ -48,7 +57,121 @@ function withFirstModel(change: Partial<Record<keyof LocalModelEntry | 'url' | '
|
||||
return { ...catalog, models: [{ ...catalog.models[0], ...change }, ...catalog.models.slice(1)] };
|
||||
}
|
||||
|
||||
/** One MiB and one GiB, in bytes. */
|
||||
const MIB = 1024 * 1024;
|
||||
const GIB = 1024 * MIB;
|
||||
|
||||
/**
|
||||
* The architecture of every model the stock catalogue lists, read from its config.json on
|
||||
* Hugging Face (2026-11-08): full-attention layers, their K/V heads and head size, and the
|
||||
* layers whose cache does not grow with the context (Gated DeltaNet layers with their value
|
||||
* and key heads, or sliding-window layers with their window). Fine-tunes share their base's.
|
||||
*/
|
||||
const ARCHITECTURES = {
|
||||
'Qwen3.5-9B': { attentionLayers: 8, kvHeads: 4, headDim: 256, linear: { layers: 24, vHeads: 32, kHeads: 16 } },
|
||||
'Qwen3.8-27B': { attentionLayers: 16, kvHeads: 4, headDim: 256, linear: { layers: 48, vHeads: 48, kHeads: 16 } },
|
||||
'Qwen3.6-35B-A3B': { attentionLayers: 10, kvHeads: 2, headDim: 256, linear: { layers: 30, vHeads: 32, kHeads: 16 } },
|
||||
'Qwen3.5-122B-A10B': {
|
||||
attentionLayers: 12,
|
||||
kvHeads: 2,
|
||||
headDim: 256,
|
||||
linear: { layers: 36, vHeads: 64, kHeads: 16 },
|
||||
},
|
||||
'North-Mini-Code-1.0': { attentionLayers: 13, kvHeads: 4, headDim: 128, sliding: { layers: 36, window: 4096 } },
|
||||
} as const;
|
||||
|
||||
type ArchitectureName = keyof typeof ARCHITECTURES;
|
||||
type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
|
||||
|
||||
/**
|
||||
* Which architecture each stock row runs, and how many tokens the runtime drafts per step with
|
||||
* its multi-token-prediction layer: 0 when the GGUF has none (`nextn_predict_layers` in the GGUF
|
||||
* header, read on 2026-11-08 from the first bytes of each model layer), else the tag's own
|
||||
* `draft_num_predict` (its params layer) or the runtime's default of 4 when the tag sets none.
|
||||
* Prediction costs a cache for that layer and one rollback copy of the recurrent state per
|
||||
* drafted token (see the catalogue header). A row the catalogue gains must be added here, or the
|
||||
* test below fails: its figures cannot be checked without it.
|
||||
*/
|
||||
const ROW_ARCHITECTURE: Record<string, { architecture: ArchitectureName; draftTokens: number }> = {
|
||||
'qwen3.5:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
'mimo-v2.6:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
'north-mini-code-1.0:q4_k_m': { architecture: 'North-Mini-Code-1.0', draftTokens: 0 },
|
||||
// Its params layer sets `draft_num_predict` 2.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 2 },
|
||||
'qwen3.6:35b-a3b': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 0 },
|
||||
// A prediction layer, and no `draft_num_predict`: the runtime's 4.
|
||||
'qwen3.8:27b-q8_0': { architecture: 'Qwen3.8-27B', draftTokens: 4 },
|
||||
// Its params layer sets `draft_num_predict` 4.
|
||||
'qwen3.8:27b-mtp-q4_k_m': { architecture: 'Qwen3.8-27B', draftTokens: 4 },
|
||||
'ornith-1.5:122b': { architecture: 'Qwen3.5-122B-A10B', draftTokens: 0 },
|
||||
};
|
||||
|
||||
/** Bytes per cached value in each K/V cache type of llama.cpp: q8_0 keeps 32 values in 34 bytes. */
|
||||
const KV_BYTES_PER_VALUE: Record<string, number> = { f16: 2, q8_0: 34 / 32 };
|
||||
/**
|
||||
* The cache type of llama-server's draft context, which holds the prediction layer's cache: its
|
||||
* default, because the runtime sets only the main context's (`--cache-type-k`).
|
||||
*/
|
||||
const DRAFT_KV_CACHE_TYPE = 'f16';
|
||||
/** The sliding-window cache holds the window plus one batch of 512 tokens (`n_swa + n_ubatch`). */
|
||||
const SLIDING_EXTRA_CELLS = 512;
|
||||
/** The head size of a Gated DeltaNet layer's keys and values, and its convolution kernel, in every model above. */
|
||||
const LINEAR_HEAD_DIM = 128;
|
||||
const LINEAR_CONV_KERNEL = 4;
|
||||
|
||||
/** The recurrent state of the linear-attention layers in bytes: the convolution tail and the delta state. */
|
||||
function recurrentStateBytes(arch: Architecture, convBytes: number): number {
|
||||
if (!('linear' in arch)) return 0;
|
||||
const { layers, vHeads, kHeads } = arch.linear;
|
||||
const convDim = vHeads * LINEAR_HEAD_DIM + 2 * kHeads * LINEAR_HEAD_DIM;
|
||||
const conv = (LINEAR_CONV_KERNEL - 1) * convDim * convBytes;
|
||||
const delta = vHeads * LINEAR_HEAD_DIM * LINEAR_HEAD_DIM * 4;
|
||||
return layers * (conv + delta);
|
||||
}
|
||||
|
||||
/** What a `llamacpp` row's three figures must be, from its architecture and the runtime's settings. */
|
||||
function llamaCppFigures(arch: Architecture, draftTokens: number): [number, number, number] {
|
||||
const predictionLayer = draftTokens > 0;
|
||||
const perValue = KV_BYTES_PER_VALUE[RUNTIME_KV_CACHE_TYPE];
|
||||
const perLayer = arch.kvHeads * arch.headDim * 2;
|
||||
// The prediction layer's cache sits in the draft context, in the draft's cache type.
|
||||
const held =
|
||||
arch.attentionLayers * perLayer * perValue +
|
||||
(predictionLayer ? perLayer * KV_BYTES_PER_VALUE[DRAFT_KV_CACHE_TYPE] : 0);
|
||||
// llama.cpp keeps the recurrent state in f32 (both parts), the sliding window in the cache type.
|
||||
const recurrent = recurrentStateBytes(arch, 4);
|
||||
const sliding =
|
||||
'sliding' in arch ? arch.sliding.layers * (arch.sliding.window + SLIDING_EXTRA_CELLS) * perLayer * perValue : 0;
|
||||
// Live: the sliding window, and the recurrent state with one rollback copy per drafted token
|
||||
// (`--spec-draft-n-max`, which `need_n_rs_seq()` turns into that many copies).
|
||||
const live = sliding + recurrent * (1 + draftTokens);
|
||||
// A context checkpoint is a copy of what cannot be rolled back: the recurrent state or the window.
|
||||
const checkpoints = RUNTIME_CTX_CHECKPOINTS * (recurrent + sliding);
|
||||
return [held, live + checkpoints + 1_000_000_000 + RUNTIME_HOST_PROMPT_CACHE_MIB * MIB, 0];
|
||||
}
|
||||
|
||||
/** The per-token figure of the MLX runner computed from an architecture: K/V in bf16, a recurrent snapshot per 8,192 tokens. */
|
||||
function mlxComputedPerToken(arch: Architecture): number {
|
||||
return arch.attentionLayers * arch.kvHeads * arch.headDim * 2 * 2 + recurrentStateBytes(arch, 2) / 8192;
|
||||
}
|
||||
|
||||
/** The figures measured for `qwen3.6:35b-a3b` on the MacBook Pro. */
|
||||
const MEASURED: [number, number, number] = [37_000, 3_000_000_000, 5_000_000_000];
|
||||
|
||||
/** What an estimated `mlx` row's three figures must be: computed, scaled by what the measured row showed. */
|
||||
function mlxFigures(arch: Architecture): [number, number, number] {
|
||||
const ratio = MEASURED[0] / mlxComputedPerToken(ARCHITECTURES['Qwen3.6-35B-A3B']);
|
||||
return [Math.ceil(mlxComputedPerToken(arch) * ratio), MEASURED[1] + 8 * GIB, MEASURED[2]];
|
||||
}
|
||||
|
||||
/** A row's three memory figures. */
|
||||
function figuresOf(model: LocalModelEntry): [number, number, number] {
|
||||
return [model.heldBytesPerToken, model.overheadBytes, model.peakExtraBytes];
|
||||
}
|
||||
|
||||
describe('the stock catalogue', () => {
|
||||
const models = STOCK_LOCAL_MODEL_CATALOG.models;
|
||||
|
||||
it('passes its own schema', () => {
|
||||
expect(issuesOf(STOCK_LOCAL_MODEL_CATALOG)).toEqual([]);
|
||||
});
|
||||
@@ -60,94 +183,114 @@ describe('the stock catalogue', () => {
|
||||
expect(getLocalModelCatalog()).toBe(first);
|
||||
});
|
||||
|
||||
it('lists the seven models of the design, without flint:40b', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b',
|
||||
'qwen3.5:9b-q8_0',
|
||||
'gpt-oss:20b',
|
||||
it('lists eight models, by rank, and none of those set aside in the header', () => {
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b-q4_k_m',
|
||||
'mimo-v2.6:9b-q4_k_m',
|
||||
'north-mini-code-1.0:q4_k_m',
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m',
|
||||
'qwen3.6:35b-a3b',
|
||||
'qwen3.8:27b',
|
||||
'qwen3.8:27b-q8_0',
|
||||
'qwen3.5:122b-a10b',
|
||||
'qwen3.8:27b-mtp-q4_k_m',
|
||||
'ornith-1.5:122b',
|
||||
]);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.some((m) => m.id.startsWith('flint'))).toBe(false);
|
||||
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const setAside =
|
||||
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:122b)|^ornith-1\.5:(9b|35b|397b)/;
|
||||
expect(models.filter((m) => setAside.test(m.id)).map((m) => m.id)).toEqual([]);
|
||||
// The plain 9B and 27B tags are MLX builds on a Mac: their GGUF builds are listed instead.
|
||||
for (const plain of ['qwen3.5:9b', 'mimo-v2.6:9b', 'qwen3.8:27b']) {
|
||||
expect(models.some((m) => m.id === plain)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the size a Mac really downloads where that was measured, else the registry manifest', () => {
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.bytes]))).toEqual({
|
||||
// The sum of the layer sizes of each tag's manifest, as fetched on 2026-11-06.
|
||||
'qwen3.5:9b': 6_550_825_373,
|
||||
'qwen3.5:9b-q8_0': 10_449_217_949,
|
||||
'gpt-oss:20b': 13_793_441_427,
|
||||
// The total of the real pull on a Mac (an MLX build). Its manifest sums to 22,621,314,161.
|
||||
it('names the runner a Mac uses for each tag', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.runner]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': 'llamacpp',
|
||||
'mimo-v2.6:9b-q4_k_m': 'llamacpp',
|
||||
'north-mini-code-1.0:q4_k_m': 'llamacpp',
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 'llamacpp',
|
||||
// Manifest lists: a Mac takes the MLX child.
|
||||
'qwen3.6:35b-a3b': 'mlx',
|
||||
'qwen3.8:27b-q8_0': 'llamacpp',
|
||||
'qwen3.8:27b-mtp-q4_k_m': 'llamacpp',
|
||||
'ornith-1.5:122b': 'mlx',
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the size of the build a Mac pulls: every layer plus the config', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.bytes]))).toEqual({
|
||||
// The registry's manifests as fetched on 2026-11-08, in lowercase.
|
||||
'qwen3.5:9b-q4_k_m': 6_550_825_550,
|
||||
'mimo-v2.6:9b-q4_k_m': 6_550_811_033,
|
||||
'north-mini-code-1.0:q4_k_m': 18_593_967_008,
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 22_621_314_381,
|
||||
// The MLX child of the list, and the total of a real pull on a Mac, byte for byte.
|
||||
'qwen3.6:35b-a3b': 23_105_798_956,
|
||||
'qwen3.8:27b': 17_741_871_939,
|
||||
'qwen3.8:27b-q8_0': 29_978_241_837,
|
||||
'qwen3.5:122b-a10b': 81_370_035_877,
|
||||
'qwen3.8:27b-q8_0': 29_978_242_050,
|
||||
'qwen3.8:27b-mtp-q4_k_m': 17_741_872_154,
|
||||
// The MLX child of the list (its GGUF child is 76,756,174,997).
|
||||
'ornith-1.5:122b': 73_388_069_552,
|
||||
});
|
||||
});
|
||||
|
||||
it('says which row was measured on a Mac: one, and every other row is an estimate', () => {
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.figures]))).toEqual({
|
||||
'qwen3.5:9b': 'estimated',
|
||||
'qwen3.5:9b-q8_0': 'estimated',
|
||||
'gpt-oss:20b': 'estimated',
|
||||
'qwen3.6:35b-a3b': 'measured',
|
||||
'qwen3.8:27b': 'estimated',
|
||||
'qwen3.8:27b-q8_0': 'estimated',
|
||||
'qwen3.5:122b-a10b': 'estimated',
|
||||
});
|
||||
});
|
||||
|
||||
it('gives every row the memory figures measured on the one row: 37 KB per token, 3 GB, 5 GB', () => {
|
||||
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
|
||||
expect(
|
||||
[model.heldBytesPerToken, model.overheadBytes, model.peakExtraBytes],
|
||||
`${model.id}: an unmeasured row carries the measured row's figures, not invented ones`
|
||||
).toEqual([37_000, 3_000_000_000, 5_000_000_000]);
|
||||
}
|
||||
// No row keeps the figure that was computed from the model's config.
|
||||
expect(JSON.stringify(STOCK_LOCAL_MODEL_CATALOG)).not.toContain('kvBytesPerToken');
|
||||
expect(models.filter((m) => m.figures === 'measured').map((m) => m.id)).toEqual(['qwen3.6:35b-a3b']);
|
||||
expect(figuresOf(models.find((m) => m.id === 'qwen3.6:35b-a3b') as LocalModelEntry)).toEqual(MEASURED);
|
||||
});
|
||||
|
||||
it('has one tested entry: the 35B-A3B with OpenCode on the MacBook Pro, with its measured speed', () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.tested.length > 0).map((m) => [m.id, m.tested])
|
||||
)
|
||||
).toEqual({
|
||||
expect(Object.fromEntries(models.filter((m) => m.tested.length > 0).map((m) => [m.id, m.tested]))).toEqual({
|
||||
'qwen3.6:35b-a3b': [
|
||||
{ chip: 'Apple M5 Pro', memGb: 48, cli: 'opencode', date: '2026-11-06', tokensPerSecond: 67.7 },
|
||||
],
|
||||
});
|
||||
// A row may claim a test only when its figures were measured.
|
||||
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
|
||||
for (const model of models) {
|
||||
if (model.tested.length > 0) expect(model.figures, model.id).toBe('measured');
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every model a parameter count, an id of the default registry and a license', () => {
|
||||
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
|
||||
expect(model.paramsB, model.id).toBeGreaterThan(0);
|
||||
expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
|
||||
expect(model.license).toBe('Apache-2.0');
|
||||
}
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, [m.paramsB, m.license]]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': [9, 'Apache-2.0'],
|
||||
'mimo-v2.6:9b-q4_k_m': [9, 'MIT'],
|
||||
'north-mini-code-1.0:q4_k_m': [30, 'Apache-2.0'],
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': [35, 'Apache-2.0'],
|
||||
'qwen3.6:35b-a3b': [35, 'Apache-2.0'],
|
||||
'qwen3.8:27b-q8_0': [27.8, 'Apache-2.0'],
|
||||
'qwen3.8:27b-mtp-q4_k_m': [27.8, 'Apache-2.0'],
|
||||
'ornith-1.5:122b': [122, 'MIT'],
|
||||
});
|
||||
for (const model of models) expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
|
||||
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b',
|
||||
'qwen3.5:9b-q8_0',
|
||||
]);
|
||||
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual(['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m']);
|
||||
});
|
||||
|
||||
it('offers no model below the 65536 tokens a harness needs, nor beyond its own window', () => {
|
||||
for (const model of models) {
|
||||
expect(Math.min(...model.contexts), model.id).toBeGreaterThanOrEqual(65536);
|
||||
expect(model.contexts[0], model.id).toBeLessThanOrEqual(262144);
|
||||
}
|
||||
});
|
||||
|
||||
it('has a speed figure only where one was measured or scaled from a measurement', () => {
|
||||
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
|
||||
'qwen3.5:9b': 9.3,
|
||||
'qwen3.5:9b-q8_0': 5.8,
|
||||
'gpt-oss:20b': null,
|
||||
'qwen3.6:35b-a3b': 13.6,
|
||||
'qwen3.8:27b': 2.95,
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
|
||||
// 37.1 tokens per second on 400 GB/s, and the same for the fine-tune of the same size.
|
||||
'qwen3.5:9b-q4_k_m': 9.3,
|
||||
'mimo-v2.6:9b-q4_k_m': 9.3,
|
||||
// The 35B-A3B's llama.cpp figure: both have 3 billion active parameters.
|
||||
'north-mini-code-1.0:q4_k_m': 13.6,
|
||||
// 54.5 on 400 GB/s with llama.cpp.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 13.6,
|
||||
// 67.8 on 400 GB/s with MLX.
|
||||
'qwen3.6:35b-a3b': 16.9,
|
||||
// 2.95 scaled by file size.
|
||||
'qwen3.8:27b-q8_0': 1.7,
|
||||
'qwen3.5:122b-a10b': null,
|
||||
// 11.8 on 400 GB/s with llama.cpp, without prediction.
|
||||
'qwen3.8:27b-mtp-q4_k_m': 2.95,
|
||||
// 16.9 x 3/10 active parameters, rounded down.
|
||||
'ornith-1.5:122b': 5,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +317,92 @@ describe('the stock catalogue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the memory figures of the stock rows: each row has its own', () => {
|
||||
const models = STOCK_LOCAL_MODEL_CATALOG.models;
|
||||
|
||||
it('knows the architecture of every row, and every row of that table is in the catalogue', () => {
|
||||
expect(models.map((m) => m.id).sort()).toEqual(Object.keys(ROW_ARCHITECTURE).sort());
|
||||
});
|
||||
|
||||
it('computes the llamacpp rows for the K/V cache type and the prompt cache the runtime really sets', () => {
|
||||
// The figures below assume these; runtimeEnv() is what the runtime gets.
|
||||
const env = runtimeEnv({ source: {}, home: '/h', modelsDir: '/m', port: 11435, contextLength: 65536 });
|
||||
expect(env.OLLAMA_KV_CACHE_TYPE).toBe(RUNTIME_KV_CACHE_TYPE);
|
||||
expect(env.LLAMA_ARG_CACHE_RAM).toBe(String(RUNTIME_HOST_PROMPT_CACHE_MIB));
|
||||
expect(env.LLAMA_ARG_CTX_CHECKPOINTS).toBe(String(RUNTIME_CTX_CHECKPOINTS));
|
||||
expect(KV_BYTES_PER_VALUE[RUNTIME_KV_CACHE_TYPE]).toBeDefined();
|
||||
// The runtime sets no cache type for the draft context, so the prediction layer's stays f16.
|
||||
expect(Object.keys(env).filter((name) => /DRAFT|SPEC/.test(name))).toEqual([]);
|
||||
});
|
||||
|
||||
it("recomputes every estimated row's figures from its own model's architecture and runner", () => {
|
||||
for (const model of models.filter((m) => m.figures === 'estimated')) {
|
||||
const { architecture, draftTokens } = ROW_ARCHITECTURE[model.id];
|
||||
const arch = ARCHITECTURES[architecture];
|
||||
const expected = model.runner === 'llamacpp' ? llamaCppFigures(arch, draftTokens) : mlxFigures(arch);
|
||||
expect(figuresOf(model), `${model.id} (${architecture}, ${model.runner})`).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('states the numbers the header and the row comments name', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, figuresOf(m)]))).toEqual({
|
||||
// State x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
|
||||
'qwen3.5:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
'mimo-v2.6:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
// The sliding window x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
|
||||
'north-mini-code-1.0:q4_k_m': [14_144, 3_698_117_120, 0],
|
||||
// State x (1 live + 2 rollback copies + 8 checkpoints) + 1.0 GB + 1 GiB; the prediction
|
||||
// layer's cache in f16 per token. The 27B rows below draft 4 tokens: 4 rollback copies.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': [12_928, 2_798_242_304, 0],
|
||||
'qwen3.6:35b-a3b': [37_000, 3_000_000_000, 5_000_000_000],
|
||||
'qwen3.8:27b-q8_0': [38_912, 4_113_353_216, 0],
|
||||
'qwen3.8:27b-mtp-q4_k_m': [38_912, 4_113_353_216, 0],
|
||||
'ornith-1.5:122b': [56_574, 11_589_934_592, 5_000_000_000],
|
||||
});
|
||||
// The two parts of a llamacpp overhead that are not computed from the architecture.
|
||||
expect(RUNTIME_CTX_CHECKPOINTS).toBe(8);
|
||||
expect(RUNTIME_HOST_PROMPT_CACHE_MIB).toBe(1024);
|
||||
// The MLX calibration: 37,000 held against 28,340 computed for the measured row.
|
||||
expect(mlxComputedPerToken(ARCHITECTURES['Qwen3.6-35B-A3B'])).toBe(28_340);
|
||||
expect(mlxComputedPerToken(ARCHITECTURES['Qwen3.5-122B-A10B'])).toBe(43_332);
|
||||
});
|
||||
|
||||
it('lets two rows share figures only when they share the architecture, the drafted tokens and the runner', () => {
|
||||
const byFigures = new Map<string, LocalModelEntry[]>();
|
||||
for (const model of models) {
|
||||
const key = figuresOf(model).join('/');
|
||||
byFigures.set(key, [...(byFigures.get(key) ?? []), model]);
|
||||
}
|
||||
const shared = [...byFigures.values()].filter((group) => group.length > 1);
|
||||
// The two 9B rows (a fine-tune and its base) and the two 27B rows (two quantizations of one model).
|
||||
expect(shared.map((group) => group.map((m) => m.id))).toEqual([
|
||||
['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
|
||||
['qwen3.8:27b-q8_0', 'qwen3.8:27b-mtp-q4_k_m'],
|
||||
]);
|
||||
for (const group of shared) {
|
||||
const identity = (m: LocalModelEntry): string => JSON.stringify([ROW_ARCHITECTURE[m.id], m.runner, m.figures]);
|
||||
expect(new Set(group.map(identity)).size, group.map((m) => m.id).join(', ')).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives no estimated row the measured row's per-token or overhead figure", () => {
|
||||
for (const model of models.filter((m) => m.figures === 'estimated')) {
|
||||
expect(model.heldBytesPerToken, model.id).not.toBe(MEASURED[0]);
|
||||
expect(model.overheadBytes, model.id).not.toBe(MEASURED[1]);
|
||||
expect(figuresOf(model), model.id).not.toEqual(MEASURED);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a llamacpp row no peak (its buffers are allocated at load) and an estimated mlx row the whole prefix budget', () => {
|
||||
for (const model of models) {
|
||||
if (model.runner === 'llamacpp') expect(model.peakExtraBytes, model.id).toBe(0);
|
||||
if (model.runner === 'mlx' && model.figures === 'estimated') {
|
||||
expect(model.overheadBytes - MEASURED[1], model.id).toBe(8 * GIB);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the model id rule', () => {
|
||||
it('accepts name:tag in lowercase', () => {
|
||||
for (const id of ['qwen3.5:9b', 'gpt-oss:20b', 'qwen3.8:27b-q8_0', 'a:b', 'x_y.z-1:2.3_4']) {
|
||||
@@ -240,6 +469,19 @@ describe('the schema, field by field', () => {
|
||||
expect(issuesOf(withFirstModel({ peakExtraBytes: 1.5 }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('requires the runner on every model row, and knows two', () => {
|
||||
const row: Record<string, unknown> = { ...copy().models[0] };
|
||||
delete row.runner;
|
||||
const issues = issuesOf({ ...copy(), models: [row] });
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toContain('models.0.runner');
|
||||
expect(issuesOf(withFirstModel({ runner: 'mlx' }))).toEqual([]);
|
||||
expect(issuesOf(withFirstModel({ runner: 'llamacpp' }))).toEqual([]);
|
||||
for (const runner of ['ggml', 'MLX', 'llama.cpp', '']) {
|
||||
expect(issuesOf(withFirstModel({ runner })), runner).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires paramsB and tested on every model row', () => {
|
||||
for (const field of ['paramsB', 'tested'] as const) {
|
||||
const row: Record<string, unknown> = { ...copy().models[0] };
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('modelFitsBudget', () => {
|
||||
const catalog = getLocalModelCatalog();
|
||||
|
||||
it('agrees with the recommendation on every model and context of the catalogue', () => {
|
||||
for (const gb of [8, 16, 24, 32, 48, 64, 128]) {
|
||||
for (const gb of [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192, 256, 512]) {
|
||||
const hw = mac(gb);
|
||||
const offered = recommend(hw, catalog, harnessFacts());
|
||||
const rows = [offered.pick, ...offered.alternatives].filter((row) => row !== undefined);
|
||||
@@ -85,7 +85,7 @@ describe('modelFitsBudget', () => {
|
||||
});
|
||||
|
||||
it('rules out a model that is too large, and one the catalogue does not have', () => {
|
||||
expect(modelFitsBudget(mac(48), catalog, 'qwen3.5:122b-a10b', 131072)).toBe(false);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'ornith-1.5:122b', 131072)).toBe(false);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'qwen3.6:35b-a3b', 131072)).toBe(true);
|
||||
});
|
||||
@@ -96,14 +96,16 @@ describe('modelFitsBudget', () => {
|
||||
expect(modelFitsBudget(mac(48), catalog, id, 131072, { measuredTotalBytes: 40_157_944_217 })).toBe(true);
|
||||
// A runtime that reports less than the estimate rules out what the estimate allowed.
|
||||
expect(modelFitsBudget(mac(48), catalog, id, 131072, { measuredTotalBytes: 33 * 1024 ** 3 })).toBe(false);
|
||||
// And one that reports more admits what the estimate ruled out: the 27B on a 36 GB Mac.
|
||||
expect(modelFitsBudget(mac(36), catalog, 'qwen3.8:27b', 65536)).toBe(false);
|
||||
expect(modelFitsBudget(mac(36), catalog, 'qwen3.8:27b', 65536, { measuredTotalBytes: 27 * 1024 ** 3 })).toBe(true);
|
||||
// And one that reports more admits what the estimate ruled out: on a 36 GB Mac the GGUF
|
||||
// 35B-A3B at its full window needs 28.8 GB, 1.032 of the 27.9 GB estimate and 0.958 of 28 GiB.
|
||||
const moeGguf = 'qwen3.6:35b-a3b-mtp-q4_k_m';
|
||||
expect(modelFitsBudget(mac(36), catalog, moeGguf, 262144)).toBe(false);
|
||||
expect(modelFitsBudget(mac(36), catalog, moeGguf, 262144, { measuredTotalBytes: 28 * 1024 ** 3 })).toBe(true);
|
||||
// The reserve still bounds it: 100 GiB "measured" on a 48 GB Mac is 40 GiB (42.9 GB) of
|
||||
// budget, so the Q8 27B with its 42.8 GB stays out (0.997) although 100 GiB would hold it.
|
||||
// budget, so the 122B with its 97.4 GB stays out although 100 GiB would hold it.
|
||||
const huge = { measuredTotalBytes: 100 * 1024 ** 3 };
|
||||
expect(modelFitsBudget(mac(48), catalog, 'qwen3.8:27b-q8_0', 131072, huge)).toBe(false);
|
||||
expect(modelFitsBudget(mac(128), catalog, 'qwen3.8:27b-q8_0', 131072, huge)).toBe(true);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'ornith-1.5:122b', 131072, huge)).toBe(false);
|
||||
expect(modelFitsBudget(mac(128), catalog, 'ornith-1.5:122b', 131072, huge)).toBe(true);
|
||||
});
|
||||
|
||||
it('agrees with the recommendation under a measured budget too', () => {
|
||||
@@ -130,7 +132,7 @@ describe('modelFitsBudget', () => {
|
||||
|
||||
it('is false for a context that is no token count', () => {
|
||||
for (const context of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(modelFitsBudget(mac(128), catalog, 'qwen3.5:9b', context), String(context)).toBe(false);
|
||||
expect(modelFitsBudget(mac(128), catalog, 'qwen3.5:9b-q4_k_m', context), String(context)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../src/local-model/harness-facts.js';
|
||||
import { stockInstallCommand } from '../src/cli-install-runner.js';
|
||||
import { getCli, registryFilePath, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
|
||||
import { getLocalModelCatalog } from '../src/config/local-model-catalog.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
import type { LocalModelCatalog } from '../src/types.js';
|
||||
|
||||
@@ -106,6 +107,15 @@ describe('cliAcceptsCustomModelId (the launch path’s model id test, on the sto
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts every model id of the stock catalogue for every harness the catalogue offers', () => {
|
||||
const catalog = getLocalModelCatalog();
|
||||
for (const harness of catalog.harnesses.filter((row) => !row.gated)) {
|
||||
for (const model of catalog.models) {
|
||||
expect(cliAcceptsCustomModelId(stockEntry(harness.cli), model.id), `${harness.cli} ${model.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a colon for a CLI whose `--model` pattern has none, and accepts the same CLI without it', () => {
|
||||
const omp = stockEntry('omp');
|
||||
expect(cliAcceptsCustomModelId(omp, 'qwen3.5:9b')).toBe(false);
|
||||
|
||||
+446
-284
File diff suppressed because it is too large.
Load diff
@@ -38,8 +38,11 @@ import {
|
||||
parseInferenceComputeTotal,
|
||||
removeLocalModelFiles,
|
||||
removePartialModelBlobs,
|
||||
RUNTIME_CTX_CHECKPOINTS,
|
||||
RUNTIME_DOWNLOAD_RETRIES,
|
||||
RUNTIME_HOST_PROMPT_CACHE_MIB,
|
||||
RUNTIME_KEEP_ALIVE,
|
||||
RUNTIME_KV_CACHE_TYPE,
|
||||
RUNTIME_MAX_RESTARTS,
|
||||
RUNTIME_PORT_FIRST,
|
||||
RUNTIME_PORT_SPAN,
|
||||
@@ -260,6 +263,7 @@ describe('runtimeEnv', () => {
|
||||
OLLAMA_REMOTES: 'evil.example',
|
||||
OLLAMA_AUTH: '1',
|
||||
LLAMA_ARG_CACHE_RAM: '0',
|
||||
LLAMA_ARG_CTX_CHECKPOINTS: '64',
|
||||
// Other secrets a server environment carries.
|
||||
ANTHROPIC_API_KEY: 'sk-ant-secret',
|
||||
OPENAI_API_KEY: 'sk-secret',
|
||||
@@ -273,7 +277,7 @@ describe('runtimeEnv', () => {
|
||||
DYLD_INSERT_LIBRARIES: '/tmp/evil.dylib',
|
||||
};
|
||||
|
||||
it('holds exactly the allowlist: what is copied, the private HOME, and seven settings', () => {
|
||||
it('holds exactly the allowlist: what is copied, the private HOME, and eleven settings', () => {
|
||||
expect(runtimeEnv(input(SERVER_ENV))).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin:/bin',
|
||||
TMPDIR: '/var/folders/xy/T/',
|
||||
@@ -292,9 +296,33 @@ describe('runtimeEnv', () => {
|
||||
OLLAMA_KEEP_ALIVE: '10m',
|
||||
OLLAMA_NUM_PARALLEL: '1',
|
||||
OLLAMA_MAX_LOADED_MODELS: '1',
|
||||
// The memory settings the catalogue's llamacpp rows are computed for.
|
||||
OLLAMA_KV_CACHE_TYPE: 'q8_0',
|
||||
OLLAMA_FLASH_ATTENTION: '1',
|
||||
LLAMA_ARG_CACHE_RAM: '1024',
|
||||
LLAMA_ARG_CTX_CHECKPOINTS: '8',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the llama.cpp memory settings from the exported constants, never from the server', () => {
|
||||
expect(RUNTIME_KV_CACHE_TYPE).toBe('q8_0');
|
||||
expect(RUNTIME_HOST_PROMPT_CACHE_MIB).toBe(1024);
|
||||
expect(RUNTIME_CTX_CHECKPOINTS).toBe(8);
|
||||
const env = runtimeEnv(input(SERVER_ENV));
|
||||
expect(env.OLLAMA_KV_CACHE_TYPE).toBe(RUNTIME_KV_CACHE_TYPE);
|
||||
expect(env.LLAMA_ARG_CACHE_RAM).toBe(String(RUNTIME_HOST_PROMPT_CACHE_MIB));
|
||||
expect(env.LLAMA_ARG_CTX_CHECKPOINTS).toBe(String(RUNTIME_CTX_CHECKPOINTS));
|
||||
// The server's own `LLAMA_ARG_CACHE_RAM=0` and `LLAMA_ARG_CTX_CHECKPOINTS=64`, and a user's
|
||||
// q4_0 cache, lose against ours.
|
||||
const env2 = runtimeEnv(input({ ...SERVER_ENV, OLLAMA_KV_CACHE_TYPE: 'q4_0', OLLAMA_FLASH_ATTENTION: '0' }));
|
||||
expect([
|
||||
env2.OLLAMA_KV_CACHE_TYPE,
|
||||
env2.OLLAMA_FLASH_ATTENTION,
|
||||
env2.LLAMA_ARG_CACHE_RAM,
|
||||
env2.LLAMA_ARG_CTX_CHECKPOINTS,
|
||||
]).toEqual(['q8_0', '1', '1024', '8']);
|
||||
});
|
||||
|
||||
it('lets no CODEMAN_ variable through, the password least of all', () => {
|
||||
const env = runtimeEnv(input(SERVER_ENV));
|
||||
expect(Object.keys(env).filter((name) => name.startsWith('CODEMAN_'))).toEqual([]);
|
||||
@@ -309,8 +337,10 @@ describe('runtimeEnv', () => {
|
||||
.sort()
|
||||
).toEqual([
|
||||
'OLLAMA_CONTEXT_LENGTH',
|
||||
'OLLAMA_FLASH_ATTENTION',
|
||||
'OLLAMA_HOST',
|
||||
'OLLAMA_KEEP_ALIVE',
|
||||
'OLLAMA_KV_CACHE_TYPE',
|
||||
'OLLAMA_MAX_LOADED_MODELS',
|
||||
'OLLAMA_MODELS',
|
||||
'OLLAMA_NO_CLOUD',
|
||||
@@ -329,7 +359,8 @@ describe('runtimeEnv', () => {
|
||||
]) {
|
||||
expect(env, name).not.toHaveProperty(name);
|
||||
}
|
||||
expect(env).not.toHaveProperty('LLAMA_ARG_CACHE_RAM');
|
||||
// The server's `LLAMA_ARG_CACHE_RAM=0` is not copied: the value is ours.
|
||||
expect(env.LLAMA_ARG_CACHE_RAM).toBe('1024');
|
||||
});
|
||||
|
||||
it('carries no secret and no value of the server that is not on the list', () => {
|
||||
@@ -365,9 +396,13 @@ describe('runtimeEnv', () => {
|
||||
it('leaves out what the server does not have, and sets the rest all the same', () => {
|
||||
expect(Object.keys(runtimeEnv(input({}))).sort()).toEqual([
|
||||
'HOME',
|
||||
'LLAMA_ARG_CACHE_RAM',
|
||||
'LLAMA_ARG_CTX_CHECKPOINTS',
|
||||
'OLLAMA_CONTEXT_LENGTH',
|
||||
'OLLAMA_FLASH_ATTENTION',
|
||||
'OLLAMA_HOST',
|
||||
'OLLAMA_KEEP_ALIVE',
|
||||
'OLLAMA_KV_CACHE_TYPE',
|
||||
'OLLAMA_MAX_LOADED_MODELS',
|
||||
'OLLAMA_MODELS',
|
||||
'OLLAMA_NO_CLOUD',
|
||||
|
||||
@@ -206,6 +206,10 @@ describe('start', () => {
|
||||
OLLAMA_KEEP_ALIVE: '10m',
|
||||
OLLAMA_NUM_PARALLEL: '1',
|
||||
OLLAMA_MAX_LOADED_MODELS: '1',
|
||||
OLLAMA_KV_CACHE_TYPE: 'q8_0',
|
||||
OLLAMA_FLASH_ATTENTION: '1',
|
||||
LLAMA_ARG_CACHE_RAM: '1024',
|
||||
LLAMA_ARG_CTX_CHECKPOINTS: '8',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -128,10 +128,10 @@ export function createMockRouteContext(options?: {
|
||||
getModelConfig: vi.fn(async () => null),
|
||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
||||
// Default OFF mirrors the shipped setting, so existing tests never touch a
|
||||
// case's .claude/skills. Overridable per test because the create-time
|
||||
// injection call sites are otherwise unreachable from a route test.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
||||
// Default ON mirrors the shipped setting (since 1.50.0), so a route test sees
|
||||
// what a user sees. Writes land in the test's temp working dir, never in a real
|
||||
// repo. Overridable per test, so the OFF branch stays reachable.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? true),
|
||||
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
||||
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
|
||||
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
|
||||
|
||||
@@ -13,9 +13,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-lightspeed-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events from raw text
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -86,7 +93,7 @@ async function createSession(baseUrl: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.data?.session?.id) {
|
||||
@@ -1226,7 +1233,7 @@ describe('Operation Lightspeed', () => {
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
}).then((r) => r.json())
|
||||
)
|
||||
);
|
||||
|
||||
@@ -29,13 +29,16 @@
|
||||
*
|
||||
* Port: ephemeral
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session, rebasePath } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { dataPath } from '../src/config/instance.js';
|
||||
import { createUser } from '../src/user-store.js';
|
||||
import type { SessionDocker, SessionRemote } from '../src/types.js';
|
||||
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
|
||||
import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js';
|
||||
@@ -314,9 +317,12 @@ describe('a pane read reaching the session record', () => {
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
|
||||
// What the browser receives, and what the file routes read: never the confinement root.
|
||||
const state = (
|
||||
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
|
||||
web as unknown as {
|
||||
getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown; outsideWorktree?: unknown };
|
||||
}
|
||||
).getSessionStateWithRespawn(session);
|
||||
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(state.outsideWorktree).toBeUndefined();
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
|
||||
const broadcasts = broadcast.mock.calls.length;
|
||||
@@ -349,6 +355,174 @@ describe('a pane read reaching the session record', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveWorktree outside the session folder (#765 follow-up)', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let sibling: string;
|
||||
|
||||
const git = (cwd: string, ...args: string[]): void => {
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'pane-outside-worktree-')));
|
||||
repo = join(base, 'myapp');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
writeFileSync(join(repo, 'README.md'), 'main');
|
||||
git(repo, 'add', 'README.md');
|
||||
git(repo, 'commit', '-q', '-m', 'init');
|
||||
sibling = join(base, 'myapp-feature');
|
||||
git(repo, 'worktree', 'add', '-q', sibling);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('publishes a sibling worktree of the session repository as outside, and clears it on the way back', async () => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
sessions.set(session.id, session);
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
vi.fn();
|
||||
mux.getPaneExit = () => undefined;
|
||||
let dir = sibling;
|
||||
mux.getPaneCurrentPath = () => dir;
|
||||
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
|
||||
// until it lands.
|
||||
await vi.waitFor(() => {
|
||||
mux.emit('paneExitsUpdated');
|
||||
expect(session.liveWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
// On the wire an outside answer rides `outsideWorktree`; `liveWorktree` stays inside-only.
|
||||
const state = session.toState();
|
||||
expect(state.outsideWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
expect(state.liveWorktree).toBeUndefined();
|
||||
|
||||
dir = repo;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
|
||||
});
|
||||
|
||||
/**
|
||||
* A session in `repo` whose pane sits in the sibling worktree, ticked until the lookup has
|
||||
* answered. Returns the answer, so a test can tell "followed" from "refused".
|
||||
*/
|
||||
const paneInSibling = async (sessionExtra: Record<string, unknown> = {}) => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo, ...sessionExtra });
|
||||
sessions.set(session.id, session);
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
vi.fn();
|
||||
mux.getPaneExit = () => undefined;
|
||||
mux.getPaneCurrentPath = () => sibling;
|
||||
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
||||
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
|
||||
// until a lookup has run.
|
||||
await vi.waitFor(() => {
|
||||
mux.emit('paneExitsUpdated');
|
||||
expect(lookup).toHaveBeenCalled();
|
||||
});
|
||||
// The lookup answers asynchronously; let the answer land before it is read.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return session.liveWorktree;
|
||||
};
|
||||
|
||||
/** Sets env vars for one test and restores them afterwards. */
|
||||
const withEnv = async (env: Record<string, string>, run: () => Promise<void>): Promise<void> => {
|
||||
const before = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
|
||||
Object.assign(process.env, env);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(before)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
it('follows nothing while attachment workspace confinement is on', async () => {
|
||||
await withEnv({ CODEMAN_ATTACHMENT_CONFINE: '1' }, async () => {
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
|
||||
it('follows nothing at a worktree that is itself a sensitive folder such as .ssh', async () => {
|
||||
const keys = join(base, 'keys');
|
||||
mkdirSync(keys);
|
||||
const ssh = join(keys, '.ssh');
|
||||
git(repo, 'worktree', 'add', '-q', '-b', 'keys-branch', ssh);
|
||||
sibling = ssh;
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows nothing at, inside or around an attachment blocked tree', async () => {
|
||||
// Around the worktree: its parent folder is blocked.
|
||||
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: base }, async () => {
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
// Inside the worktree: a blocked tree below its root.
|
||||
mkdirSync(join(sibling, 'private'));
|
||||
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(sibling, 'private') }, async () => {
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
// A blocked tree elsewhere changes nothing.
|
||||
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(base, 'elsewhere') }, async () => {
|
||||
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('follows nothing in a Docker case workspace, around or inside it', async () => {
|
||||
const casesFile = dataPath('docker-cases.json');
|
||||
mkdirSync(dirname(casesFile), { recursive: true });
|
||||
try {
|
||||
for (const hostWorkspacePath of [sibling, base, join(sibling, 'src')]) {
|
||||
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath }]));
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
}
|
||||
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath: join(base, 'elsewhere') }]));
|
||||
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
} finally {
|
||||
rmSync(casesFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('follows nothing while docker-cases.json cannot be parsed', async () => {
|
||||
const casesFile = dataPath('docker-cases.json');
|
||||
mkdirSync(dirname(casesFile), { recursive: true });
|
||||
try {
|
||||
// A read that lands while the writer is rewriting the file.
|
||||
writeFileSync(casesFile, '[{"hostWorkspacePath": "/hom');
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
writeFileSync(casesFile, '[]');
|
||||
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
} finally {
|
||||
rmSync(casesFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the owner folder rule in multi-user mode', async () => {
|
||||
const spaces = join(base, 'spaces');
|
||||
mkdirSync(spaces);
|
||||
await createUser({ username: 'pane-admin', role: 'admin', password: 'correct horse battery' });
|
||||
await createUser({ username: 'pane-user', role: 'user', password: 'correct horse battery' });
|
||||
await withEnv({ CODEMAN_MULTIUSER: '1', CODEMAN_USER_SPACES_DIR: spaces }, async () => {
|
||||
// A regular user's space does not hold the sibling, and neither does the session folder.
|
||||
expect(await paneInSibling({ owner: 'pane-user' })).toBeUndefined();
|
||||
// An admin may follow any worktree the repository lists.
|
||||
expect(await paneInSibling({ owner: 'pane-admin' })).toEqual({
|
||||
name: 'myapp-feature',
|
||||
path: sibling,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
|
||||
const { mux, sessions } = build();
|
||||
const session = localMuxSession();
|
||||
@@ -552,10 +726,14 @@ describe('confinement is unchanged by the live cwd', () => {
|
||||
__proto: {
|
||||
_resolveLinkedFilePath: (p: string, id: string) => string;
|
||||
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
||||
_isOutsideWorktreePath: (p: string, id: string) => boolean;
|
||||
};
|
||||
}
|
||||
).__proto;
|
||||
const app = { sessions: new Map([['s1', session]]) };
|
||||
const app = {
|
||||
sessions: new Map([['s1', session]]),
|
||||
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
|
||||
};
|
||||
return {
|
||||
target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'),
|
||||
external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'),
|
||||
@@ -600,10 +778,14 @@ describe('a workspace reached through a symlink', () => {
|
||||
__proto: {
|
||||
_resolveLinkedFilePath: (p: string, id: string) => string;
|
||||
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
||||
_isOutsideWorktreePath: (p: string, id: string) => boolean;
|
||||
};
|
||||
}
|
||||
).__proto;
|
||||
const app = { sessions: new Map([['s1', session]]) };
|
||||
const app = {
|
||||
sessions: new Map([['s1', session]]),
|
||||
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
|
||||
};
|
||||
return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1');
|
||||
};
|
||||
|
||||
|
||||
+60
-32
@@ -174,10 +174,13 @@ describe('Session Management', () => {
|
||||
|
||||
describe('POST /api/sessions', () => {
|
||||
it('should create a new session', async () => {
|
||||
// Under the test HOME, never the machine's real `/tmp`: a create writes the workspace
|
||||
// hooks and the agent skill (on by default) into its working dir.
|
||||
const workingDir = mkdtempSync(join(TEST_HOME, 'work-'));
|
||||
const response = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -185,7 +188,7 @@ describe('Session Management', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.session).toBeDefined();
|
||||
expect(data.data.session.id).toBeDefined();
|
||||
expect(data.data.session.workingDir).toBe('/tmp');
|
||||
expect(data.data.session.workingDir).toBe(workingDir);
|
||||
expect(data.data.session.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -353,49 +356,63 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not inject by default, accepts the setting via PUT, then injects on quick-start', async () => {
|
||||
// 1. Default OFF: a claude quick-start creates the case without the skill.
|
||||
const offCase = 'test-skill-off-' + Date.now();
|
||||
createdCases.push(offCase);
|
||||
const offResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
// The tests below run in order against one server and one settings.json: absent key
|
||||
// (on), a partial PUT that omits it (still on), then an explicit false (off).
|
||||
const skillDirOf = (caseName: string) => join(CASES_DIR, caseName, '.claude', 'skills', 'codeman');
|
||||
|
||||
async function quickStart(prefix: string, mode?: string): Promise<string> {
|
||||
const caseName = `${prefix}-${Date.now()}`;
|
||||
createdCases.push(caseName);
|
||||
const response = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: offCase }),
|
||||
body: JSON.stringify(mode ? { caseName, mode } : { caseName }),
|
||||
});
|
||||
const offData = await offResponse.json();
|
||||
expect(offData.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, offCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
expect((await response.json()).success).toBe(true);
|
||||
return caseName;
|
||||
}
|
||||
|
||||
// 2. The `.strict()` settings schema accepts the new synced key.
|
||||
const putResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
async function putSettings(body: Record<string, unknown>): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: true }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const putData = await putResponse.json();
|
||||
expect(putData.success).toBe(true);
|
||||
|
||||
// 3. The server's settings read is cached ~2s; outwait it so the create sees the toggle.
|
||||
expect((await response.json()).success).toBe(true);
|
||||
// The server's settings read is cached ~2s; outwait it so the next create sees the change.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
}
|
||||
|
||||
// 4. Quick-start now injects the marker-carrying skill into the new case.
|
||||
const onCase = 'test-skill-on-' + Date.now();
|
||||
createdCases.push(onCase);
|
||||
const onResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: onCase }),
|
||||
});
|
||||
const onData = await onResponse.json();
|
||||
expect(onData.success).toBe(true);
|
||||
/** What settings.json holds, read back through GET /api/settings (enveloped or not). */
|
||||
async function storedSettings(): Promise<Record<string, unknown>> {
|
||||
const body = (await (await fetch(`${baseUrl}/api/settings`)).json()) as Record<string, unknown>;
|
||||
const settings = (body.success === true ? body.data : body) as Record<string, unknown>;
|
||||
// Not vacuous: every quick-start above records the case it used.
|
||||
expect(settings).toHaveProperty('lastUsedCase');
|
||||
return settings;
|
||||
}
|
||||
|
||||
it('injects by default: a settings.json without the key means on', async () => {
|
||||
const caseName = await quickStart('test-skill-default');
|
||||
// A quick-start writes lastUsedCase, never this key.
|
||||
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
|
||||
|
||||
const skillDir = join(CASES_DIR, onCase, '.claude', 'skills', 'codeman');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const skillMd = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
const skillMd = readFileSync(join(skillDirOf(caseName), 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain('<!-- codeman-managed-agent-skill');
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDir, 'reference', 'recipes.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDirOf(caseName), 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDirOf(caseName), 'reference', 'recipes.md'))).toBe(true);
|
||||
// The copy ignores itself in git, so the default leaves a repository's status clean.
|
||||
expect(readFileSync(join(skillDirOf(caseName), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
}, 30000);
|
||||
|
||||
it('a partial settings PUT that omits the key keeps it on', async () => {
|
||||
await putSettings({ autoNameSessions: false });
|
||||
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
|
||||
|
||||
const caseName = await quickStart('test-skill-partial');
|
||||
expect(existsSync(join(skillDirOf(caseName), 'SKILL.md'))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('does not inject for shell-mode quick-start even when enabled', async () => {
|
||||
@@ -410,4 +427,15 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, shellCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
});
|
||||
|
||||
it('an explicit false turns it off, and a later partial PUT leaves it off', async () => {
|
||||
// The `.strict()` settings schema accepts the synced key.
|
||||
await putSettings({ agentSkillEnabled: false });
|
||||
await putSettings({ autoNameSessions: true });
|
||||
expect((await storedSettings()).agentSkillEnabled).toBe(false);
|
||||
|
||||
const caseName = await quickStart('test-skill-off');
|
||||
expect(existsSync(join(CASES_DIR, caseName))).toBe(true);
|
||||
expect(existsSync(skillDirOf(caseName))).toBe(false);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -81,6 +81,21 @@ describe('Files panel listing of a live worktree', () => {
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('lists an inside worktree for root=live-any, the root the Files panel sends', async () => {
|
||||
const data = await list('&root=live-any');
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('walks the session folder for root=live-any when a symlink replaced the worktree', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
symlinkSync(makeOutsideFolder(), worktree);
|
||||
expectSessionFolderListing(await list('&root=live-any&showHidden=true'));
|
||||
});
|
||||
|
||||
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
|
||||
const data = await list('&root=live&showHidden=false');
|
||||
expect(data.totalFiles).toBe(1);
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @fileoverview The file routes for a worktree OUTSIDE the session's folder (#765 follow-up).
|
||||
*
|
||||
* A session's outside worktree is a second root: `GET .../files?root=live-any` lists it with
|
||||
* ABSOLUTE paths (`root=live` keeps walking `workingDir`), and the read, raw, edit and download
|
||||
* routes confine an absolute path inside it to it. What must not change: a relative path still resolves against `workingDir` only, a path that
|
||||
* escapes the worktree through a symlink is refused, and a worktree folder replaced by a symlink is
|
||||
* neither listed nor served. Real filesystem on purpose, since realpath is the thing under test.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
|
||||
// The converter would shell out to pdftoppm/LibreOffice; the route's own path check is under test.
|
||||
vi.mock('../../src/document-thumbnailer.js', () => ({
|
||||
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
|
||||
}));
|
||||
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
|
||||
|
||||
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
describe('file routes for a worktree outside the session folder', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let sibling: string;
|
||||
let secret: string;
|
||||
|
||||
const sessionUrl = (rest: string) => `/api/sessions/${harness.ctx._sessionId}/${rest}`;
|
||||
const list = async (query = '') => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: sessionUrl(`files?depth=5${query}`) });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data;
|
||||
};
|
||||
const readContent = (path: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`file-content?path=${encodeURIComponent(path)}`) });
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-route-')));
|
||||
repo = join(base, 'myapp');
|
||||
sibling = join(base, 'myapp-feature');
|
||||
secret = join(base, 'secret');
|
||||
mkdirSync(join(repo, 'src'), { recursive: true });
|
||||
mkdirSync(join(sibling, 'src'), { recursive: true });
|
||||
mkdirSync(secret);
|
||||
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
|
||||
writeFileSync(join(sibling, 'src', 'feature-only.ts'), 'feature');
|
||||
writeFileSync(join(secret, 'key.txt'), 'secret');
|
||||
Object.assign(harness.ctx._session, {
|
||||
workingDir: repo,
|
||||
liveWorktree: { name: 'myapp-feature', path: sibling, outside: true },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
});
|
||||
|
||||
describe('GET .../files', () => {
|
||||
it('lists the worktree with absolute paths', async () => {
|
||||
const data = await list('&root=live-any');
|
||||
expect(data.root).toBe(sibling);
|
||||
expect(data.worktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
expect(data.tree[0].path).toBe(join(sibling, 'src'));
|
||||
expect(data.tree[0].children[0].path).toBe(join(sibling, 'src', 'feature-only.ts'));
|
||||
});
|
||||
|
||||
it('searches the worktree, matching on paths relative to it and answering absolute ones', async () => {
|
||||
const data = await list(`&root=live-any&q=${encodeURIComponent('src/feature*')}`);
|
||||
expect(data.matches.map((m: { path: string }) => m.path)).toEqual([join(sibling, 'src', 'feature-only.ts')]);
|
||||
});
|
||||
|
||||
it('keeps walking the session folder, with relative paths, for root=live', async () => {
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.worktree).toBeUndefined();
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
expect(JSON.stringify(data.tree)).not.toContain('feature-only.ts');
|
||||
});
|
||||
|
||||
it('keeps the session folder, with relative paths, without the parameter', async () => {
|
||||
const data = await list();
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.worktree).toBeUndefined();
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
});
|
||||
|
||||
it('falls back to the session folder once a symlink has taken the worktree folder’s place', async () => {
|
||||
renameSync(sibling, `${sibling}-moved`);
|
||||
symlinkSync(secret, sibling);
|
||||
const data = await list('&root=live-any');
|
||||
expect(data.root).toBe(repo);
|
||||
expect(JSON.stringify(data.tree)).not.toContain('key.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET .../file-content', () => {
|
||||
it('serves an absolute path inside the worktree', async () => {
|
||||
const res = await readContent(join(sibling, 'src', 'feature-only.ts'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.stringify(res.json())).toContain('feature');
|
||||
});
|
||||
|
||||
it('refuses a sensitive file in the worktree on file-content and file-raw, and serves its neighbours', async () => {
|
||||
writeFileSync(join(sibling, '.env'), 'TOKEN=abc');
|
||||
mkdirSync(join(sibling, '.aws'));
|
||||
writeFileSync(join(sibling, '.aws', 'credentials'), 'aws_secret=abc');
|
||||
writeFileSync(join(sibling, 'plain.ts'), 'plain');
|
||||
for (const blocked of [join(sibling, '.env'), join(sibling, '.aws', 'credentials')]) {
|
||||
const content = await readContent(blocked);
|
||||
expect(content.json().success).toBe(false);
|
||||
expect(content.body).not.toContain('abc');
|
||||
const raw = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: sessionUrl(`file-raw?path=${encodeURIComponent(blocked)}`),
|
||||
});
|
||||
expect(raw.statusCode).not.toBe(200);
|
||||
expect(raw.body).not.toContain('abc');
|
||||
}
|
||||
const plain = await readContent(join(sibling, 'plain.ts'));
|
||||
expect(plain.json().success).toBe(true);
|
||||
const plainRaw = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: sessionUrl(`file-raw?path=${encodeURIComponent(join(sibling, 'plain.ts'))}`),
|
||||
});
|
||||
expect(plainRaw.statusCode).toBe(200);
|
||||
expect(plainRaw.body).toBe('plain');
|
||||
});
|
||||
|
||||
it('still resolves a relative path against the session folder only', async () => {
|
||||
expect((await readContent('src/main-only.ts')).statusCode).toBe(200);
|
||||
const escaped = await readContent('../myapp-feature/src/feature-only.ts');
|
||||
expect(JSON.stringify(escaped.json())).not.toContain('"feature"');
|
||||
expect(escaped.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a symlink in the worktree that leads out of it', async () => {
|
||||
symlinkSync(join(secret, 'key.txt'), join(sibling, 'leak.txt'));
|
||||
const res = await readContent(join(sibling, 'leak.txt'));
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(JSON.stringify(res.json())).not.toContain('"secret"');
|
||||
});
|
||||
|
||||
it('serves a symlink in the worktree that leads into the session folder', async () => {
|
||||
symlinkSync(join(repo, 'src', 'main-only.ts'), join(sibling, 'main-link.ts'));
|
||||
const res = await readContent(join(sibling, 'main-link.ts'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
});
|
||||
|
||||
it('serves nothing from the folder once a symlink has taken its place', async () => {
|
||||
renameSync(sibling, `${sibling}-moved`);
|
||||
symlinkSync(secret, sibling);
|
||||
const res = await readContent(join(sibling, 'key.txt'));
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('serves nothing outside the session folder once the worktree is not outside', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: { name: 'myapp-feature', path: sibling } });
|
||||
const res = await readContent(join(sibling, 'src', 'feature-only.ts'));
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-raw, download and edit', () => {
|
||||
it('streams an absolute path inside the worktree from file-raw and download', async () => {
|
||||
const target = join(sibling, 'src', 'feature-only.ts');
|
||||
const raw = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: sessionUrl(`file-raw?path=${encodeURIComponent(target)}`),
|
||||
});
|
||||
expect(raw.statusCode).toBe(200);
|
||||
expect(raw.body).toBe('feature');
|
||||
const download = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(target)}`,
|
||||
});
|
||||
expect(download.statusCode).toBe(200);
|
||||
expect(download.body).toBe('feature');
|
||||
});
|
||||
|
||||
it('edits a file inside the worktree in place', async () => {
|
||||
const target = join(sibling, 'notes.md');
|
||||
writeFileSync(target, 'before\n');
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: target, content: 'after\n', baseHash: sha256('before\n') },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readFileSync(target, 'utf8')).toBe('after\n');
|
||||
});
|
||||
|
||||
it('refuses to edit through a symlink that leads out of the worktree', async () => {
|
||||
symlinkSync(join(secret, 'key.txt'), join(sibling, 'leak.md'));
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: join(sibling, 'leak.md'), content: 'pwned', baseHash: sha256('secret') },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(secret, 'key.txt'), 'utf8')).toBe('secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-preview and file-thumbnail', () => {
|
||||
const preview = (path: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`file-preview?path=${encodeURIComponent(path)}`) });
|
||||
const thumbnail = (path: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`file-thumbnail?path=${encodeURIComponent(path)}`) });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(generateFirstPageThumbnail).mockClear();
|
||||
writeFileSync(join(sibling, 'doc.pdf'), '%PDF-1.4 fake');
|
||||
writeFileSync(join(sibling, 'pic.png'), 'png');
|
||||
});
|
||||
|
||||
it('answers file-preview for an absolute path inside the worktree', async () => {
|
||||
const res = await preview(join(sibling, 'doc.pdf'));
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/file-raw?path=');
|
||||
});
|
||||
|
||||
it('serves file-thumbnail for an absolute path inside the worktree', async () => {
|
||||
const res = await thumbnail(join(sibling, 'pic.png'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
expect(generateFirstPageThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses both once a symlink has taken the worktree folder’s place', async () => {
|
||||
writeFileSync(join(secret, 'doc.pdf'), '%PDF-1.4 fake');
|
||||
writeFileSync(join(secret, 'pic.png'), 'png');
|
||||
renameSync(sibling, `${sibling}-moved`);
|
||||
symlinkSync(secret, sibling);
|
||||
expect((await preview(join(sibling, 'doc.pdf'))).statusCode).toBe(404);
|
||||
expect((await thumbnail(join(sibling, 'pic.png'))).statusCode).toBe(404);
|
||||
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('the attachment guard, judged again on every request', () => {
|
||||
const target = () => join(sibling, 'src', 'feature-only.ts');
|
||||
const writeSettings = (settings: Record<string, unknown>) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
|
||||
};
|
||||
const get = (route: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`${route}?path=${encodeURIComponent(target())}`) });
|
||||
|
||||
it('serves the worktree while no guard rule applies', async () => {
|
||||
writeSettings({});
|
||||
expect((await get('file-raw')).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
|
||||
['the worktree’s parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
|
||||
])('stops serving it once %s', async (_label, settings) => {
|
||||
writeSettings(settings());
|
||||
|
||||
const content = await get('file-content');
|
||||
expect(content.json().success).toBe(false);
|
||||
expect(content.body).not.toContain('feature');
|
||||
expect((await get('file-raw')).statusCode).toBe(404);
|
||||
|
||||
const before = readFileSync(target(), 'utf8');
|
||||
const put = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: target(), content: 'changed', baseHash: sha256(before) },
|
||||
});
|
||||
expect(put.statusCode).toBe(404);
|
||||
expect(readFileSync(target(), 'utf8')).toBe(before);
|
||||
|
||||
const download = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(target())}`,
|
||||
});
|
||||
expect(download.statusCode).toBe(404);
|
||||
|
||||
// The listing walks the session folder, and keeps reporting the worktree so the panel settles.
|
||||
const data = await list('&root=live-any');
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
expect(data.worktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,9 @@ describe('html-view routes', () => {
|
||||
writeFileSync(join(root, 'outside.txt'), 'outside');
|
||||
symlinkSync(join(root, 'outside.txt'), join(workspace, 'escape.txt'));
|
||||
|
||||
mkdirSync(join(root, 'sibling-feature'));
|
||||
writeFileSync(join(root, 'sibling-feature', 'report.html'), '<p>feature</p>');
|
||||
|
||||
harness = await createRouteTestHarness(registerHtmlViewRoutes);
|
||||
sid = harness.ctx._sessionId as string;
|
||||
(harness.ctx.sessions.get(sid) as { workingDir: string }).workingDir = workspace;
|
||||
@@ -270,6 +273,64 @@ describe('html-view routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('in a worktree outside the workspace (#765 follow-up)', () => {
|
||||
let sibling: string;
|
||||
const saved = {
|
||||
confine: process.env.CODEMAN_ATTACHMENT_CONFINE,
|
||||
blocked: process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sibling = realpathSync(join(root, 'sibling-feature'));
|
||||
Object.assign(harness.ctx.sessions.get(sid) as object, {
|
||||
liveWorktree: { name: 'sibling-feature', path: sibling, outside: true },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of [
|
||||
['CODEMAN_ATTACHMENT_CONFINE', saved.confine],
|
||||
['CODEMAN_ATTACHMENT_BLOCKED_PATHS', saved.blocked],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a page from the worktree, minted as outside the workspace', async () => {
|
||||
const res = await open(join(sibling, 'report.html'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
const url = JSON.parse(res.body).data.url as string;
|
||||
const page = await harness.app.inject({ method: 'GET', url });
|
||||
expect(page.statusCode).toBe(200);
|
||||
expect(page.body).toBe('<p>feature</p>');
|
||||
// Minted as outside the workspace, so the serve route's per-request confinement check covers it.
|
||||
expect(htmlViewCapabilities.resolve(capabilityFromHtmlViewPath(url) as string)?.outsideWorkspace).toBe(true);
|
||||
});
|
||||
|
||||
it('answers to the attachment guard: confinement on, or a blocked tree', async () => {
|
||||
const res = await open(join(sibling, 'report.html'));
|
||||
const url = JSON.parse(res.body).data.url as string;
|
||||
|
||||
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||
expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403);
|
||||
// A page minted before confinement was switched on stops serving too.
|
||||
expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(404);
|
||||
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
|
||||
|
||||
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = sibling;
|
||||
expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('leaves a path outside both roots as it was', async () => {
|
||||
mkdirSync(join(root, 'reports'));
|
||||
writeFileSync(join(root, 'reports', 'report.html'), '<p>report</p>');
|
||||
expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(200);
|
||||
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||
expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an HTML file inside a hidden directory, in or out of the workspace', async () => {
|
||||
mkdirSync(join(workspace, '.cache'));
|
||||
writeFileSync(join(workspace, '.cache', 'r.html'), '<p>r</p>');
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* @fileoverview Agent-skill injection on POST /api/sessions (docs/agent-control-plan.md §2).
|
||||
*
|
||||
* The quick-start half of this is covered end-to-end in test/quick-start.test.ts;
|
||||
* the plain create path had NO coverage, because the shared mock context hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it. This exercises the real
|
||||
* the plain create path had NO coverage, because the shared mock context once hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it (it now defaults to true,
|
||||
* the shipped default since 1.50.0, and takes an override). This exercises the real
|
||||
* `applyAgentSkill` against a temp working dir, so it asserts bytes on disk rather
|
||||
* than a spy call.
|
||||
*
|
||||
@@ -81,6 +82,27 @@ describe('POST /api/sessions agent-skill injection', () => {
|
||||
// remove what this create wrote.
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(true);
|
||||
// The copy ignores itself, so the default leaves the user's `git status` clean.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('injects with the shared mock context left at its default (the shipped default is ON)', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
harness = { app, ctx };
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-default', mode: 'claude', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the working dir untouched when the gate is OFF', async () => {
|
||||
|
||||
+12
-6
@@ -1,15 +1,20 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('Interactive Session Lifecycle', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let customDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// The 'custom working directory' test creates a session in /tmp/test; workingDir
|
||||
// validation requires the dir to exist, so ensure it does (idempotent, CI-safe).
|
||||
mkdirSync('/tmp/test', { recursive: true });
|
||||
// The 'custom working directory' test creates a session in a folder of its own
|
||||
// (workingDir validation requires it to exist), never a fixed path in the machine's
|
||||
// real `/tmp`: a create writes the workspace hooks and the agent skill (on by
|
||||
// default) into its working dir, and those would outlive the run there.
|
||||
customDir = mkdtempSync(join(tmpdir(), 'codeman-session-test-'));
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
@@ -17,6 +22,7 @@ describe('Interactive Session Lifecycle', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
rmSync(customDir, { recursive: true, force: true });
|
||||
}, 60000); // Extended timeout for cleanup
|
||||
|
||||
describe('Session Creation', () => {
|
||||
@@ -39,13 +45,13 @@ describe('Interactive Session Lifecycle', () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp/test' }),
|
||||
body: JSON.stringify({ workingDir: customDir }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.session.workingDir).toBe('/tmp/test');
|
||||
expect(data.data.session.workingDir).toBe(customDir);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-4
@@ -2,9 +2,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-events-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -130,7 +137,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
// Wait a bit for the event
|
||||
@@ -168,7 +175,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`);
|
||||
@@ -221,7 +228,7 @@ describe('SSE Event Types', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
|
||||
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user