mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-08 16:29:33 +01:00
Compare commits
118
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 | ||
|
|
ac59cfa9ca | ||
|
|
89d18eb9a2 | ||
|
|
b497748e31 | ||
|
|
34a7812008 | ||
|
|
0c63f76d31 | ||
|
|
ea23445ad7 | ||
|
|
5c61fc1ea8 | ||
|
|
028bd87494 | ||
|
|
2e61ac9534 | ||
|
|
e19b2c3a54 | ||
|
|
31ce0c88cd | ||
|
|
e8a63ede00 | ||
|
|
2522ac1727 | ||
|
|
2f9cc7aabc | ||
|
|
10db8a4bd9 | ||
|
|
e6c79c8e8c | ||
|
|
98c4a3d386 | ||
|
|
4b349b57f6 | ||
|
|
a7343fe087 | ||
|
|
49e1dda23b | ||
|
|
a192fb7253 | ||
|
|
d5273347c3 | ||
|
|
94912241bc | ||
|
|
1e262b04d1 | ||
|
|
7edf1a1c27 | ||
|
|
324aa75896 | ||
|
|
cd4cee15a7 | ||
|
|
59af305e84 | ||
|
|
be8fc2980a | ||
|
|
aa76b1b304 | ||
|
|
6b1664abae | ||
|
|
eb91279a3b | ||
|
|
04f232f239 | ||
|
|
0a8ab84ea1 | ||
|
|
77d29d4621 | ||
|
|
3c4bb91d6f | ||
|
|
fa978008d2 | ||
|
|
6319fffce1 | ||
|
|
30ffdaeecd | ||
|
|
7238ced571 | ||
|
|
84d5c691ff | ||
|
|
3af94df4f6 | ||
|
|
a864acc52c | ||
|
|
be9279de35 | ||
|
|
2af413aa9b | ||
|
|
492df776d0 | ||
|
|
56b85b707b | ||
|
|
e2359aa0e8 | ||
|
|
8d33f53fd0 | ||
|
|
84c1017487 | ||
|
|
771072c36b | ||
|
|
c9b79097b0 | ||
|
|
66ca92f7cf | ||
|
|
8281045cf2 | ||
|
|
91cea50d9c | ||
|
|
4739542397 | ||
|
|
58e20cf613 | ||
|
|
1976431ce6 | ||
|
|
34e95ceb62 | ||
|
|
92c20fe572 | ||
|
|
73ce6a1819 | ||
|
|
d6226385c0 | ||
|
|
84d5c8de52 | ||
|
|
1157aa9ecd | ||
|
|
da2f12fdbd | ||
|
|
dd07126dd8 | ||
|
|
5cdd34bddf | ||
|
|
c04955ca68 | ||
|
|
92797c2619 | ||
|
|
a42ce9c79f | ||
|
|
116db3ba66 | ||
|
|
01d3379b74 | ||
|
|
b51632e54b | ||
|
|
ed74ae41bc | ||
|
|
8ba5134174 |
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"
|
||||
|
||||
@@ -69,6 +69,7 @@ shared-host, multi-user, or tunneled deployments.
|
||||
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
|
||||
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
|
||||
|
||||
- **The local model runtime listens on a loopback port with no login.** The model server the local model install starts (macOS only, opt-in by a click) accepts requests from any process of any account on the Mac, and from any page served from `localhost`, like every model server of its kind. Codeman limits what that buys (one model in the endpoint record, a digest check before an agent is started on it) but does not authenticate the port. See `docs/security-architecture.md` §10d.
|
||||
- **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account.
|
||||
|
||||
Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its
|
||||
|
||||
@@ -80,6 +80,35 @@ jobs:
|
||||
CLI_DETECT_DONE=""; detect_all_clis
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
|
||||
# --ai-cli answers the same menu with no terminal: `none` carries on, and a
|
||||
# name runs that one entry (bash 3.2 cannot expand an empty offer list under
|
||||
# set -u, which is the guard the loop over it has to get right).
|
||||
has_tty() { return 1; }
|
||||
AI_CLI_CHOICE=none
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
AI_CLI_CHOICE=codex
|
||||
k=0; while [[ $k -lt ${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
|
||||
CLI_INSTALL_CMD_TRUSTED[$k]=""
|
||||
k=$((k + 1))
|
||||
done
|
||||
offer_ai_cli_install >/dev/null 2>&1
|
||||
echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer"
|
||||
# What the desktop app runs: preflight on stdout, and only JSON there. These
|
||||
# child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the
|
||||
# sourcing guard and print nothing. Each check ends in `|| exit 1`, because
|
||||
# bash 3.2 does not let a false [[ ]] trip set -e.
|
||||
out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin)
|
||||
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; }
|
||||
json_string "a\"b\\c" >/tmp/json-out
|
||||
[[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; }
|
||||
echo "bash $BASH_VERSION: preflight prints JSON"
|
||||
# How the desktop app adds an agent: a real run of the subcommand, which stops
|
||||
# on the missing npm before it could install anything.
|
||||
if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then
|
||||
echo "agent ran with no npm to run"; exit 1
|
||||
fi
|
||||
[[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; }
|
||||
echo "bash $BASH_VERSION: agent stops without saying done"
|
||||
'
|
||||
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
|
||||
# array, and on stock macOS there is no `timeout`, so the array is empty and the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,6 +104,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
|
||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||
- **Never start `git` without `gitSpawnAllowed()`** (`utils/git-availability.ts`): on a Mac without the developer tools `/usr/bin/git` is Apple's placeholder, and running it, even as `git --version`, opens macOS's "install the command line developer tools" window. The server did that on the first page load after a no-admin install (`isGitAvailable()` from `renderIndexHtml`) and would have again at every start. The gate resolves `git` on the PATH itself and, for the placeholder, proves the tools first with `xcode-select -p`, which never opens a window; a refusal is answered as ENOENT (`gitMissingError()`), which every caller already reads as "git is not installed". `test/git-spawn-gate.test.ts` pins the list of spawn sites, so a new one fails until it is gated. `install.sh` follows the same rule for the same reason.
|
||||
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
|
||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
||||
|
||||
@@ -127,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`
|
||||
|
||||
@@ -187,10 +188,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. 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), 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
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
@@ -230,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).
|
||||
|
||||
@@ -323,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), `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) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three 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.
|
||||
|
||||
@@ -388,6 +393,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
|
||||
|
||||
## Scripts & Tunnel
|
||||
|
||||
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
|
||||
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli <id>|none`, `install.sh preflight`, `install.sh agent <id>` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
|
||||
|
||||
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
@@ -492,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)
|
||||
@@ -792,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.
|
||||
|
||||
@@ -812,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:
|
||||
@@ -865,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>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-2
@@ -792,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`。
|
||||
|
||||
@@ -865,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"
|
||||
},
|
||||
|
||||
@@ -60,6 +60,8 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/fullscreen-tab-switch-capture.browser.test.ts',
|
||||
'test/terminal-touch-swipe-axis.browser.test.ts',
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||
'test/local-model-ui.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+140
-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
|
||||
@@ -1676,6 +1714,98 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
## Local model
|
||||
|
||||
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
|
||||
|
||||
**Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet.
|
||||
|
||||
Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before anything is probed, parsed or started): the GET names the host's hardware, and the verbs download and run a program, install an agent CLI and write the server-wide settings. There is no SSE event: the window polls the GET once a second while it is open.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
|
||||
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. |
|
||||
| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. |
|
||||
| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). |
|
||||
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
|
||||
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. |
|
||||
|
||||
Every verb answers `{ success: true, data: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "The download is 23.3 GB now. Check the sizes and confirm again.",
|
||||
"errorCode": "CONFLICT",
|
||||
"data": {
|
||||
"reason": "size-changed",
|
||||
"message": { "text": "The download is {size} GB now. Check the sizes and confirm again.", "vars": { "size": "23.3" } },
|
||||
"expectedBytes": 23291770627,
|
||||
"expectedGb": "23.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
|
||||
|
||||
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
|
||||
|
||||
**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw.
|
||||
|
||||
A probe that fails is an answer, not an error: a server that is not on macOS, or a Mac whose `sysctl` call failed or timed out, still gets a `200`, and `hardware.source` says what was read.
|
||||
|
||||
`data`:
|
||||
|
||||
- `hardware`: what the server sees of the machine it runs on.
|
||||
- `platform`, `arch`: `process.platform` and `os.arch()` of the server process. `arm64: true` means the machine is Apple silicon whatever `arch` says; `translated: true` means the server process runs under Rosetta.
|
||||
- `memBytes`, `memGb` (the bytes as decimal GB text, `"51.5"` for a 48 GB Mac) and `memClassGb` (the memory size the Mac is sold as: 16, 32, 48; `0` when the size could not be read). `memClassGb` is what to show as the installed memory: it is the number on the box and in About This Mac. It is the one size here that is not decimal GB.
|
||||
- `chip`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb` (`0` means not set), `freeDiskBytes` and `freeDiskGb` (free space on the filesystem of Codeman's data directory). Each is absent when it could not be read.
|
||||
- `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed: memory, architecture, the chip name and the free disk space come from Node, and nothing only `sysctl` knows, such as `arm64`, `translated`, `model`, `macosVersion`, `macosMajor` or `wiredLimitMb`, is present) or `not-probed` (not macOS, nothing was run).
|
||||
- `recommendation`:
|
||||
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so.
|
||||
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
|
||||
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
|
||||
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
||||
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
|
||||
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
|
||||
- `needBytes` and `needGb` (memory the model needs at that context, at 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), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
|
||||
- `installBytes` and `installGb`: the whole download a click on Install starts for this row, `downloadBytes` plus the runtime archive while the runtime is not unpacked. It is the number to send as `acknowledgedBytes`;
|
||||
- `harnesses[]`: every agent CLI the model can be run with at this context, best first: `{ cli, cliLabel, installed, installCommand?, slowFirstTurn? }`. `installCommand` is the literal command the install would run for a CLI that is not installed (the release's own stock command), for the consent screen. The first entry is the one the flat `cli` fields name;
|
||||
- `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`;
|
||||
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
|
||||
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
|
||||
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run.
|
||||
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on).
|
||||
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
|
||||
- `install`: where the install stands and what can be done about it.
|
||||
- `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`.
|
||||
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed.
|
||||
- `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue.
|
||||
- `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated.
|
||||
- `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen.
|
||||
- `running`: the runtime Codeman started is alive right now. A runtime the person stopped is `running: false` with no `error`.
|
||||
- `loaded` (with `installed`): what the runtime reported when the job checked the loaded model: `context`, `memoryBytes`, `memoryGb`, `onGpu`.
|
||||
- `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli`.
|
||||
- `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`.
|
||||
- `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked.
|
||||
- `folder`: the absolute folder everything of this feature is kept in.
|
||||
- `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
|
||||
|
||||
Error codes (`install.error.code`), each with one next step:
|
||||
|
||||
| Next step | Codes |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `retry` | `runtime-download-failed`, `runtime-damaged`, `runtime-unpack-failed`, `runtime-start-failed`, `disk-full`, `pull-failed`, `context-not-applied`, `load-failed`, `probe-unfinished`, `harness-failed`, `endpoint-id-taken`, `connect-failed`, `internal` |
|
||||
| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) |
|
||||
| `other-port` | `port-in-use` |
|
||||
| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` |
|
||||
| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) |
|
||||
|
||||
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one.
|
||||
|
||||
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -694,6 +694,38 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
|
||||
|
||||
⚠️ **The loading banner shows the REAL backend log line, not a guess, and has no countdown or auto-timeout at all.** `getLatestLlamaSwapLogLine()` holds one `GET /api/events` SSE connection open per endpoint (confirmed live to stay open indefinitely — read past 220KB over 8s with no `done`; idle-closed after 30s via `pruneIdleLlamaSwapLogTails`, same 20s sweep as the swap-displacement check above), parsing `logData` frames and keeping only `source: "upstream"` (the real `llama-server` process's own stdout) lines, never `source: "proxy"` (llama-swap's own request-access log). ⚠️ `GET /logs` — the endpoint this feature's own first cut targeted, since the name suggested it — was confirmed live to carry ONLY the proxy log and never a single backend line, even seconds after a real, verified model swap; caught and corrected by a live check before merge, not after. The banner itself dropped its size-scaled expected-time estimate and matching auto-timeout (a guess dressed up as a fact that could kill a genuinely slow load partway through on slower hardware) for a generic hardware/model-size disclaimer plus a user-driven **Cancel** button (`_showCenterStatus`'s `onCancel` option, a real button distinct from the plain "×" close glyph an `'error'`-type banner gets) that ends the wait and closes the session on the user's own call rather than a guessed deadline.
|
||||
|
||||
### Local model install
|
||||
|
||||
**A model that runs on this Mac** (`src/local-model/`, `src/config/local-model-catalog.ts`, routes `src/web/routes/local-model-routes.ts`; EXPERIMENTAL; guide `docs/local-models.md`): the server looks at the Mac it runs on, says which local coding model fits it, and installs one: a pinned runtime it supervises, the model, the checks, the coding agent, and one saved model endpoint. The read side (`hardware.ts`, the catalogue, the pure `recommend()`) is described in the root `CLAUDE.md`; this section is the install.
|
||||
|
||||
**The layout** is one pure function (`localModelPaths()`, `paths.ts`): `<data dir>/local-model/` holds `runtime/ollama-<version>/` (plus its `.tgz.partial` and `.unpack` while it is fetched), `models/` and `home/`; `<data dir>/local-model.json` is the state file. ⚠️ Every path comes from `dataPath()` through that function and NONE is ever read back from the state file (which holds no path), so a state file cannot point a delete anywhere. ⚠️ Nothing is written or deleted outside `local-model/` except the state file and the two writes of the connect step. The runtime's `HOME` is the private `home/` (measured to work), so its key pair lands there and the user's own `~/.ollama` is never created, read or removed; `home/` and a `.ollama/id_ed25519` anywhere are on the sensitive-path list.
|
||||
|
||||
**The pin** (`OLLAMA_RUNTIME_PIN`, `runtime.ts`) is a COMPILED constant: version, URL, size, sha256. ⚠️ No file, setting, env var or request may supply any part of it: a hash that comes from the same writable place as the URL pins nothing. The archive is streamed to `*.partial` while it is hashed, continued with a Range request (3 bounded retries), refused when it grows past the pinned size, and the hash is compared BEFORE `/usr/bin/tar` unpacks anything into a temporary folder that is then renamed.
|
||||
|
||||
**Supervision** (`createRuntimeSupervisor()`): the runtime is a NON-detached child, started at boot once a model is installed and enabled, restarted at most 3 times in a row, SIGTERMed at shutdown beside `stopDeepSeekWeb()`. ⚠️ Its environment comes from the pure `runtimeEnv()` ALLOWLIST (PATH, the private HOME, TMPDIR, LANG, the proxy variables, and exactly `OLLAMA_HOST=127.0.0.1:<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.
|
||||
|
||||
**The job** (`install-job.ts`) is a pure reducer (`reduceInstallJob()`) over three tables in that file (`INSTALL_STATE_TABLE`, `INSTALL_STEP_TABLE`, `INSTALL_TRANSITIONS`), driven by one service per process (`getLocalModelService()`). States: `idle`, `installing`, `paused`, `failed`, `installed`; steps in order: `runtime`, `model`, `verify`, `agent`, `connect` (`agent` is left out when the coding agent is installed). ⚠️ An event the transition table does not list is REFUSED by returning the same object, which the service answers with 409 `wrong-state`; add a transition to the table, never a special case to the service. ⚠️ ONE job at a time: verbs run on one queue, so two installs sent together cannot both find the state free. ⚠️ A job the state file still calls `installing` when the process has none was interrupted by a restart: it becomes `paused` the first time anything looks (`boot()`, a GET or a verb), and NOTHING continues without a click. `shutdown()` therefore writes nothing: it aborts the step and leaves `installing` in the file. ⚠️ An interrupted step is never recorded as failed, whatever its last call answered (an aborted fetch looks like a network error): the job checks its own signal before it writes `failed`. ⚠️ Every step is idempotent, because Resume and "Try again" re-enter the step the job stopped in. ⚠️ Cancel in the `connect` step is honoured only before the first write: once the two writes began they finish, so the pair is never left half done. ⚠️ The state file (`state-store.ts`) has ONE writer, serialized, temp file + rename, mode 0600, and reading is TOTAL (a file that does not parse, another schema, or a state that lacks a member it needs reads as idle): it holds nothing of the user, so it is replaced rather than refused, unlike `settings.json`. ⚠️ Progress is kept in memory and written at most every few seconds and when the job pauses or fails; the window reads the live figure. A 23 GB pull emits about 1.4 million lines, so neither the stream nor one state write per line is affordable.
|
||||
|
||||
**Install is checked before anything is downloaded** (`LocalModelService.install()`): the model, the context and the coding agent must be a row the CURRENT recommendation offers (a model id is looked up, never passed on: without a slash it cannot name another registry host, and the request schema repeats that); the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`: the model plus the runtime archive while the runtime is not unpacked), else 409 `size-changed` with that total, so a consent is bound to the size that was shown; and a saved endpoint that already has the reserved id and is not ours is said now, not after a 23 GB download.
|
||||
|
||||
**The check after the pull** (`verifyModel()`): one tiny chat with `reasoning_effort: "none"`, then `/api/ps` must show `size_vram == size` at the chosen context, then one tool probe (`tools`, `reasoning_effort: "none"`, `max_tokens` 1024) that passes on a tool call with parseable arguments. ⚠️ Thinking is switched off and the cap is generous on purpose: every catalogue model thinks first, and a probe that ends on its token limit proves nothing (`probe-unfinished`, never `no-tool-call`). ⚠️ A failure is a state with a code and ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed". ⚠️ The job fails when the runtime reports no digest for the model: there would be nothing to compare later.
|
||||
|
||||
**The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP.
|
||||
|
||||
**The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [<id as /v1/models lists it>], defaultModelId, defaultContextLength: <the chosen context> }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest.
|
||||
|
||||
**The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment.
|
||||
|
||||
**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove.
|
||||
|
||||
**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`.
|
||||
|
||||
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
|
||||
|
||||
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
|
||||
|
||||
### Approvals Inbox
|
||||
|
||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`).
|
||||
@@ -768,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
|
||||
|
||||
@@ -1355,6 +1401,8 @@ On this route Node.js comes from nodejs.org (`latest-v<major>.x/SHASUMS256.txt`
|
||||
|
||||
⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting.
|
||||
|
||||
**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli <id>|none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent <id>` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and Run reads that memory, so `mark_cli_installed` touches `cli-installed` in the data dir and `resolve()` leaves the backoff when that file's mtime changes. ⚠️ That check is one `stat` inside the backoff branch and buys one run of the chain. Never replace it with a re-walk of PATH during the backoff: that puts a synchronous stat of every PATH entry on the request path (#516) and executes candidates already refused. The app never restarts the service for this. Only `install.sh` writes the marker, always under `~/.codeman`, so a CLI installed by hand, or another `CODEMAN_INSTANCE`, waits out the backoff as before, and so does a server older than this. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`).
|
||||
|
||||
⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/<ref>.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `<install dir>.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log.
|
||||
|
||||
## Agent inspection and guarded submission
|
||||
|
||||
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
|
||||
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
|
||||
|
||||
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
|
||||
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
|
||||
- installs a missing **stock** CLI by running its shipped install command (the entry as merged with `clis.json`, so an override of a stock entry's install command is what runs here), after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
|
||||
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
|
||||
|
||||
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
|
||||
@@ -332,7 +332,7 @@ already have.
|
||||
|
||||
Three rules, and the middle one is why the embed matters:
|
||||
|
||||
1. **The server never executes an entry's `install.command`.** Unchanged, and still enforced by nothing executing it: the field is display text (`CliDiscovery.install.command`).
|
||||
1. **The server executes an entry's `install.command` in exactly two places, both behind a consent click that shows the literal text.** CLI management (above, `cliManagementEnabled`, the merged entry's command) and the local model install (`docs/local-models.md`: only the COMPILED stock command of a catalogue harness row, through `stockInstallCommand()`, so no `clis.json` override reaches it; no setting gates that path, the consent screen does). Both run through the one runner in `src/cli-install-runner.ts` (one install per CLI at a time, no `CODEMAN_*` in the environment, the whole process group killed on a timeout or a cancel) and both are written to the admin audit log in multi-user mode. Everywhere else the field is display text (`CliDiscovery.install.command`).
|
||||
2. **`install.sh` executes only commands embedded in itself.** Those arrive in the same file, over the same TLS fetch, in the same commit as the `curl \| bash` line that fetched the script — identical trust to the hardcoded vendor one-liners it replaces.
|
||||
3. **Nothing fetched at install time is ever executed.** There is no second code path that fetches anything after the script itself has been fetched.
|
||||
|
||||
|
||||
@@ -663,6 +663,45 @@ each result. `scripts/test-local-llm-harnesses.ts` is the standalone script
|
||||
used to check a harness against a real endpoint outside the web UI
|
||||
entirely; see its own `--help` for usage.
|
||||
|
||||
## The endpoint store has one writer
|
||||
|
||||
`~/.codeman/custom-model-hosts.json` is written only through `mutateCustomModelHosts()`
|
||||
(`src/custom-model-hosts-writer.ts`): the create, update, delete and discover routes, the
|
||||
five-minute re-discovery sweep and the local model install all go through it.
|
||||
|
||||
- **One at a time.** Changes run on one queue, and the check a route makes ("this id is
|
||||
free", "this endpoint still exists") runs in the same step as its write. Before, each
|
||||
writer read the file, changed its copy and wrote it back on its own, all through one temp
|
||||
file name: a route and the sweep, or two requests sent together, could lose each other's
|
||||
change or fail outright.
|
||||
- **Atomic.** A temp file with a random name beside the target, then a rename. Mode 0600.
|
||||
- **A file that does not parse is refused, not replaced.** The writing routes answer
|
||||
`409 CONFLICT` with the fix (repair or delete the file, or restore it from a config
|
||||
backup) and leave it byte for byte. Before, such a file read as an empty list, and the next
|
||||
save replaced every stored endpoint and its API key with one record. The message never
|
||||
quotes the file, which holds keys.
|
||||
|
||||
The one direct writer left is config-backup restore, which must work on a file that does
|
||||
not parse.
|
||||
|
||||
## The endpoint of the model on this Mac
|
||||
|
||||
Installing a local model from Codeman ([local-models.md](local-models.md)) saves one endpoint
|
||||
with the reserved id `local-ollama` and the label "This Mac". It shows up in the list and in
|
||||
the Run menu like any other endpoint. Three things are different:
|
||||
|
||||
- A new endpoint cannot be given the id `local-ollama`.
|
||||
- While it is the endpoint the install manages, it cannot be edited or deleted here, and
|
||||
"Discover" answers its stored model list without asking the runtime. Its model list must
|
||||
stay the one model the install checked, and its address the port of the runtime Codeman
|
||||
supervises. Change or remove it in the local model window.
|
||||
- A session is started on it only while Codeman's own runtime is running and the model is
|
||||
still the one that was installed (its digest is compared). Otherwise the launch answers
|
||||
with one sentence.
|
||||
|
||||
An endpoint with that id that somebody made by hand, with no model installed by Codeman, is
|
||||
an ordinary endpoint: it can be edited and deleted as before.
|
||||
|
||||
## Security note
|
||||
|
||||
Every env var this feature can set that redirects a session's traffic
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
# A model that runs on this Mac
|
||||
|
||||
Codeman can set up a coding model that runs on the Mac the server runs on: no account and
|
||||
no API key, and the session's prompts and files go to a model on this Mac rather than a
|
||||
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
|
||||
shows what fits this Mac, installs the choice and ends on a coding agent session that
|
||||
runs on it.
|
||||
|
||||
**Experimental.** The routes and their payloads may change in any release
|
||||
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
|
||||
silicon macOS only: anywhere else the window says why nothing is offered.
|
||||
|
||||
This page is the reference for how it works. The HTTP contract is in
|
||||
[api-reference.md](api-reference.md) ("Local model"). The rules that must not be broken are
|
||||
in [architecture-invariants.md](architecture-invariants.md#local-model-install).
|
||||
|
||||
## Which model fits which Mac
|
||||
|
||||
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
|
||||
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
|
||||
the catalogue answers today, before the runtime measured anything (the budget is then an
|
||||
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
|
||||
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
|
||||
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
|
||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
|
||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
|
||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||
|
||||
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
|
||||
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
|
||||
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
|
||||
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
|
||||
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
|
||||
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
|
||||
|
||||
### How much memory a model needs
|
||||
|
||||
The need of a model is its peak: the weights, what stays held per token of context, what the
|
||||
runner holds beside both, and what it takes for a moment while a long prompt is processed. The
|
||||
runtime runs a model with one of two runners, which spend memory differently, so every row
|
||||
names its runner (`runner`) and its figures follow that runner's rule:
|
||||
|
||||
- **llama.cpp** (GGUF builds, on Metal) allocates the K/V cache for the whole context when the
|
||||
model loads and does not grow after that. The runtime is started with
|
||||
`OLLAMA_KV_CACHE_TYPE=q8_0` (the cache in 8 bits, 17/32 of the default f16; the runtime's
|
||||
own documentation calls the loss very small), `OLLAMA_FLASH_ATTENTION=1` (an 8-bit cache
|
||||
needs it), `LLAMA_ARG_CACHE_RAM=1024` (llama-server keeps copies of earlier prompts, and
|
||||
this bounds them to 1 GiB) and `LLAMA_ARG_CTX_CHECKPOINTS=8` (llama-server keeps copies of
|
||||
the state it cannot roll back, two or three per request, up to 32 by default: 5 GB for the
|
||||
27B; 8 keeps the last few requests, which is what an agent's next request restores from). A
|
||||
row's figures are computed from its model's `config.json`: the K/V cache of its attention
|
||||
layers, its recurrent or sliding-window state, live and in each of the 8 checkpoints, 1.0
|
||||
GB for the runner and the 1 GiB prompt cache. A build with a multi-token-prediction layer
|
||||
(the two `-mtp` tags and `qwen3.8:27b-q8_0`) costs more: the runtime turns prediction on,
|
||||
so llama-server keeps that layer's cache in f16 and one more copy of the recurrent state for
|
||||
rollback per drafted token (the tag's `draft_num_predict`: 2 for the 35B-A3B, 4 for the
|
||||
27B). No peak: the buffers are allocated at load. The checkpoints and the
|
||||
prediction costs were read in the llama.cpp source the runtime bundles (b12111), not
|
||||
measured.
|
||||
- **MLX** grows its cache with the tokens actually used, keeps snapshots of the recurrent
|
||||
state, and may keep up to 8 GiB of snapshots of earlier prompts, which no setting bounds. A
|
||||
Mac always takes the MLX build of a tag that has one. Below 48 GB that 8 GiB does not fit
|
||||
beside a useful model, so the catalogue lists GGUF builds there.
|
||||
|
||||
Where every figure comes from:
|
||||
|
||||
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
|
||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
|
||||
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
|
||||
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
|
||||
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
|
||||
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
|
||||
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
|
||||
| `qwen3.8:27b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 2.95: a published run without prediction (a floor) |
|
||||
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
|
||||
|
||||
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
|
||||
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
|
||||
calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
|
||||
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
|
||||
below). The measured row's figures come from single long prompts, not a session of many
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
|
||||
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
|
||||
|
||||
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
|
||||
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
||||
`test/local-model-catalog.test.ts`, which recomputes every computed figure from it and fails
|
||||
for a row it does not know. Its answer for each memory size is then pinned in
|
||||
`test/local-model-recommend.test.ts`.
|
||||
|
||||
## What an install does, in order
|
||||
|
||||
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
|
||||
server then runs one job of five steps. The window polls `GET /api/local-model` once a
|
||||
second; the job runs in the server, so the window can be closed.
|
||||
|
||||
| Step | What it does |
|
||||
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `runtime` | Downloads the pinned runtime, compares its hash, unpacks it, chooses a port once, starts it, reads the measured memory budget and judges the choice again |
|
||||
| `model` | Downloads the model through the runtime |
|
||||
| `verify` | Loads the model and checks it: one tiny chat, the memory and context the runtime reports, one tool call |
|
||||
| `agent` | Installs the coding agent with its stock install command. Left out when the agent is installed |
|
||||
| `connect` | Saves one model endpoint and switches the custom model endpoints setting on |
|
||||
|
||||
Every step can be repeated: Resume and "Try again" enter the step the job stopped in.
|
||||
|
||||
### The runtime
|
||||
|
||||
- **One pinned build.** Ollama 0.40.15 for macOS, a compiled constant
|
||||
(`OLLAMA_RUNTIME_PIN` in `src/local-model/runtime.ts`): the URL, the size (185,971,671
|
||||
bytes) and the sha256. No file, setting, environment variable or request can change which
|
||||
binary is downloaded.
|
||||
- **Download.** Streamed to `*.partial` while it is hashed. A cut stream is continued with a
|
||||
Range request, at most three times. The hash is compared before anything is unpacked, and
|
||||
a mismatch deletes the file. Unpacking is `/usr/bin/tar` into a temporary folder, then one
|
||||
rename.
|
||||
- **No administrator rights.** Everything lives in the data directory.
|
||||
- **Supervised by Codeman.** The runtime is a non-detached child of the server: started at
|
||||
boot once a model is installed and not stopped, restarted at most three times after a
|
||||
crash, sent SIGTERM at shutdown. It is not a login item: it stops with Codeman, and a
|
||||
leftover from a server that was killed is taken back at the next start (see below).
|
||||
- **Its environment is an allowlist.** `PATH`, `TMPDIR`, `LANG` and the proxy variables are
|
||||
copied from the server. `HOME` is a private folder. Exactly these are set:
|
||||
`OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`,
|
||||
`OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`,
|
||||
`OLLAMA_MAX_LOADED_MODELS=1`, and four memory settings of the llama.cpp runner:
|
||||
`OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_FLASH_ATTENTION=1`, `LLAMA_ARG_CACHE_RAM=1024` and
|
||||
`LLAMA_ARG_CTX_CHECKPOINTS=8` (see "How much memory a model needs" above). No `CODEMAN_*`
|
||||
variable and none of the user's own `OLLAMA_*` or `LLAMA_ARG_*` settings reach it.
|
||||
- **The port is chosen once**, by binding, from 11435 across 20 ports, and stored. 11434 is
|
||||
left alone: it is the default of a runtime the user may run themselves, which is never
|
||||
touched.
|
||||
- **An orphan is recognized.** The pid and the port are stored. At boot a listener on the
|
||||
port that answers `/api/version` and whose pid runs a binary under Codeman's runtime folder
|
||||
is Codeman's own leftover from a server that was killed: it is adopted or replaced. Any
|
||||
other listener is "Port 11435 is in use by another program", with "Use another port" as
|
||||
the way out.
|
||||
|
||||
### The memory budget is measured
|
||||
|
||||
Before the runtime exists the budget is an estimate from the installed memory. Right after
|
||||
it starts, the runtime prints the device total in its log line "inference compute"
|
||||
(`total="37.4 GiB"` on a 48 GB MacBook). Codeman reads that line, stores the figure and
|
||||
judges the chosen model against it before a byte of the model is downloaded. A model that no
|
||||
longer fits ends the job there with "does not fit" and the next model to try. From then on
|
||||
the window shows the measured budget and the word "estimated" is gone.
|
||||
|
||||
The need of a model is its peak (see "How much memory a model needs" above).
|
||||
|
||||
### The model download
|
||||
|
||||
`POST /api/pull` with `stream: true`. A 23 GB pull sends about 1.4 million progress lines,
|
||||
so the stream is parsed piece by piece and never kept. Cancel aborts the request, which
|
||||
stops the download. Resume pulls again and the runtime continues its partial files. An error
|
||||
line or a cut stream is retried up to five times. Once the pull reported its total, every
|
||||
size shown for the model is that total.
|
||||
|
||||
### The check
|
||||
|
||||
After the pull, in this order:
|
||||
|
||||
1. One tiny chat with `reasoning_effort: "none"`. It loads the model.
|
||||
2. `GET /api/ps` must show the whole model on the GPU (`size_vram` equal to `size`) at the
|
||||
chosen context length. For a llama.cpp build both figures are the buffers llama-server
|
||||
reports allocating (weights, K/V cache, compute), so this proves the whole load is on the
|
||||
GPU. For an MLX build it proves only the weights: the runtime books an MLX model at its
|
||||
weights (runtime issue 19417), so for those rows the catalogue's figures are all there is.
|
||||
3. One tool probe on `/v1/chat/completions` (a `tools` array, `reasoning_effort: "none"`,
|
||||
`max_tokens` 1024). It passes on a tool call whose arguments parse.
|
||||
|
||||
A failure is a state with a code and one next step, never a bare "failed": `does-not-fit`
|
||||
and `no-tool-call` offer the next model, `probe-unfinished` (the token limit ended the
|
||||
probe, so it proves nothing) and `load-failed` offer "Try again". The digest the runtime
|
||||
reports for the model is stored when the check passed.
|
||||
|
||||
### The coding agent
|
||||
|
||||
When the chosen coding agent is not installed, the job installs it with the CLI registry's
|
||||
own stock install command, through the same runner as App Settings > CLIs
|
||||
(`src/cli-install-runner.ts`). The rules:
|
||||
|
||||
- Only a CLI that is a harness row of the local model catalogue.
|
||||
- Only the **compiled** stock command. A `~/.codeman/clis.json` override cannot change what
|
||||
runs here.
|
||||
- Only after the person's consent click, on a screen that showed the literal command.
|
||||
- Admin only in multi-user mode, and written to the admin audit log (`cli_install`, with
|
||||
`via: "local-model"`).
|
||||
- One installer per CLI at a time, shared with App Settings: while one runs, the other
|
||||
answers that it is busy.
|
||||
- Afterwards the cached lookups of the CLI's binary are forgotten and the binary is looked
|
||||
for again. Still missing is the failure `harness-failed`, with the installer's last lines.
|
||||
|
||||
This step does not need the `cliManagementEnabled` setting. That setting gates the general
|
||||
CLI management surface; this path can only ever run one vetted command for one catalogue
|
||||
row.
|
||||
|
||||
### The connection
|
||||
|
||||
Two writes outside the local model folder, both undone by Remove:
|
||||
|
||||
1. One record in `custom-model-hosts.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "local-ollama",
|
||||
"label": "This Mac",
|
||||
"baseUrl": "http://127.0.0.1:11435",
|
||||
"authStyle": "bearer",
|
||||
"models": ["qwen3.6:35b-a3b"],
|
||||
"defaultModelId": "qwen3.6:35b-a3b",
|
||||
"defaultContextLength": 131072
|
||||
}
|
||||
```
|
||||
|
||||
`defaultContextLength` is the chosen context: the runtime's model listing states none, so
|
||||
this field is what reaches the coding agent as its context limit.
|
||||
|
||||
2. `customModelEndpointsEnabled: true` in `settings.json`. The value it had before is kept
|
||||
in the state file.
|
||||
|
||||
The id `local-ollama` is reserved. A new endpoint cannot take it, and a record with that id
|
||||
that this feature did not write is never overwritten: the install says so before anything is
|
||||
downloaded. While the record is the managed one it cannot be edited or deleted in App
|
||||
Settings and the five-minute discovery sweep leaves it alone, so its model list stays the
|
||||
one model the install checked.
|
||||
|
||||
Switching the setting on also starts that five-minute sweep for every other saved endpoint.
|
||||
|
||||
## Starting a session
|
||||
|
||||
"Start a session" starts the coding agent on the endpoint `local-ollama` through the
|
||||
existing custom model launch. Before a coding agent is STARTED on that endpoint the server
|
||||
checks, in this order:
|
||||
|
||||
- the runtime child Codeman started is alive (whatever else answers on the port is not
|
||||
ours);
|
||||
- the digest the runtime reports for the installed model is the one stored at install time;
|
||||
- the model asked for is the one that was installed and checked.
|
||||
|
||||
That check runs wherever a new agent process comes to be on the endpoint: a session created
|
||||
with the model, a model applied to a running session (which restarts its agent), the attach
|
||||
that starts an agent in a session that has none or whose agent exited, and the rebuild of a
|
||||
session after a reboot (that entry is skipped with "the local model is not ready" and stays
|
||||
on offer). An agent that is already running when Codeman restarts is not started by
|
||||
Codeman, so it is not asked: it keeps running on its port, see below.
|
||||
|
||||
A different digest refuses the launch: "The model on this Mac changed since it was
|
||||
installed. Remove it and install it again." The digest is also read again at boot and when
|
||||
the runtime is started.
|
||||
|
||||
### From a Claude Code session: cloud plus local
|
||||
|
||||
The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac))
|
||||
lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and
|
||||
reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and
|
||||
files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what
|
||||
is installed or why nothing can run; `spawn_worker <name> local` starts the runtime when it was
|
||||
stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same
|
||||
quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its
|
||||
answer back from a file it writes in its own folder. The skill never installs a model and never
|
||||
answers a confirmation, the runtime still answers one request at a time, and while a worker it
|
||||
opened is live, Stop and Remove are refused until it is closed.
|
||||
|
||||
## Stop, Start, Remove
|
||||
|
||||
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
|
||||
that.
|
||||
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
|
||||
only when this feature switched it on and no other endpoint is saved, and deletes the
|
||||
runtime and its private home. The downloaded model is deleted only when asked. The coding
|
||||
agent stays. While a live session uses the model, Remove answers with the session names
|
||||
instead.
|
||||
|
||||
## Where everything is
|
||||
|
||||
All under the data directory (`~/.codeman`, or the instance's own):
|
||||
|
||||
```
|
||||
local-model.json the state file (mode 0600)
|
||||
local-model/
|
||||
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
|
||||
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
|
||||
models/ the model store
|
||||
home/ the runtime's private HOME (its key pair is here)
|
||||
```
|
||||
|
||||
Nothing is written or deleted outside `local-model/` except the state file beside it and the
|
||||
two writes of the connection. The state file holds no path, so it can never point a delete
|
||||
somewhere else. The user's own `~/.ollama` is never created, read or removed.
|
||||
|
||||
`local-model.json` is not part of a config backup. `custom-model-hosts.json` and
|
||||
`settings.json` are, so restoring an older backup can take the endpoint record away while
|
||||
the model stays installed. The record is put back the next time Codeman starts (the setting
|
||||
is left as the backup had it). A restore can also bring a record back after a Remove: it
|
||||
then points at a port nothing listens on, is an ordinary endpoint again, and can be deleted
|
||||
in App Settings.
|
||||
|
||||
## The job's states
|
||||
|
||||
| State | A server restart | Cancel | What can be done |
|
||||
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
|
||||
| `idle` | stays idle | refused | install |
|
||||
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
|
||||
| `paused` | stays paused | refused | resume, install, remove |
|
||||
| `failed` | stays failed, same error | refused | resume, install, remove |
|
||||
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
|
||||
|
||||
One job at a time. A job a server restart interrupted comes back paused and never continues
|
||||
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
|
||||
the installer's process group); in the last step it is honoured before the first write, and
|
||||
once the two writes began they finish.
|
||||
|
||||
## On a Mac without developer tools
|
||||
|
||||
Coding agents call `git` and `python3`. On a Mac without Xcode or the Command Line Tools
|
||||
those are stubs that open macOS's "install the command line developer tools" window. So the
|
||||
window says one sentence wherever the person commits or finishes, and only when
|
||||
`/usr/bin/xcode-select -p` fails:
|
||||
|
||||
> Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer
|
||||
> to install them the first time an agent needs one.
|
||||
|
||||
Codeman itself only ever runs `/usr/bin/xcode-select -p` for this, bounded, and never `git`
|
||||
or `python3`.
|
||||
|
||||
## What to know before relying on it
|
||||
|
||||
- **The runtime's port has no login.** Any program on this Mac, and any web page served from
|
||||
`localhost`, can use the model server and change its models. That is why the endpoint
|
||||
record lists one model, why the sweep leaves it alone, and why the digest is compared
|
||||
before a session starts.
|
||||
- **A Codeman restart stops the runtime.** A session that is mid-answer on the local model
|
||||
loses that answer. The runtime is started again with the server, and the first request
|
||||
loads the model again (seconds).
|
||||
- **An open session keeps calling the runtime's port.** The coding agent was given the
|
||||
address when it started. While the runtime is stopped, or for the seconds of a Codeman
|
||||
restart, its requests go to a port nothing of ours holds, and any program on this Mac could
|
||||
bind that port and answer them. That is why Stop and "Use another port" are refused while
|
||||
a session uses the model: close those sessions first.
|
||||
- **The runtime does not enforce the context length.** A prompt longer than the context was
|
||||
accepted in testing. Do not rely on the runtime to stop one.
|
||||
- **Most memory figures are calculated, not measured.** One model was run on one Mac; the
|
||||
others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
||||
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
|
||||
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
||||
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
||||
unloads it.
|
||||
|
||||
## Keeping the catalogue current
|
||||
|
||||
The catalogue (`src/config/local-model-catalog.ts`) and the runtime pin are compiled into
|
||||
Codeman on purpose: no file or download decides what is recommended or run. What the
|
||||
registry serves can still move away from them: a tag is pushed again or removed, the build
|
||||
a Mac downloads changes size, Ollama ships a new version. The workflow
|
||||
`.github/workflows/local-model-catalog.yml` looks for that every Monday at 06:00 UTC. It
|
||||
runs `scripts/check-local-model-catalog.mjs`, which also runs locally:
|
||||
`npm run check:local-model-catalog` prints the report and exits 0 (clean), 1 (drift) or 2
|
||||
(the check itself failed).
|
||||
|
||||
For every model row it checks:
|
||||
|
||||
- **The tag still exists** in the registry.
|
||||
- **The size of what a Mac downloads** is within 0.1% of the row's `bytes`. A tag can be a
|
||||
manifest list with one build per runner, and Ollama on Apple silicon takes the `mlx` build
|
||||
when there is one, else `llamacpp`, else `ggml`. So the check asks for the tag the way
|
||||
Ollama asks, picks the build a Mac picks and sizes it the way a pull counts it (every layer
|
||||
plus the config). The plain manifest other tools get for the same tag is the `llamacpp`
|
||||
build, which is not what a Mac downloads.
|
||||
- **The runner of that build**, once a row names its `runner`: a Mac still pulls an `mlx`
|
||||
build for an `mlx` row and a GGUF build (`llamacpp`, or a manifest that names no runner)
|
||||
for a `llamacpp` row. A row's memory figures follow its runner, so a tag that gains or
|
||||
loses an mlx build is drift even at about the same size.
|
||||
- **The build a Mac pulls runs on the pinned runtime**: its config declares no newer
|
||||
Ollama (`requires`) than `OLLAMA_RUNTIME_PIN`.
|
||||
- **The pinned digest**, once a row carries one: a Mac would still pull that manifest. The
|
||||
field is `digest` and its value is what `/api/tags` reports after the pull. The catalogue
|
||||
schema does not have it yet: `LocalModelEntrySchema` must gain an optional `digest` under
|
||||
exactly that name, or the check never compares it. Until then the report lists today's
|
||||
digests, rows with measured figures first, because without a digest a rebuild of about the
|
||||
same size goes unnoticed (a switch between the mlx and the gguf build does not: their sizes
|
||||
differ by 2% or more). Pinning digests is a decision, not a quick fix: today the catalogue
|
||||
holds no digest on purpose, and `test/local-model-catalog.test.ts` ("holds no download
|
||||
data") refuses a `sha256` anywhere in it, so that rule changes with it.
|
||||
|
||||
For the runtime pin it checks whether a newer Ollama release exists, and whether the pinned
|
||||
`ollama-darwin.tgz` is still on GitHub with the pinned size and sha256.
|
||||
|
||||
It reads manifests and configs only, never a model layer. It does not check the memory
|
||||
figures, the speeds or whether a coding agent works with a model: no manifest says anything
|
||||
about those, and they change only when somebody measures on a Mac.
|
||||
|
||||
**The issue.** Drift opens one issue, "Local model catalogue: weekly check", or, while that
|
||||
issue is open, replaces its description with the new report and adds a comment. A clean week
|
||||
comments on the open issue and does nothing when there is none. A check that failed (the
|
||||
network, a rate limit, an answer that did not parse) fails the run and leaves the issue
|
||||
alone; what it found is in the run's summary. The schedule runs the copy of the workflow on
|
||||
`master`, and so does `gh workflow run local-model-catalog.yml`.
|
||||
|
||||
**What a maintainer does with it**, in a pull request each time:
|
||||
|
||||
- **Size drift or a changed digest**: correct the row. When the build itself changed (another
|
||||
runner or quantization), its memory figures and speed no longer describe it: mark the row
|
||||
`estimated` until somebody measures it again.
|
||||
- **A tag that is gone, or a build that needs a newer runtime**: replace or drop the row, or
|
||||
move the runtime pin first.
|
||||
- **A newer Ollama**: read its release notes, then move `OLLAMA_RUNTIME_PIN` (version, URL,
|
||||
size and sha256, checked against the release's `sha256sum.txt`) and run an install on a
|
||||
Mac before merging.
|
||||
- **A pinned archive that changed**: a security question first. Every install refuses it at
|
||||
the hash compare, so nobody gets it, but find out why before moving the pin.
|
||||
|
||||
Close the issue once the fix is merged. A catalogue or pin change reaches users only in a
|
||||
release: an installed Codeman keeps the catalogue it was built with until it is updated.
|
||||
|
||||
## Source
|
||||
|
||||
| What | Where |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Hardware probe, catalogue, recommendation | `src/local-model/hardware.ts`, `src/config/local-model-catalog.ts`, `src/local-model/recommend.ts` |
|
||||
| Runtime pin, download, environment, supervisor | `src/local-model/runtime.ts` |
|
||||
| Runtime HTTP client, pull parser, checks | `src/local-model/ollama-client.ts` |
|
||||
| Developer tools check | `src/local-model/developer-tools.ts` |
|
||||
| Job (state table, reducer, view, service) | `src/local-model/install-job.ts` |
|
||||
| State file | `src/local-model/state-store.ts` |
|
||||
| Connection and its undoing | `src/local-model/connect.ts` |
|
||||
| Paths | `src/local-model/paths.ts` |
|
||||
| Routes | `src/web/routes/local-model-routes.ts` |
|
||||
| Shared CLI install runner | `src/cli-install-runner.ts` |
|
||||
| Serialized writer of the endpoint store | `src/custom-model-hosts-writer.ts` |
|
||||
| Weekly catalogue check | `scripts/check-local-model-catalog.mjs`, `.github/workflows/local-model-catalog.yml` |
|
||||
+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 |
|
||||
@@ -594,6 +613,15 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom
|
||||
- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA.
|
||||
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||
|
||||
## 10d. The model on this Mac (local model install)
|
||||
|
||||
Opt-in by a click, macOS only, admin-only in multi-user mode on every verb. The server downloads one pinned runtime build, runs it as its own child, pulls one model into it, installs the coding agent when it is missing, and saves one endpoint record. Source: `src/local-model/`, routes in `src/web/routes/local-model-routes.ts`. Guide: [`local-models.md`](local-models.md).
|
||||
|
||||
- **Supply chain: a compiled pin.** The runtime's URL, size and sha256 are a constant in `src/local-model/runtime.ts` that no file, setting or request can change; the archive is hashed while it streams and compared BEFORE anything is unpacked, a mismatch deletes it, and only `/usr/bin/tar` unpacks it into a folder under the data dir. The model comes from the runtime's own registry under the name the catalogue pins; the digest the runtime reports after the pull is stored, and a model whose digest changed under that name is refused at the next start.
|
||||
- **A loopback port without a login.** The runtime listens on `127.0.0.1:<port>` (chosen once from 11435) with no authentication, like every model server of its kind: any program of any account on the Mac, and any web page served from `localhost`, can use it and change its models. What limits that: the endpoint record lists ONE model and the discovery sweep leaves it alone, the digest is compared before an agent is started on it (every start path, see the guide), and a listener on the port that is not our own child (judged by the stored pid's command line, never by the port alone) is reported as `port-in-use` and never signalled. An open session keeps calling the port while the runtime is stopped, which is why Stop and a port change are refused while one uses the model.
|
||||
- **A second install path for a coding agent, without `cliManagementEnabled`.** The job runs the COMPILED stock install command of a catalogue harness row (never a `clis.json` override, never a custom entry) through the same runner as CLI management, only after the consent screen showed the literal command, and writes the admin audit entry (`cli_install`, `via: 'local-model'`). The installer runs as the server's user with the same reach as every other spawn.
|
||||
- **The runtime's own files stay inside `~/.codeman/local-model/`**: its private HOME (so no `~/.ollama` is created), its model store and its unpacked folder; its environment is an allowlist (no `CODEMAN_*`). Remove deletes only inside that folder, refuses a folder that is a symbolic link, and ends the runtime (ours by its command line) before any file goes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
@@ -86,6 +86,26 @@ covered like any other stable behavior. Other agent commands and API routes keep
|
||||
their existing guarantees. Promotion to stable requires an explicit documented
|
||||
decision.
|
||||
|
||||
### Experimental local model routes
|
||||
|
||||
`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/cancel`, `/resume`
|
||||
and `/runtime`, and `DELETE /api/v1/local-model` (and their `/api` aliases) are
|
||||
labeled experimental, so item 3 above applies to them: their paths, their payloads
|
||||
(the hardware facts, the verdicts and reasons, the candidate fields, the `install`
|
||||
object with its states, steps, error codes and actions, the 409 conflict shape and
|
||||
every English sentence) and their behavior may change or be removed in a MINOR or
|
||||
PATCH release. They look at the Mac the server runs on, say which local model fits,
|
||||
and install, start, stop and remove one ([local-models.md](local-models.md)).
|
||||
|
||||
They are not behind a setting. Reading changes nothing; an install starts only on an
|
||||
explicit request that carries the size the person confirmed. All of them are admin
|
||||
only in multi-user mode. What an install leaves behind is ordinary, stable state: one
|
||||
saved model endpoint with the id `local-ollama` and the `customModelEndpointsEnabled`
|
||||
setting switched on, both of which the stable custom model endpoint routes read like
|
||||
any other endpoint. On-disk state (`local-model.json`, the `local-model/` folder) is
|
||||
internal, as for every other file in the data directory. Promotion to stable requires
|
||||
an explicit documented decision.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
When we need to change a covered surface:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -204,6 +204,45 @@ Which harnesses show up in the Run-menu picker is read live off Codeman's own CL
|
||||
not a fixed list here, so this table can go stale before this page does — a greyed-out or
|
||||
missing entry is the more current answer.
|
||||
|
||||
## A model that runs on this Mac
|
||||
|
||||
If the Codeman server runs on a Mac with Apple silicon, you do not need a server of your
|
||||
own: Codeman can set one model up for you on that Mac. This is experimental.
|
||||
|
||||
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
|
||||
on this Mac).
|
||||
2. The window shows what fits this Mac. A bar shows how much of the memory the selected
|
||||
model takes at its full context; a nearly full bar means a tight fit. Pick a model and
|
||||
the coding agent to run it with.
|
||||
3. Press **Install on this Mac**, check the sizes and where they go, and confirm. Nothing is
|
||||
downloaded before this click.
|
||||
4. Codeman downloads a model runtime (Ollama, about 186 MB) and the model into
|
||||
`~/.codeman/local-model`, loads the model, checks that all of it runs on the GPU and that
|
||||
it can call a tool, installs the coding agent if it is missing, and saves an endpoint
|
||||
named **This Mac**. No administrator password is asked for.
|
||||
5. Press **Start a session**.
|
||||
|
||||
Good to know:
|
||||
|
||||
- You can close the window: the download continues while Codeman is running. **Cancel**
|
||||
pauses it and **Continue** picks it up where it stopped. After a Codeman restart an
|
||||
unfinished install waits for your click.
|
||||
- The memory figure is an estimate until the runtime has started once. From then on it is
|
||||
what the runtime itself measured, and the model is judged against that before it is
|
||||
downloaded.
|
||||
- The endpoint "This Mac" belongs to this install. It is in the list and in the Run menu,
|
||||
but it is changed and removed in the local model window, not here.
|
||||
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
|
||||
starting next time.
|
||||
- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The
|
||||
coding agent stays installed.
|
||||
- If the model on disk is no longer the one that was installed, Codeman refuses to start a
|
||||
session on it and tells you to remove and install it again.
|
||||
- Any program on this Mac, and any web page served from `localhost`, can reach the model
|
||||
runtime: its port has no login. It listens on this Mac only.
|
||||
- On a Mac without Apple's developer tools, macOS will offer to install them the first time
|
||||
a coding agent runs `git` or Python. The window tells you when that applies.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **No remote or Docker sessions yet.** Both restart their agent differently under the hood
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ install.sh uninstall # remove (offers to undo a rename it performed)
|
||||
install.sh tailscale # retrofit Tailscale access onto an existing install
|
||||
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
|
||||
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
|
||||
install.sh preflight # print what is on this machine as JSON; changes nothing
|
||||
install.sh agent <id> # install one agent CLI and nothing else, also next to a running Codeman
|
||||
```
|
||||
|
||||
**Flags** answer the questions from the command line and pipe through `bash -s --`:
|
||||
@@ -95,10 +97,18 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every
|
||||
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
|
||||
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
|
||||
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
|
||||
`--ai-cli <id>` answers the agent menu that appears when no agent CLI is found, with no
|
||||
terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it.
|
||||
To add an agent later, `install.sh agent codex` installs that one and changes nothing else:
|
||||
it does not touch Codeman or its service, and an agent that is already there is left alone.
|
||||
Codeman Desktop does the same from *Add a coding agent*. A running Codeman notices the new
|
||||
agent at once; reload an open page to see it.
|
||||
|
||||
**Automation and CI**: with no terminal attached, any step that would change the system
|
||||
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
|
||||
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
|
||||
approve those steps. With nothing else said, that also installs the first agent CLI of the
|
||||
catalogue when none is found; `--ai-cli` (or `CODEMAN_AI_CLI`) says which one, or none.
|
||||
`CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
|
||||
installs Tailscale itself non-interactively; a non-interactive run never renames the
|
||||
machine and never starts a service. Everything the unattended steps print goes to
|
||||
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
|
||||
|
||||
@@ -193,6 +193,26 @@ attached.
|
||||
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
||||
instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints).
|
||||
|
||||
**Model on this Mac** is not a setting: it is the state of a model Codeman can run for you
|
||||
on the Mac the server runs on, with a button that opens the local model window. The same
|
||||
window opens from the **Run a local model** button on the welcome screen. It shows that
|
||||
Mac's chip, memory and free disk space, a bar for how much of the memory the selected model
|
||||
takes, and the models that fit, each with one tag (Best fit, Tight, Slow, Small model, or
|
||||
"Tested on a Mac like this"), a speed and a download size. **Install on this Mac** asks you
|
||||
to confirm the sizes once, then downloads a model runtime and the model into
|
||||
`~/.codeman/local-model`, checks that the model loads and can call a tool, installs the
|
||||
coding agent if it is missing, and ends on **Start a session**. No account, no API key and
|
||||
no administrator password. You can close the window while it downloads, cancel and continue
|
||||
later; after a Codeman restart an unfinished install waits for you to press Continue. Once a
|
||||
model is installed the window offers Start a session, Stop or Start, and Remove. Installing
|
||||
switches **Custom model endpoints** on and saves one endpoint named "This Mac"; Remove takes
|
||||
the endpoint away again and switches the setting back off if nothing else uses it. A Mac
|
||||
that can only run a small model is told so in plain words, and a Mac with too little memory,
|
||||
too little free disk space or an Intel processor is told why nothing is offered. The group
|
||||
is not shown when the server runs on Linux or Windows, and in multi-user mode only an admin
|
||||
sees it. The "Mac" is always the machine the server runs on, not the device you are reading
|
||||
on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-on-this-mac).
|
||||
|
||||
### Agents & CLIs
|
||||
|
||||
| Setting | Notes |
|
||||
@@ -202,7 +222,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
| 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
|
||||
|
||||
+285
-46
@@ -20,6 +20,10 @@
|
||||
# --no-admin Never use sudo or Homebrew: download Node.js and tmux into
|
||||
# the home folder instead (chosen by itself when this account
|
||||
# has no administrator rights)
|
||||
# --ai-cli <id>|none Which AI CLI to install when none is found, without the menu
|
||||
# (the ids are the keys of the catalogue below)
|
||||
# --progress Also print machine-readable progress lines (for the
|
||||
# desktop app; see progress_phase)
|
||||
# --help, -h Print this text
|
||||
#
|
||||
# Environment variables:
|
||||
@@ -49,6 +53,8 @@
|
||||
# CODEMAN_TARBALL_URL - Source archive (.tar.gz) to install from when no
|
||||
# working git is available (default: the GitHub
|
||||
# archive of CODEMAN_REPO_URL at CODEMAN_BRANCH)
|
||||
# CODEMAN_AI_CLI - Same as --ai-cli
|
||||
# CODEMAN_PROGRESS=1 - Same as --progress
|
||||
#
|
||||
# Subcommands:
|
||||
# install.sh update - Update an existing install
|
||||
@@ -57,6 +63,7 @@
|
||||
# install.sh name [<n>] - Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
# install.sh status - Print the URLs, the QR code and how to manage the service
|
||||
# install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel
|
||||
# install.sh preflight - Print what is on this machine as JSON; changes nothing
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -165,6 +172,12 @@ ASSUME_YES="0"
|
||||
# Set by parse_flags when a flag asks to change how an existing install is
|
||||
# reached or run, so a bare re-run takes the full flow instead of a quiet update.
|
||||
RECONFIGURE="0"
|
||||
# --ai-cli / CODEMAN_AI_CLI: answers the AI CLI menu, with a catalogue id or
|
||||
# "none". Empty means ask. Consent comes with the flag, so unlike the
|
||||
# CODEMAN_NONINTERACTIVE default it installs exactly what was named.
|
||||
AI_CLI_CHOICE="${CODEMAN_AI_CLI:-}"
|
||||
# --progress / CODEMAN_PROGRESS=1: see progress_phase.
|
||||
PROGRESS="${CODEMAN_PROGRESS:-0}"
|
||||
SUBCOMMAND=""
|
||||
SUBCOMMAND_ARG=""
|
||||
# Answers to question 3 (choose_launch_mode): LAUNCH_CHOICE 1/2/3 as above,
|
||||
@@ -272,6 +285,24 @@ warn() {
|
||||
|
||||
error() {
|
||||
echo -e "${RED}Error:${NC} $1" >&2
|
||||
if [[ "$PROGRESS" == "1" ]]; then
|
||||
printf '@@codeman error %s\n' "${1//$'\n'/ }" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# progress_phase <name>: tell the desktop app which part of the install has
|
||||
# begun. The protocol is one line, `@@codeman phase <name>`, for each of
|
||||
# runtime, agent, source, packages, build, service and done, always in that
|
||||
# order, and `@@codeman error <text>` for each error() above. A phase runs until
|
||||
# the next one starts; a run that ends without `done` failed. A phase with
|
||||
# nothing to do may be skipped. The app ignores a name it does not know, so a
|
||||
# new phase needs no new app, but `done` must stay the last thing main() reports.
|
||||
# On stderr, because this can be reached inside a $( ) that captures stdout.
|
||||
progress_phase() {
|
||||
if [[ "$PROGRESS" == "1" ]]; then
|
||||
printf '@@codeman phase %s\n' "$1" >&2
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
die() {
|
||||
@@ -748,6 +779,17 @@ cli_catalog_names() {
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# "claude, opencode, codex, ...": the ids --ai-cli takes.
|
||||
cli_catalog_ids() {
|
||||
local i out=""
|
||||
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
|
||||
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
|
||||
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
|
||||
out="${out:+$out, }${CLI_IDS[$i]}"
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# The "install one yourself" hints: every enabled CLI that is not installed,
|
||||
# showing the trusted install command. An entry with no install command gets
|
||||
# its docs URL instead of being silently omitted, which is what used to
|
||||
@@ -785,6 +827,129 @@ cli_catalog_print_install_hints() {
|
||||
# the first time a hint is printed.
|
||||
cli_catalog_select_platform
|
||||
|
||||
# cli_index <id>: print the catalogue index of an enabled CLI that has a binary
|
||||
# to look for. Fails for anything else, `shell` included.
|
||||
cli_index() {
|
||||
local i
|
||||
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
|
||||
if [[ "${CLI_IDS[$i]}" == "$1" ]] && [[ "${CLI_ENABLED[$i]}" == "1" ]] && [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]]; then
|
||||
printf '%s' "$i"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# cli_offerable <index>: can this run install that entry by itself? 0 = yes,
|
||||
# 1 = no, 2 = no, only because its command needs curl and this host has wget.
|
||||
# One rule for the menu, for --ai-cli and for `preflight`, so what the desktop
|
||||
# app lists is exactly what the menu would. detect_all_clis must have run.
|
||||
#
|
||||
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
|
||||
# (vendor one-liners), others are `npm install -g …`, which never needed curl
|
||||
# at all. A wget-only host used to lose the WHOLE menu over this, including
|
||||
# every npm entry: the two literals this replaced went through
|
||||
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
|
||||
# silently stopped defaulting to Claude Code as documented. So this is decided
|
||||
# per entry: only a command that actually starts with `curl ` is curl-dependent.
|
||||
# Rewriting curl to wget inside a string about to be executed is the wrong
|
||||
# instinct either way; the ones we can't run, we show as a hint.
|
||||
cli_offerable() {
|
||||
local i="$1"
|
||||
[[ "${CLI_ENABLED[$i]}" == "1" ]] || return 1
|
||||
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || return 1
|
||||
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || return 1
|
||||
[[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || return 1
|
||||
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
|
||||
return 2
|
||||
fi
|
||||
# Same reasoning for a Homebrew command: it cannot run where there is no
|
||||
# Homebrew, and the no-admin route never installs one.
|
||||
if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Tell a running Codeman to look again: it remembers a missing CLI for up to 5
|
||||
# minutes (cliResolveRetryDelayMs), and its Run button reads that memory. The name
|
||||
# is CLI_INSTALLED_MARKER in src/utils/cli-executable-resolver.ts. With no data
|
||||
# folder there is no Codeman to tell, which is why a failure here is not one.
|
||||
mark_cli_installed() {
|
||||
touch "$HOME/.codeman/cli-installed" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# run_offered_cli <index>: run the trusted command of one entry and look again
|
||||
# at what is installed. What that means is the caller's to judge.
|
||||
run_offered_cli() {
|
||||
local idx="$1"
|
||||
info "Installing ${CLI_LABELS[$idx]}..."
|
||||
# </dev/null: under `curl | bash` a child that reads stdin would
|
||||
# consume the rest of this script.
|
||||
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
|
||||
hash -r 2>/dev/null || true
|
||||
CLI_DETECT_DONE=""
|
||||
detect_all_clis
|
||||
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
|
||||
success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}"
|
||||
mark_cli_installed
|
||||
else
|
||||
warn "${CLI_LABELS[$idx]} installation failed."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# install_offered_cli <index>: the same during an install, where a chosen CLI
|
||||
# that leaves no CLI at all behind is fatal.
|
||||
install_offered_cli() {
|
||||
run_offered_cli "$1"
|
||||
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
|
||||
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Install one AI CLI and nothing else (`install.sh agent <id>`): how the desktop
|
||||
# app adds an agent once Codeman is here, also over a running server, which this
|
||||
# never touches. It judges the run itself, because install_offered_cli cannot: its
|
||||
# gate is fatal only when NO CLI is left, and here another one usually is, so a
|
||||
# failed install would report success, and its message is about re-running the
|
||||
# installer. A CLI that is here already is a success, and installs nothing.
|
||||
agent_subcommand() {
|
||||
if [[ -z "$AI_CLI_CHOICE" || "$AI_CLI_CHOICE" == "none" ]]; then
|
||||
die "Name the AI CLI to install: install.sh agent <id> (one of: $(cli_catalog_ids))."
|
||||
fi
|
||||
check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first."
|
||||
detect_all_clis
|
||||
progress_phase agent
|
||||
|
||||
local idx offerable=0
|
||||
idx=$(cli_index "$AI_CLI_CHOICE")
|
||||
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
|
||||
success "${CLI_LABELS[$idx]} is already installed at ${CLI_FOUND_PATH[$idx]}"
|
||||
mark_cli_installed
|
||||
progress_phase done
|
||||
return 0
|
||||
fi
|
||||
cli_offerable "$idx" || offerable=$?
|
||||
if [[ "$offerable" -ne 0 ]]; then
|
||||
die "${CLI_LABELS[$idx]} cannot be installed automatically here. See ${CLI_DOCS[$idx]:-its documentation}."
|
||||
fi
|
||||
# What this run sees is a login shell's PATH at best: a Node.js that only an
|
||||
# interactive profile loads (nvm in .zshrc) is not on it.
|
||||
if [[ "${CLI_INSTALL_CMD_TRUSTED[$idx]}" == npm\ * ]] && ! check_npm; then
|
||||
die "${CLI_LABELS[$idx]} installs with npm, and this run cannot find it. Run this in a terminal instead: ${CLI_INSTALL_CMD_TRUSTED[$idx]}"
|
||||
fi
|
||||
|
||||
run_offered_cli "$idx"
|
||||
if [[ -z "${CLI_FOUND_PATH[$idx]}" ]]; then
|
||||
die "${CLI_LABELS[$idx]} did not install. You can run its installer yourself: ${CLI_INSTALL_CMD_TRUSTED[$idx]}"
|
||||
fi
|
||||
progress_phase done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Offer to install one AI CLI from the catalogue, or let the user skip.
|
||||
#
|
||||
# Split out of main() so the bash 3.2 CI step and test/install-sh-invariants.test.ts
|
||||
@@ -798,7 +963,10 @@ offer_ai_cli_install() {
|
||||
local i
|
||||
echo ""
|
||||
warn "No AI CLI found. Codeman needs at least one: $(cli_catalog_names)."
|
||||
headless_guard "install an AI CLI (curl | bash from its vendor)"
|
||||
# --ai-cli is the consent the guard otherwise looks for at a terminal.
|
||||
if [[ -z "$AI_CLI_CHOICE" ]]; then
|
||||
headless_guard "install an AI CLI (curl | bash from its vendor)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# The menu is built from the catalogue: every enabled CLI that is not
|
||||
@@ -810,36 +978,38 @@ offer_ai_cli_install() {
|
||||
# ⚠️ TRUST BOUNDARY: the command executed comes from CLI_INSTALL_CMD_TRUSTED,
|
||||
# the only array the generated block above writes and the only one the
|
||||
# installer ever runs or displays — see cli_catalog_select_platform.
|
||||
#
|
||||
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
|
||||
# (vendor one-liners), others are `npm install -g …`, which never needed
|
||||
# curl at all. A wget-only host used to lose the WHOLE menu over this,
|
||||
# including every npm entry — the two literals this replaced went through
|
||||
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
|
||||
# silently stopped defaulting to Claude Code as documented. Filter per
|
||||
# entry instead: only a command that actually starts with `curl ` is
|
||||
# curl-dependent, so only THOSE are held back on a wget-only host.
|
||||
# Rewriting curl to wget inside a string about to be executed is the
|
||||
# wrong instinct either way — the ones we can't run, we show as a hint.
|
||||
local -a offer_idx=()
|
||||
local curl_only_skipped=0
|
||||
local curl_only_skipped=0 offerable
|
||||
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
|
||||
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
|
||||
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
|
||||
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue
|
||||
[[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || continue
|
||||
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
|
||||
curl_only_skipped=$((curl_only_skipped + 1))
|
||||
continue
|
||||
fi
|
||||
# Same reasoning for a Homebrew command: it cannot run where there is no
|
||||
# Homebrew, and the no-admin route never installs one.
|
||||
if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then
|
||||
continue
|
||||
fi
|
||||
offer_idx[${#offer_idx[@]}]=$i
|
||||
offerable=0
|
||||
cli_offerable "$i" || offerable=$?
|
||||
case "$offerable" in
|
||||
0) offer_idx[${#offer_idx[@]}]=$i ;;
|
||||
2) curl_only_skipped=$((curl_only_skipped + 1)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# The answer was given up front: never the menu, and never a different CLI
|
||||
# than the one named. A name this host cannot install (parse_flags already
|
||||
# refused a typo) warns and carries on, the same as Skip.
|
||||
if [[ -n "$AI_CLI_CHOICE" ]]; then
|
||||
local named="" entry
|
||||
if [[ "$AI_CLI_CHOICE" != "none" ]]; then
|
||||
named=$(cli_index "$AI_CLI_CHOICE") || named=""
|
||||
for entry in ${offer_idx[@]+"${offer_idx[@]}"}; do
|
||||
if [[ "$entry" == "$named" ]]; then
|
||||
install_offered_cli "$named"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
warn "$AI_CLI_CHOICE cannot be installed automatically here. Codeman will run, but sessions need a CLI to drive."
|
||||
else
|
||||
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
|
||||
fi
|
||||
cli_catalog_print_install_hints
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$curl_only_skipped" -gt 0 ]]; then
|
||||
warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)."
|
||||
fi
|
||||
@@ -888,22 +1058,7 @@ offer_ai_cli_install() {
|
||||
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
|
||||
cli_catalog_print_install_hints
|
||||
else
|
||||
idx="${offer_idx[$((cli_choice - 1))]}"
|
||||
info "Installing ${CLI_LABELS[$idx]}..."
|
||||
# </dev/null: under `curl | bash` a child that reads stdin would
|
||||
# consume the rest of this script.
|
||||
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
|
||||
hash -r 2>/dev/null || true
|
||||
CLI_DETECT_DONE=""
|
||||
detect_all_clis
|
||||
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
|
||||
success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}"
|
||||
else
|
||||
warn "${CLI_LABELS[$idx]} installation failed."
|
||||
fi
|
||||
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
|
||||
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
|
||||
fi
|
||||
install_offered_cli "${offer_idx[$((cli_choice - 1))]}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -2033,7 +2188,8 @@ run_step() {
|
||||
success "$label ($(fmt_elapsed $((SECONDS - start))))"
|
||||
return 0
|
||||
fi
|
||||
error "$label failed (exit $rc). Last lines of $LOG_FILE:"
|
||||
error "$label failed (exit $rc)."
|
||||
echo "Last lines of $LOG_FILE:" >&2
|
||||
tail -n 25 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' >&2
|
||||
return "$rc"
|
||||
}
|
||||
@@ -3723,6 +3879,7 @@ main() {
|
||||
# ========================================================================
|
||||
# Ask everything up front, so the build afterwards can run unattended
|
||||
# ========================================================================
|
||||
progress_phase runtime
|
||||
ask_dependencies "$os" "$distro"
|
||||
|
||||
# AI CLI: needs a human choice AND node (installed just above), so it sits
|
||||
@@ -3732,6 +3889,7 @@ main() {
|
||||
CLI_DETECT_DONE=""
|
||||
detect_all_clis
|
||||
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
|
||||
progress_phase agent
|
||||
offer_ai_cli_install
|
||||
fi
|
||||
|
||||
@@ -3746,8 +3904,11 @@ main() {
|
||||
# ========================================================================
|
||||
info "Installing Codeman to $INSTALL_DIR (a few minutes; you can leave this running)"
|
||||
INSTALL_STARTED="1"
|
||||
progress_phase source
|
||||
install_or_update_repo
|
||||
progress_phase packages
|
||||
npm_install_deps
|
||||
progress_phase build
|
||||
run_step "Building Codeman" npm run build || exit 1
|
||||
finish_source_swap
|
||||
install_symlink
|
||||
@@ -3758,6 +3919,7 @@ main() {
|
||||
# (symlinks, PATH, launch menu) instead of silently "updating".
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
|
||||
|
||||
progress_phase service
|
||||
local service_ok="true"
|
||||
if [[ "$LAUNCH_CHOICE" == "2" ]]; then
|
||||
if [[ "$SERVICE_TYPE" == "launchd" ]]; then
|
||||
@@ -3782,6 +3944,7 @@ main() {
|
||||
# Security notice: last informational block so it stays visible (when not
|
||||
# auto-launching below; if we exec, the server prints the same notice anyway).
|
||||
print_security_notice
|
||||
progress_phase done
|
||||
|
||||
# Run now in foreground (must be last: exec replaces the shell)
|
||||
if [[ "$LAUNCH_CHOICE" == "1" ]]; then
|
||||
@@ -4520,6 +4683,65 @@ uninstall() {
|
||||
echo ""
|
||||
}
|
||||
|
||||
# json_string <text>: <text> as one JSON string. Control characters that JSON
|
||||
# has no short form for are dropped; nothing printed here needs them.
|
||||
json_string() {
|
||||
local text
|
||||
text=$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\010\013\014\016-\037')
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\t'/\\t}"
|
||||
text="${text//$'\r'/\\r}"
|
||||
text="${text//$'\n'/\\n}"
|
||||
printf '"%s"' "$text"
|
||||
}
|
||||
|
||||
json_bool() {
|
||||
if [[ "$1" == "1" ]]; then printf 'true'; else printf 'false'; fi
|
||||
}
|
||||
|
||||
# What is on this machine and what an install would do here, as one JSON object
|
||||
# on stdout: the desktop app shows its install choices from this. Prompts for
|
||||
# nothing, changes nothing, and never runs sudo beyond resolve_admin_mode's
|
||||
# `sudo -n`. With --no-admin, `blocker` is why that route cannot finish here
|
||||
# (empty when it can). `clis` lists the same entries as the AI CLI menu, with
|
||||
# the same rule for `installable`, plus the ones already installed.
|
||||
#
|
||||
# `schema` is bumped only when a field changes meaning: the app is released on
|
||||
# its own, and refuses a number it does not know.
|
||||
preflight_subcommand() {
|
||||
local os arch need_node="0" need_tmux="0" need_git="0" blocker="" i first="1"
|
||||
os=$(detect_os)
|
||||
arch=$(detect_arch)
|
||||
check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first."
|
||||
check_node || need_node="1"
|
||||
check_tmux || need_tmux="1"
|
||||
check_git || need_git="1"
|
||||
detect_all_clis
|
||||
if [[ "$NO_ADMIN" == "1" ]]; then
|
||||
blocker=$(no_admin_blocker "$os" "$arch" "$need_node" "$need_tmux")
|
||||
fi
|
||||
|
||||
printf '{"schema":1,"os":%s,"arch":%s,"noAdmin":%s,"blocker":%s' \
|
||||
"$(json_string "$os")" "$(json_string "$arch")" "$(json_bool "$NO_ADMIN")" "$(json_string "$blocker")"
|
||||
printf ',"needs":{"node":%s,"tmux":%s,"git":%s},"clis":[' \
|
||||
"$(json_bool "$need_node")" "$(json_bool "$need_tmux")" "$(json_bool "$need_git")"
|
||||
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
|
||||
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
|
||||
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
|
||||
local installable="0" installed="0"
|
||||
if cli_offerable "$i"; then installable="1"; fi
|
||||
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || installed="1"
|
||||
[[ "$first" == "1" ]] || printf ','
|
||||
first="0"
|
||||
printf '{"id":%s,"label":%s,"installed":%s,"installable":%s,"docs":%s}' \
|
||||
"$(json_string "${CLI_IDS[$i]}")" "$(json_string "${CLI_LABELS[$i]}")" \
|
||||
"$(json_bool "$installed")" "$(json_bool "$installable")" "$(json_string "${CLI_DOCS[$i]}")"
|
||||
done
|
||||
printf ']}\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat << 'EOF'
|
||||
Codeman installer
|
||||
@@ -4536,6 +4758,8 @@ Flags
|
||||
--port <n> Port Codeman listens on (default 3000)
|
||||
--no-admin Never use sudo or Homebrew: Node.js and tmux go into the
|
||||
home folder (chosen by itself without administrator rights)
|
||||
--ai-cli <id>|none Which AI CLI to install when none is found, without the menu
|
||||
--progress Also print machine-readable progress lines
|
||||
--help, -h This text
|
||||
|
||||
Subcommands
|
||||
@@ -4545,11 +4769,13 @@ Subcommands
|
||||
name [<n>] Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
status Print the URLs, the QR code and how to manage the service
|
||||
cloudflared Install cloudflared for the in-app Cloudflare tunnel
|
||||
preflight Print what is on this machine as JSON; changes nothing
|
||||
agent <id> Install one AI CLI and nothing else, also next to a running Codeman
|
||||
|
||||
Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST,
|
||||
CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME,
|
||||
CODEMAN_SKIP_SYSTEMD=1, CODEMAN_NODE_VERSION, CODEMAN_REPO_URL, CODEMAN_BRANCH,
|
||||
CODEMAN_NO_ADMIN=1 or 0, CODEMAN_TARBALL_URL.
|
||||
CODEMAN_NO_ADMIN=1 or 0, CODEMAN_TARBALL_URL, CODEMAN_AI_CLI, CODEMAN_PROGRESS=1.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -4577,6 +4803,12 @@ parse_flags() {
|
||||
--no-start) LAUNCH_PRESET="3" ;;
|
||||
--yes|-y) ASSUME_YES="1" ;;
|
||||
--no-admin) NO_ADMIN="1" ;;
|
||||
--ai-cli)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || die "--ai-cli needs a value (a catalogue id, or none)"
|
||||
AI_CLI_CHOICE="$1" ;;
|
||||
--ai-cli=*) AI_CLI_CHOICE="${1#--ai-cli=}" ;;
|
||||
--progress) PROGRESS="1" ;;
|
||||
--password)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || die "--password needs a value"
|
||||
@@ -4588,13 +4820,15 @@ parse_flags() {
|
||||
CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;;
|
||||
--port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
update|uninstall|tailscale|name|status|cloudflared)
|
||||
update|uninstall|tailscale|name|status|cloudflared|preflight|agent)
|
||||
[[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)."
|
||||
SUBCOMMAND="$1" ;;
|
||||
-*) die "Unknown option: $1 (see --help)" ;;
|
||||
*)
|
||||
if [[ "$SUBCOMMAND" == "name" && -z "$SUBCOMMAND_ARG" ]]; then
|
||||
SUBCOMMAND_ARG="$1"
|
||||
elif [[ "$SUBCOMMAND" == "agent" && -z "$AI_CLI_CHOICE" ]]; then
|
||||
AI_CLI_CHOICE="$1"
|
||||
else
|
||||
die "Unexpected argument: $1 (see --help)"
|
||||
fi ;;
|
||||
@@ -4604,6 +4838,9 @@ parse_flags() {
|
||||
if [[ -n "${CODEMAN_PORT:-}" ]] && ! [[ "$CODEMAN_PORT" =~ ^[0-9]+$ && "$CODEMAN_PORT" -ge 1 && "$CODEMAN_PORT" -le 65535 ]]; then
|
||||
die "Invalid port: $CODEMAN_PORT"
|
||||
fi
|
||||
if [[ -n "$AI_CLI_CHOICE" && "$AI_CLI_CHOICE" != "none" ]] && ! cli_index "$AI_CLI_CHOICE" >/dev/null; then
|
||||
die "Unknown AI CLI: $AI_CLI_CHOICE (use none, or one of: $(cli_catalog_ids))"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -4628,6 +4865,8 @@ case "$SUBCOMMAND" in
|
||||
name) setup_name_subcommand ;;
|
||||
status) status_subcommand ;;
|
||||
cloudflared) cloudflared_subcommand ;;
|
||||
preflight) preflight_subcommand ;;
|
||||
agent) agent_subcommand ;;
|
||||
*)
|
||||
# Only a COMPLETED install re-runs as a quiet update. A partial one
|
||||
# (clone succeeded but build/menu never finished) lacks the marker and
|
||||
|
||||
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'",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
dist/
|
||||
Generated
+3135
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,93 @@
|
||||
[package]
|
||||
name = "codeman-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.90"
|
||||
description = "Codeman Desktop: a small native window for a Codeman server"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "codeman-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Window + event loop (tao), the system web view (wry: WKWebView on macOS,
|
||||
# WebView2 on Windows, WebKitGTK on Linux) and native menus (muda). Tauri is
|
||||
# built on these three; using them directly keeps the app small and gives us
|
||||
# `load_url_with_headers`, which Tauri's navigate() does not have (see README).
|
||||
tao = "0.37"
|
||||
wry = { version = "0.57", features = ["devtools"] }
|
||||
muda = "0.21"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
# The reachability probe. platform-verifier = the OS trust store, the same one
|
||||
# WKWebView uses, so the probe and the window agree on which certificates pass.
|
||||
ureq = { version = "3", features = ["platform-verifier"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
# Ignoring signals in the logo blinker (src/logo.rs). Already in the tree
|
||||
# through wry and tao, so no new crate is added.
|
||||
libc = "0.2"
|
||||
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
|
||||
# content size (src/macos.rs). The versions wry already pulls in, so no new
|
||||
# crate is added.
|
||||
objc2 = { version = "0.6.5", default-features = false, features = ["std"] }
|
||||
block2 = { version = "0.6.2", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"block2",
|
||||
"NSBundle",
|
||||
"NSDate",
|
||||
"NSDictionary",
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"NSString",
|
||||
"NSURL",
|
||||
"NSURLAuthenticationChallenge",
|
||||
"NSURLCredential",
|
||||
"NSURLProtectionSpace",
|
||||
"NSURLRequest",
|
||||
"NSURLResponse",
|
||||
"NSURLSession",
|
||||
"NSValue",
|
||||
] }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"NSAlert",
|
||||
"NSApplication",
|
||||
"NSButton",
|
||||
"NSControl",
|
||||
"NSResponder",
|
||||
"NSTextField",
|
||||
"NSView",
|
||||
"NSWindow",
|
||||
] }
|
||||
objc2-web-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"block2",
|
||||
"objc2-app-kit",
|
||||
"WKFrameInfo",
|
||||
"WKNavigationDelegate",
|
||||
"WKSecurityOrigin",
|
||||
"WKUIDelegate",
|
||||
"WKWebView",
|
||||
"WKWebsiteDataRecord",
|
||||
"WKWebsiteDataStore",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,426 @@
|
||||
# Codeman Desktop
|
||||
|
||||
A small native app that opens Codeman in its own window. Double-click it, and
|
||||
it runs Codeman on this Mac (installing it first if it is not there) or
|
||||
connects to Codeman on another machine (installing it there over SSH if
|
||||
needed), and Codeman fills the window. `⌃⌘F` puts it in full screen.
|
||||
|
||||
macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
|
||||
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
|
||||
Windows and Linux later.
|
||||
|
||||
- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
|
||||
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
||||
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
||||
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
||||
for what wry leaves out on macOS (JavaScript dialogs, HTTP Basic login) and for
|
||||
the lid logo. No bundled browser and no Node in the app.
|
||||
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
|
||||
|
||||
## Why it is cheap
|
||||
|
||||
Codeman is already a web app, so the desktop app is a native window around the
|
||||
operating system's own web view. An Electron build of the same thing would ship
|
||||
a whole Chromium (around 150 MB). The native side only adds what a browser tab
|
||||
cannot do.
|
||||
|
||||
## What it does (v0.1)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac 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. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). The tab also finds every Codeman server of yours that already runs on this Mac (with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, a beta instance beside the installed one) and recommends connecting to it. Each one's address is worked out from its listening socket and its command line (`127.0.0.1` for a bind to loopback or to every interface, its own address for a bind to one, `https` with `--https`, the path of `--base-url`), and each is asked once whether Codeman answers there. When nothing answers at the usual address (the LaunchAgent's, or `http://127.0.0.1:3000` without one), *Open Codeman* is for the first found server that does; when none of them can be opened, the tab says *Codeman is already running on this Mac* and lists them. Next to a Codeman that answers, the others are listed under *Also running on this Mac*. A row shows the address, the version, and a *Connect* button, or the reason the app cannot open that one (HTTPS with Codeman's own certificate, no answer). *Connect* is the connect of the Another machine tab with that address filled in: the same check, the login saved for it, the same entry under recent servers. Finding a server connects to nothing by itself. The tab never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. |
|
||||
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
|
||||
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac (or connects to the one found here, when that is the tab's main button) and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
|
||||
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
|
||||
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
|
||||
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. |
|
||||
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
|
||||
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
|
||||
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. Each pop-out opens a step down and to the right of the window in use, never exactly on top of another one. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
|
||||
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
|
||||
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
|
||||
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
|
||||
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
|
||||
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
|
||||
|
||||
## Build (on a Mac)
|
||||
|
||||
Needs Rust 1.90 or newer ([rustup](https://rustup.rs); `rustup update` for an older
|
||||
toolchain) and the Xcode Command Line Tools (`xcode-select --install`).
|
||||
|
||||
```bash
|
||||
cd packages/desktop
|
||||
cargo test # unit tests
|
||||
./scripts/bundle-macos.sh # dist/Codeman.app for this Mac's architecture
|
||||
./scripts/bundle-macos.sh --universal --dmg # Apple Silicon + Intel, plus dist/Codeman-<version>.dmg
|
||||
open dist/Codeman.app
|
||||
```
|
||||
|
||||
The connect page has its own test, which needs no Mac: from the repository
|
||||
root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives
|
||||
`assets/connect.html` in Chromium through every state of both tabs, under the
|
||||
Content-Security-Policy the app serves it with. It is part of the nightly
|
||||
browser suite, not of `npm test`.
|
||||
|
||||
To try a change to `install.sh` before it is merged, build with
|
||||
`CODEMAN_DESKTOP_INSTALLER_URL` (where to get the installer) and
|
||||
`CODEMAN_DESKTOP_INSTALL_BRANCH` (the branch it installs from) set. Both are
|
||||
read at compile time: a shipped app cannot be pointed at another installer.
|
||||
|
||||
The script builds with `--locked` against the committed `Cargo.lock`, targets
|
||||
the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
|
||||
takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a
|
||||
cargo config's `build.target-dir` work.
|
||||
|
||||
Settings live in `~/Library/Application Support/Codeman/desktop.json`; passwords
|
||||
in the Keychain under *Codeman Desktop*, one item per username and server. A
|
||||
`desktop.json` the app cannot read or parse is moved aside, never overwritten:
|
||||
to `desktop.json.corrupt`, or to the first free name of `desktop.json.corrupt.1`
|
||||
to `.corrupt.9` when that one is taken, so an earlier copy is kept. If all ten
|
||||
names are taken, or the file cannot be moved, the app starts with default
|
||||
settings and saves nothing, which leaves the file where it is. When a server's
|
||||
username changes (a change of letter case counts), or a server drops off the
|
||||
recent list (it keeps 8), the Keychain item of the old username is deleted.
|
||||
|
||||
## Decisions, and why
|
||||
|
||||
**tao + wry + muda directly, not Tauri.** Tauri is built on the same three crates.
|
||||
The deciding detail is login: Codeman's password protection is HTTP Basic, and
|
||||
a WKWebView shows no login dialog of its own (wry 0.57 implements no
|
||||
authentication-challenge handler). So the app sends the `Authorization` header
|
||||
itself on the first load, with wry's `load_url_with_headers` (Tauri 2.12's
|
||||
`navigate()` takes only a URL), and from then on answers the HTTP Basic
|
||||
challenges of Codeman's own pages with the current login. Codeman keeps its
|
||||
session cookies in memory, so after a server restart that answer is what keeps
|
||||
the page logged in. Using the crates directly is also smaller.
|
||||
|
||||
**What wry leaves out is added at runtime, not patched in.** `src/macos.rs` adds
|
||||
the JavaScript dialog methods and the authentication-challenge method to the
|
||||
classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
||||
|
||||
**What a page may ask of the app** (all in `src/policy.rs`, unit-tested):
|
||||
|
||||
- Codeman shows content it did not write on its own origin: rendered HTML files
|
||||
(`/html-view/`) and proxied dashboards (`/webview/`). Only Codeman's own app
|
||||
shell (the dashboard and a session pop-out) may use `window.CodemanHost`.
|
||||
That is an allowlist on the raw path, and a path holding any `%` fails it, so
|
||||
an encoded spelling or a route Codeman adds later stays out.
|
||||
- An app window opens only for Codeman's own pop-out pages under the server's
|
||||
base path, checked after decoding the path once, and which ones depends on
|
||||
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
||||
rendered HTML views, the raw, preview, attachment and folder file routes, the
|
||||
path picker's preview and `api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
||||
on from any frame without saying which, gets only the file routes and
|
||||
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
||||
HTML file could open copies of itself as top-level windows that pass for the
|
||||
app's own. Such a window does not take keyboard focus, and these windows
|
||||
have a budget of their own (3 at once, then 1 per second), apart from the
|
||||
one links draw on. Any other page on the server's origin is refused rather
|
||||
than sent to the browser: a top-level `/webview/` load would escape the web
|
||||
tab's iframe sandbox in either place, so it never opens anywhere.
|
||||
`window.open('')` is refused too, since WebKit's blank window carries none of
|
||||
the app's handlers.
|
||||
- wry's navigation handler sees every frame and does not say which one. So a
|
||||
navigation to another site is blocked rather than opened in the browser;
|
||||
otherwise a page full of external iframes could open browser tabs. A real
|
||||
link click is sent out by the injected script in the main frame only.
|
||||
- Only `http(s)` URLs ever reach the default browser, at most 3 at once and
|
||||
then 1 per second. macOS lets a page call `window.open` without a click, so
|
||||
this keeps a script from opening a browser tab for every call.
|
||||
- The `Authorization` header is only sent to URLs under the server's base path.
|
||||
The login answers a Basic challenge only when it is Codeman's own: the
|
||||
server's scheme, host and port (never a proxy's), a URL below the base path,
|
||||
and not a web tab (`webview/`) or a rendered HTML file (`html-view/`), which
|
||||
relay 401s from content Codeman did not write. Only such a challenge can
|
||||
send the user back to the connect screen. WebKit is handed the login without
|
||||
persistence, so it keeps no copy: every challenge gets the current login,
|
||||
and one from before a Switch Server is never replayed.
|
||||
- Each connect starts by clearing the app's cookies (never local storage).
|
||||
Codeman checks its session cookie before the `Authorization` header, so an
|
||||
earlier user's cookie would otherwise win over the new login.
|
||||
- Microphone and camera requests prompt per page. wry's default without a
|
||||
handler is to grant them to every frame, sandboxed previews included.
|
||||
- The bundled connect page keeps working until a server page commits in the
|
||||
main window (it is still the live page while the server loads), and only
|
||||
the main window may talk to it.
|
||||
- `codeman web -d` gets the port and base path as separate arguments, never as
|
||||
shell text.
|
||||
|
||||
**The app runs the installer itself; Terminal is the fallback.** `install.sh
|
||||
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
|
||||
no password and therefore no terminal, and the two questions only a person can
|
||||
answer arrive as flags (`src/gui_install.rs`). The app downloads the installer
|
||||
once to a private folder, asks it what is on this Mac (`install.sh preflight`,
|
||||
which prints JSON and changes nothing) and draws its choices from that answer.
|
||||
So the app has no list of coding agents of its own: a new one appears without a
|
||||
new app, and the list always matches what the installer can really install
|
||||
here. The installer runs with the login shell's environment, so it finds what
|
||||
this person already has. The page sends only the agent's id, whether other
|
||||
devices may reach Codeman, and that password; the password reaches the
|
||||
installer through its environment, never its command line, where `ps` shows it.
|
||||
|
||||
**The install outlives the app.** Its output goes to a file, never a pipe, and
|
||||
it runs in a process group of its own. With a pipe, quitting the app would kill
|
||||
the installer at its next write; in the app's group, launchd would kill it with
|
||||
the app. The file holds the whole run, so a page that was reloaded, or an app
|
||||
that was opened again, finds the process in `ps` and replays the file from the
|
||||
first line. That is why the app keeps no state about a run. A run that ends
|
||||
without the installer's last `done` line failed, and the first error it reported
|
||||
is the one shown: the ones after it are consequences.
|
||||
|
||||
**Adding an agent is a run of its own.** `install.sh agent <id>` installs one
|
||||
agent and touches neither Codeman nor its service, so it may run next to a live
|
||||
server, which an install must refuse: the installer's service step would replace
|
||||
that server under its sessions. It has its own temporary folder, so `ps` tells the
|
||||
two apart: everything that reads a running install takes it to mean "Codeman is
|
||||
being installed", which an agent run does not. One run goes at a time, since both
|
||||
can write to the runtime folder. It asks `preflight` for the list like an install
|
||||
does, and both pass `--no-admin`: that flag decides which agents count as
|
||||
installable, so without it on both the page could offer one the run refuses. The
|
||||
menu item is on the connect page because that is where the app may run things:
|
||||
from a Codeman page it leaves the page the way *Switch Server* does.
|
||||
|
||||
**Terminal stays for what the app cannot do.** The route with Homebrew asks for
|
||||
the administrator password on a terminal, the Xcode tools arrive as a system
|
||||
dialog, and ssh asks about host keys and passwords on a terminal too. The app
|
||||
writes a short script to the per-user temporary folder (mode 0700, created
|
||||
fresh, deleted by the script as it starts) and opens it with `open -b
|
||||
com.apple.Terminal`, which needs no permission; scripting Terminal with
|
||||
osascript would ask for Automation access. The script prints what it runs. A
|
||||
This Mac install downloads the installer to a temporary file and runs it from
|
||||
there, so the installer's input is the terminal. Everything in the scripts is
|
||||
built and unit-tested in `src/setup.rs`; the page sends only which install it
|
||||
wants, whether other devices should reach it, and the SSH login, which is
|
||||
checked before it is quoted.
|
||||
|
||||
**This Mac's own login comes from where Codeman keeps it.** A Codeman
|
||||
installed with a password keeps it in its LaunchAgent (`install.sh`) or in
|
||||
`~/.codeman/.env` (what `codeman attach`, `codeman tui` and `codeman agent`
|
||||
read). The app reads the same places, so a password-protected Codeman on this
|
||||
Mac opens without typing the password. The username and password always come
|
||||
from the same place, since the server reads its username from its own
|
||||
environment. The login goes only to `127.0.0.1` on that Codeman's port, and
|
||||
only while `lsof` shows one of your own Codeman processes holding that port:
|
||||
with Codeman stopped, whatever else listens there never gets it.
|
||||
|
||||
**Never a second server on the same sessions.** Two Codeman servers on one
|
||||
data directory and tmux socket attach to each other's live sessions, and
|
||||
`codeman web -d` only notices a server it started itself or one at the same
|
||||
address. So the app looks for running Codeman servers of this user (`ps`)
|
||||
before it offers or runs a start, and offers to connect to the ones it finds
|
||||
instead (`found_addresses` in `src/setup.rs`, unit-tested).
|
||||
|
||||
**Closing the main window quits the app.** Codeman's sessions keep running on
|
||||
the server, so nothing is lost, and reopening takes about a second.
|
||||
|
||||
**One reachability probe per user action, and one to check a refused login.**
|
||||
Codeman counts a request without valid credentials against its login rate
|
||||
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
||||
never retries in a loop. A look at this Mac asks each Codeman server found
|
||||
here once; the app has a login only for the one at the usual address, so the
|
||||
others are asked without one. Once the server refuses the login in a challenge, the
|
||||
app sends it only once more, in the single probe that checks the refusal. If
|
||||
that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
|
||||
restart, each request that arrives without a cookie gets a 401 before the
|
||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||
at once can push a request past 10, and that request then fails with a 429
|
||||
instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
|
||||
**The logo blinks from a child process, through CoreBrightness.** Apple documents
|
||||
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
|
||||
for every blink would need a root helper and an administrator password. The same
|
||||
setting is also a property of `BrightnessSystemClient` in the private
|
||||
CoreBrightness framework, and a process of the logged-in user can set it there,
|
||||
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
|
||||
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
|
||||
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
|
||||
keeps its value while the logo is dark.
|
||||
|
||||
The setting is system-wide and survives a reboot, so the app must not end with the
|
||||
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
|
||||
app can be relied on. So all of it runs as this same binary with
|
||||
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
|
||||
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
|
||||
back and exits. Switching it off closes the same pipe, so the path a crash
|
||||
takes is the one used every day. A marker file
|
||||
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
|
||||
at the next launch the app puts back a logo a blink left dark. The framework is
|
||||
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
|
||||
answers nil) is what hides the option, so a macOS that drops it loses this feature
|
||||
and nothing else.
|
||||
|
||||
**Red and yellow come from the server's approvals list.** `GET /api/approvals` is what the
|
||||
page reads to rebuild its red and yellow tabs after a reload, and it follows the same rules:
|
||||
`permission` and `question` items are red, an `idle` item is yellow until someone has looked
|
||||
at it (`acknowledgedAt`). So the logo stops when the tab's colour goes. The request is skipped
|
||||
when neither alert has a style, and a server from before the list reads as no alerts. Each
|
||||
poll with a dialog on screen costs that server one tmux capture, because the list re-checks
|
||||
the dialog, which also clears red soon after a dialog is answered in the terminal.
|
||||
|
||||
**The agent count is asked for natively, because the page cannot be trusted with it.**
|
||||
The first version read the page's own event stream, which needs no second connection
|
||||
and no login. On a real Mac it failed in both directions as soon as the window was
|
||||
covered, because WebKit stalls the stream of a hidden page: a turn that started while
|
||||
it was stalled was never seen and the logo stayed still, and in another run the logo
|
||||
kept blinking 39 seconds after the agent finished, until the page's watchdog reopened
|
||||
the stream. A hidden window is the normal case for a signal meant to be read from
|
||||
across the room, so `src/agents.rs` asks `api/sessions` itself, from the child process,
|
||||
every two seconds. That needs care with the login. A request with a good password and
|
||||
no session cookie makes Codeman create a login session, it keeps 100 of them, and the
|
||||
oldest goes first, so polling with the password alone would log every browser out
|
||||
within minutes. The poller therefore sends the cookie of the first answer with every
|
||||
request, which Codeman reads first, so one session is created per server start. A
|
||||
refused login ends the polling for good, because Codeman counts each refusal against
|
||||
the login rate limit. The app and the child share nothing but the pipe the login
|
||||
crosses, so the login is never on a command line.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **A new agent can take a few minutes to show up in an older Codeman.** The server
|
||||
remembers that a CLI is missing, for up to 5 minutes after it last looked
|
||||
(`cliResolveRetryDelayMs`), so that a status poll never spawns a login shell. The
|
||||
installer's `cli-installed` marker ends that at once, but only for a server that
|
||||
knows the marker, so the screen after *Add a coding agent* still mentions it. An
|
||||
open Codeman page always needs a reload, because the list of installed agents is
|
||||
part of the page.
|
||||
- **Agents the app can add are the ones `--no-admin` can install.** OMP's macOS
|
||||
install is Homebrew, so it is not offered, also on a Mac that has Homebrew.
|
||||
- **Agents that install with npm use whichever Node.js the login shell finds.**
|
||||
With the Node.js the installer downloaded, or Homebrew's, that folder is yours.
|
||||
The nodejs.org installer leaves `/usr/local/lib/node_modules` to root, so those
|
||||
agents would be expected to fail there, with npm's permission error shown, while
|
||||
the ones with their own installer still work. This has not been tried.
|
||||
- **Only your own Codeman servers on this Mac are found, by their command
|
||||
line.** A server another macOS user runs, or one in a container or a virtual
|
||||
machine, is not listed: connect to it by address under Another machine. A
|
||||
base path that a server started by hand has only in its environment
|
||||
(`CODEMAN_BASE_URL`) is not visible either, so its row has the address
|
||||
without the path; the LaunchAgent's base path is read from its job.
|
||||
- **A found server that is slow to answer shows as not answering.** Next to a
|
||||
Codeman that answers, each other server gets two seconds (five when nothing
|
||||
answers at the usual address). A password-protected one counts the
|
||||
look as a failed login, like any look at a server the app has no login for.
|
||||
*Check again* asks again.
|
||||
|
||||
- **The logo blink rests on a private macOS interface.** Apple documents the
|
||||
setting, not the way the app reaches it, so a macOS update can take it away;
|
||||
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
||||
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
||||
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
||||
setting is on or off, so these styles are rhythms of hard changes. After a blink
|
||||
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
|
||||
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
|
||||
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
||||
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
||||
keep-alive turn blinks too.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). 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. Marking the certificate as trusted in
|
||||
Keychain Access can only help at `https://localhost` or `https://127.0.0.1`,
|
||||
the only names Codeman's certificate carries, and may not work even there.
|
||||
The connect screen says so when it happens.
|
||||
- **Keychain prompt after every rebuild of an ad-hoc build.** The Keychain
|
||||
knows the app that saved a password by its code signature, and an ad-hoc
|
||||
signature changes with every build and update. So each new build asks once
|
||||
for access to the saved passwords: choose *Always Allow*. All Keychain work
|
||||
runs on one background thread, so the prompt does not freeze the window
|
||||
while it waits for an answer. To keep one identity across builds, sign with
|
||||
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
|
||||
is enough). Microphone and camera permissions are asked again for the same
|
||||
reason.
|
||||
- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you
|
||||
made yourself opens normally; a copy that arrived by download or AirDrop is
|
||||
quarantined. On macOS 15 and later, try to open it once, then click *Open
|
||||
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. 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
|
||||
while the question is open: connect again after answering. If it was denied,
|
||||
turn Codeman on in System Settings > Privacy & Security > Local Network. A
|
||||
server started with *Start Codeman on this Mac* counts as part of the app
|
||||
here, so the same answer covers what it reaches on the local network (ssh to
|
||||
LAN hosts, Wake-on-LAN, web tabs pointing at LAN dashboards), also after the
|
||||
app quits.
|
||||
- **Subagent transcript windows do not open in the app.** Codeman writes that
|
||||
view into a blank `window.open('')` window, which the app refuses (see
|
||||
Decisions), and shows a toast instead. A Codeman without the null check in
|
||||
`viewSubagentTranscript` (`src/web/public/panels-ui.js`) reports *Failed to
|
||||
load transcript* there. Open the transcript from a browser tab.
|
||||
- **Dialogs are app-modal.** While a JavaScript dialog is open, every Codeman
|
||||
window waits for it. A page that calls `alert()` in a loop (a rendered HTML
|
||||
preview or a web tab can) leaves no way out but Force Quit (`⌥⌘⎋`).
|
||||
- **Codeman needs the WebKit of Safari 16.4.** The app itself runs on macOS
|
||||
11.3 or later (downloads go through WKDownload, which arrived in 11.3), but
|
||||
Codeman's terminal (xterm.js 6 uses class static blocks) needs the WebKit
|
||||
that came with Safari 16.4: macOS 13.3, or macOS 11 or 12 with Safari 16.4
|
||||
or later installed. On an older WebKit the connect screen says so and does
|
||||
not connect. Session pop-outs also need BroadcastChannel (Safari 15.4),
|
||||
which the same requirement covers.
|
||||
- **Plain links in rendered HTML and web tabs do not open.** A link to another
|
||||
site inside a rendered HTML preview or a web tab does nothing, in the
|
||||
preview overlay, and for a rendered HTML file in its pop-out window too: the
|
||||
app blocks navigation to other sites in every frame (see Decisions). A link
|
||||
there that asks for a new window (`target="_blank"`, or a script's
|
||||
`window.open`) does open, in your default browser, within the budget of 3 at
|
||||
once and then 1 per second.
|
||||
- **No login dialog for a web tab's own HTTP Basic login.** The app answers
|
||||
only Codeman's own login, and WebKit shows no dialog for any other, so a
|
||||
dashboard in a web tab that asks for HTTP Basic shows its 401 page.
|
||||
- **Downloads from previews and web tabs are saved without asking.** Rendered
|
||||
HTML files and web tabs are allowed to start downloads, and the app saves
|
||||
every download to `~/Downloads` without a prompt. Asking first for those
|
||||
frames is not done yet.
|
||||
- **No desktop notifications yet.** WKWebView has no Web Notification API.
|
||||
Native notifications are the first item on the list below.
|
||||
- **No service worker.** Codeman works without it; it is only an offline cache.
|
||||
- **The graphical install 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 says so and offers Terminal, where Homebrew
|
||||
installs it (that takes an administrator once).
|
||||
- **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.
|
||||
- **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
|
||||
it again is harmless; *Skip for now* leaves it as it is.
|
||||
- **Installing from the app is macOS only so far**, and installing on another
|
||||
machine needs ssh access to it and `curl` or `wget` there. The app does not
|
||||
know when a remote install is done or which address it chose: the installer
|
||||
prints the address, and you connect to it under Another machine.
|
||||
- **A This Mac install always sets Codeman up as a LaunchAgent** that starts
|
||||
at login. To stop it: `launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist`;
|
||||
to remove Codeman: `curl -fsSL https://getcodeman.com/install | bash -s -- uninstall`.
|
||||
- **Windows and Linux** need their menu hookup (`init_for_hwnd`, GTK), a
|
||||
credential store (Credential Manager, Secret Service) and installers. The
|
||||
rest of the code is platform-neutral.
|
||||
|
||||
## Ideas for next
|
||||
|
||||
- Native notifications and a Dock badge with the number of sessions waiting
|
||||
for you (tao has `set_badge_label`; Codeman already tracks which sessions need input).
|
||||
- A global hotkey that brings Codeman to the front from anywhere.
|
||||
- A menu bar icon showing session states.
|
||||
- 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, auto-update.
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.getcodeman.desktop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>codeman-desktop</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<!-- 11.3 is where WKDownload arrived; wry saves files through it. Below
|
||||
that a Download link replaces the window or saves nothing.
|
||||
scripts/bundle-macos.sh builds for this version too. -->
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.3</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>MIT License, Codeman Contributors</string>
|
||||
<!-- Voice dictation and gesture control ask for these; without the
|
||||
strings macOS kills the app the first time a page requests them. -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Codeman uses the microphone for voice dictation when you turn it on.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Codeman uses the camera for hand-gesture control when you turn it on.</string>
|
||||
<!-- macOS 15 asks before an app reaches the local network (a LAN address,
|
||||
a .local name), and shows this text in that question. A process the
|
||||
app starts counts as the app (Apple TN3179), so a server started with
|
||||
"Start Codeman on this Mac" runs under the same answer. -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Codeman connects to Codeman servers on your local network. A Codeman server you start from this app uses the same permission for what it reaches on the network.</string>
|
||||
<!-- App Transport Security would block plain http:// to a host NAME
|
||||
(my-mac.local, a LAN box). IP literals are exempt, which is why
|
||||
http://127.0.0.1 works without this. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Only used with a Developer ID (SIGN_IDENTITY=...), which turns on the
|
||||
hardened runtime; there, microphone and camera need these. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 120
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Codeman.app (and optionally a .dmg) from packages/desktop, on macOS.
|
||||
#
|
||||
# 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
|
||||
# and later, try to open it once, then click Open Anyway in System Settings >
|
||||
# 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")/.."
|
||||
[ "$(uname -s)" = Darwin ] || { echo "bundle-macos.sh runs on macOS only" >&2; exit 1; }
|
||||
|
||||
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; }
|
||||
|
||||
# The binary targets the same macOS as LSMinimumSystemVersion in Info.plist,
|
||||
# so the two cannot disagree.
|
||||
MACOSX_DEPLOYMENT_TARGET=$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' macos/Info.plist)
|
||||
[ -n "$MACOSX_DEPLOYMENT_TARGET" ] || { echo "could not read LSMinimumSystemVersion from macos/Info.plist" >&2; exit 1; }
|
||||
export MACOSX_DEPLOYMENT_TARGET
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Build with cargo and set BUILT to the executable cargo reports for
|
||||
# codeman-desktop. Asking cargo, rather than assuming ./target, keeps
|
||||
# CARGO_TARGET_DIR, build.target-dir and build.target from a cargo config
|
||||
# working, and a stale binary from an older build is never bundled.
|
||||
cargo_build() {
|
||||
local json
|
||||
json=$(cargo build --locked --release "$@" --message-format=json-render-diagnostics)
|
||||
BUILT=$(printf '%s\n' "$json" | awk '
|
||||
index($0, "\"reason\":\"compiler-artifact\"") &&
|
||||
index($0, "\"kind\":[\"bin\"]") &&
|
||||
index($0, "\"name\":\"codeman-desktop\"") &&
|
||||
match($0, /"executable":"[^"]*"/) { path = substr($0, RSTART + 14, RLENGTH - 15) }
|
||||
END { print path }')
|
||||
if [ -z "$BUILT" ] || [ ! -x "$BUILT" ]; then
|
||||
echo "cargo did not report a codeman-desktop executable${BUILT:+ (it named $BUILT)}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$UNIVERSAL" = 1 ]; then
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin >/dev/null
|
||||
cargo_build --target aarch64-apple-darwin
|
||||
ARM_BIN=$BUILT
|
||||
cargo_build --target x86_64-apple-darwin
|
||||
INTEL_BIN=$BUILT
|
||||
BIN="$WORK/codeman-desktop-universal"
|
||||
lipo -create -output "$BIN" "$ARM_BIN" "$INTEL_BIN"
|
||||
else
|
||||
cargo_build
|
||||
BIN=$BUILT
|
||||
fi
|
||||
|
||||
APP=dist/Codeman.app
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/codeman-desktop"
|
||||
sed "s/__VERSION__/$VERSION/g" macos/Info.plist > "$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist" >/dev/null
|
||||
printf 'APPL????' > "$APP/Contents/PkgInfo"
|
||||
|
||||
# assets/icon.png (1024x1024, already on Apple's icon grid) -> Codeman.icns
|
||||
ICONSET="$WORK/Codeman.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" assets/icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
double=$((size * 2))
|
||||
sips -z "$double" "$double" assets/icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/Codeman.icns"
|
||||
|
||||
if [ -n "${SIGN_IDENTITY:-}" ]; then
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements macos/entitlements.plist --sign "$SIGN_IDENTITY" "$APP"
|
||||
else
|
||||
codesign --force --sign - "$APP"
|
||||
fi
|
||||
codesign --verify --strict "$APP"
|
||||
|
||||
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
|
||||
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
./scripts/sign-macos.sh "$APP" dist
|
||||
elif [ "$DMG" = 1 ]; then
|
||||
DMG_PATH="dist/Codeman-$VERSION.dmg"
|
||||
./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")"
|
||||
@@ -0,0 +1,462 @@
|
||||
//! What the agents on a server are doing, asked from outside the web view: at
|
||||
//! work, finished with nobody having looked yet (a yellow tab), or stopped on a
|
||||
//! permission or a question (a red tab).
|
||||
//!
|
||||
//! The page knows this too, but it cannot be relied on for it: WebKit stalls the
|
||||
//! event stream of a hidden page. Measured on macOS 27.2 with the page hidden: a
|
||||
//! `session:idle` the server sent never reached it, its own watchdog reopened the
|
||||
//! stream 39 seconds later, and in another run a whole turn went unseen. A hidden
|
||||
//! window is the normal case for this question, so it is asked natively, of
|
||||
//! `api/sessions`, a stable route that works with any Codeman, and of
|
||||
//! `api/approvals`, the list the page itself builds its red and yellow tabs from
|
||||
//! when it loads. A server from before that list has no such route, which reads
|
||||
//! as no alerts.
|
||||
//!
|
||||
//! A login has a price here that a single request does not show. A request with
|
||||
//! a good password and no session cookie makes Codeman create a login session,
|
||||
//! it keeps 100 of them, and the oldest goes first, so asking every two seconds
|
||||
//! with the password alone would log every browser out within minutes. So the
|
||||
//! cookie of the first answer goes along from then on, and Codeman reads the
|
||||
//! cookie first: the password is only used when the cookie has lapsed (a server
|
||||
//! restart), and then it creates the one new session that replaces it. Sending
|
||||
//! both also means a stale cookie costs no failed login. A refused login ends
|
||||
//! the polling for good, since Codeman counts each one against the login rate
|
||||
//! limit (see `probe`).
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
/// How long the logo can lag behind the server. The list is cached for a
|
||||
/// second on the server's side and is a few kilobytes.
|
||||
pub const POLL_EVERY: Duration = Duration::from_secs(2);
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SESSIONS_PATH: &str = "api/sessions";
|
||||
const APPROVALS_PATH: &str = "api/approvals";
|
||||
const COOKIE_NAME: &str = "codeman_session";
|
||||
|
||||
/// What the agents on a server are doing. Any number of these can be true.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Activity {
|
||||
/// At least one agent is at work.
|
||||
pub working: bool,
|
||||
/// An agent finished and nobody has looked at it yet: a yellow tab.
|
||||
pub waiting: bool,
|
||||
/// An agent stopped on a permission or a question: a red tab.
|
||||
pub needs_you: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Poll {
|
||||
Seen(Activity),
|
||||
/// No answer this time. Worth asking again.
|
||||
Failed,
|
||||
/// The server turned the login down. Never ask again.
|
||||
Refused,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
mode: String,
|
||||
}
|
||||
|
||||
/// Codeman wraps the list in its API envelope, and older servers sent it bare.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Sessions {
|
||||
Envelope { data: Vec<Session> },
|
||||
Bare(Vec<Session>),
|
||||
}
|
||||
|
||||
/// Agents only: a shell tab with a `tail -f` is a run of output, which the
|
||||
/// server may call busy, and that is nobody's agent.
|
||||
fn count_working(body: &str) -> Option<u32> {
|
||||
let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) = serde_json::from_str(body).ok()?;
|
||||
let working = sessions.iter().filter(|s| s.status == "busy" && s.mode != "shell");
|
||||
u32::try_from(working.count()).ok()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Approval {
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
#[serde(default, rename = "acknowledgedAt")]
|
||||
acknowledged_at: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Approvals {
|
||||
approvals: Vec<Approval>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApprovalsAnswer {
|
||||
data: Approvals,
|
||||
}
|
||||
|
||||
/// The two alerts, by the page's own rules: `permission_prompt` and a question
|
||||
/// make a tab red, an idle prompt makes it yellow until someone has looked at it,
|
||||
/// and an acknowledged item stays listed but alerts nobody.
|
||||
fn read_alerts(body: &str) -> Option<(bool, bool)> {
|
||||
let answer: ApprovalsAnswer = serde_json::from_str(body).ok()?;
|
||||
let items = &answer.data.approvals;
|
||||
let waiting = items.iter().any(|i| i.kind == "idle" && i.acknowledged_at.is_none());
|
||||
let needs_you = items.iter().any(|i| i.kind == "permission" || i.kind == "question");
|
||||
Some((waiting, needs_you))
|
||||
}
|
||||
|
||||
/// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie.
|
||||
fn session_cookie(header: &str) -> Option<&str> {
|
||||
let pair = header.split(';').next()?.trim();
|
||||
let (name, value) = pair.split_once('=')?;
|
||||
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
|
||||
}
|
||||
|
||||
/// What a status code says about asking again.
|
||||
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
|
||||
match status {
|
||||
200 => body()
|
||||
.as_deref()
|
||||
.and_then(count_working)
|
||||
.map_or(Poll::Failed, |working| {
|
||||
Poll::Seen(Activity {
|
||||
working: working > 0,
|
||||
..Activity::default()
|
||||
})
|
||||
}),
|
||||
401 | 403 | 429 => Poll::Refused,
|
||||
_ => Poll::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Poller {
|
||||
agent: ureq::Agent,
|
||||
sessions_url: String,
|
||||
/// None when no alert has a style: that request is then never made.
|
||||
approvals_url: Option<String>,
|
||||
authorization: Option<String>,
|
||||
cookie: Option<String>,
|
||||
/// The last alerts seen, kept through a request that failed.
|
||||
alerts: (bool, bool),
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
pub fn new(server: &Url, authorization: Option<String>, alerts: bool) -> Option<Self> {
|
||||
let approvals_url = match alerts {
|
||||
true => Some(server.join(APPROVALS_PATH).ok()?.to_string()),
|
||||
false => None,
|
||||
};
|
||||
Some(Poller {
|
||||
agent: crate::probe::agent(TIMEOUT),
|
||||
sessions_url: server.join(SESSIONS_PATH).ok()?.to_string(),
|
||||
approvals_url,
|
||||
authorization,
|
||||
cookie: None,
|
||||
alerts: (false, false),
|
||||
})
|
||||
}
|
||||
|
||||
/// One GET with the login, and the status with the body of a 200.
|
||||
fn ask(&mut self, url: &str) -> Option<(u16, Option<String>)> {
|
||||
let mut request = self.agent.get(url).header("Accept", "application/json");
|
||||
if let Some(authorization) = &self.authorization {
|
||||
request = request.header("Authorization", authorization);
|
||||
}
|
||||
if let Some(cookie) = &self.cookie {
|
||||
request = request.header("Cookie", cookie);
|
||||
}
|
||||
let mut response = request.call().ok()?;
|
||||
let fresh = response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(session_cookie)
|
||||
.map(str::to_owned);
|
||||
if fresh.is_some() {
|
||||
self.cookie = fresh;
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let body = (status == 200).then(|| {
|
||||
let body = response.body_mut().with_config();
|
||||
body.limit(crate::probe::MAX_BODY).read_to_string().ok()
|
||||
});
|
||||
Some((status, body.flatten()))
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> Poll {
|
||||
let url = self.sessions_url.clone();
|
||||
let Some((status, body)) = self.ask(&url) else {
|
||||
return Poll::Failed;
|
||||
};
|
||||
let mut activity = match answer(status, || body) {
|
||||
Poll::Seen(activity) => activity,
|
||||
other => return other,
|
||||
};
|
||||
if let Some(url) = self.approvals_url.clone() {
|
||||
match self.ask(&url) {
|
||||
Some((200, body)) => {
|
||||
if let Some(alerts) = body.as_deref().and_then(read_alerts) {
|
||||
self.alerts = alerts;
|
||||
}
|
||||
}
|
||||
Some((401 | 403 | 429, _)) => return Poll::Refused,
|
||||
// A server from before the list has no alerts to report.
|
||||
Some((404, _)) => self.alerts = (false, false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(activity.waiting, activity.needs_you) = self.alerts;
|
||||
Poll::Seen(activity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The start of a real answer of `GET /api/sessions`, captured from a
|
||||
/// server: other fields around the two that matter, in Codeman's envelope.
|
||||
const REAL_ANSWER: &str = r#"{"success":true,"data":[{"id":"0f8d0215-661e-4a1c-bfad-d7f6fd2ed4b5",
|
||||
"pid":61017,"status":"busy","workingDir":"/Users/lee/codeman-cases/testcase",
|
||||
"paneCwd":"/Users/lee/codeman-cases/testcase","currentTaskId":null,"mode":"opencode"}]}"#;
|
||||
|
||||
#[test]
|
||||
fn reads_the_answer_a_real_server_gives() {
|
||||
assert_eq!(count_working(REAL_ANSWER), Some(1));
|
||||
assert_eq!(count_working(r#"{"success":true,"data":[]}"#), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_agents_at_work_and_nothing_else() {
|
||||
let body = r#"{"success":true,"data":[
|
||||
{"id":"a","status":"busy","mode":"claude"},
|
||||
{"id":"b","status":"busy","mode":"codex"},
|
||||
{"id":"c","status":"idle","mode":"claude"},
|
||||
{"id":"d","status":"busy","mode":"shell"},
|
||||
{"id":"e","status":"stopped","mode":"claude"},
|
||||
{"id":"f","status":"error","mode":"opencode"}
|
||||
]}"#;
|
||||
assert_eq!(count_working(body), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_bare_list_of_an_older_server() {
|
||||
assert_eq!(count_working(r#"[{"status":"busy","mode":"claude"}]"#), Some(1));
|
||||
assert_eq!(count_working("[]"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_without_a_mode_counts_and_one_without_a_status_does_not() {
|
||||
assert_eq!(
|
||||
count_working(r#"{"data":[{"status":"busy"},{"mode":"claude"}]}"#),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_that_is_not_the_list_is_no_answer() {
|
||||
assert_eq!(count_working("<html>"), None);
|
||||
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
|
||||
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
|
||||
assert_eq!(answer(200, || None), Poll::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_login_refusal_ends_the_polling() {
|
||||
for status in [401, 403, 429] {
|
||||
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
|
||||
}
|
||||
for status in [301, 404, 500, 502, 503] {
|
||||
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_codemans_cookie_in_a_set_cookie_header() {
|
||||
assert_eq!(
|
||||
session_cookie("codeman_session=abc123; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax"),
|
||||
Some("codeman_session=abc123")
|
||||
);
|
||||
assert_eq!(session_cookie("codeman_session=abc123"), Some("codeman_session=abc123"));
|
||||
assert_eq!(session_cookie("other=1; Path=/"), None);
|
||||
assert_eq!(session_cookie("codeman_session=; Max-Age=0"), None);
|
||||
assert_eq!(session_cookie(""), None);
|
||||
}
|
||||
|
||||
/// A server that answers each connection with the next canned response
|
||||
/// and records the headers it was sent.
|
||||
fn serve(responses: Vec<String>) -> (u16, Arc<Mutex<Vec<Vec<String>>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for response in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut headers = Vec::new();
|
||||
let mut line = String::new();
|
||||
while reader.read_line(&mut line).is_ok() && line.trim() != "" {
|
||||
headers.push(line.trim().to_ascii_lowercase());
|
||||
line.clear();
|
||||
}
|
||||
log.lock().unwrap().push(headers);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
(port, seen)
|
||||
}
|
||||
|
||||
const EMPTY: &str = r#"{"success":true,"data":[]}"#;
|
||||
|
||||
fn plain(working: bool) -> Poll {
|
||||
Poll::Seen(Activity {
|
||||
working,
|
||||
..Activity::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The shape of a real `GET /api/approvals`, with the frame text cut.
|
||||
fn approvals(items: &[&str]) -> String {
|
||||
format!(r#"{{"success":true,"data":{{"approvals":[{}]}}}}"#, items.join(","))
|
||||
}
|
||||
const IDLE: &str = r#"{"id":"s:1","sessionId":"s","sessionName":"w4","kind":"idle","createdAt":1,"context":"x"}"#;
|
||||
const LOOKED_AT: &str = r#"{"id":"s:2","sessionId":"s","kind":"idle","createdAt":1,"acknowledgedAt":2}"#;
|
||||
const PERMISSION: &str = r#"{"id":"s:3","sessionId":"s","kind":"permission","createdAt":1,"toolName":"Bash"}"#;
|
||||
const QUESTION: &str = r#"{"id":"s:4","sessionId":"s","kind":"question","createdAt":1}"#;
|
||||
|
||||
#[test]
|
||||
fn the_alerts_follow_the_pages_rules() {
|
||||
assert_eq!(read_alerts(&approvals(&[])), Some((false, false)));
|
||||
assert_eq!(read_alerts(&approvals(&[IDLE])), Some((true, false)));
|
||||
assert_eq!(read_alerts(&approvals(&[LOOKED_AT])), Some((false, false)), "seen");
|
||||
assert_eq!(read_alerts(&approvals(&[PERMISSION])), Some((false, true)));
|
||||
assert_eq!(read_alerts(&approvals(&[QUESTION])), Some((false, true)));
|
||||
assert_eq!(read_alerts(&approvals(&[IDLE, PERMISSION])), Some((true, true)));
|
||||
assert_eq!(read_alerts("not json"), None);
|
||||
assert_eq!(read_alerts(EMPTY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_all_three_states_from_one_poll() {
|
||||
let busy = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
|
||||
let (port, seen_requests) = serve(vec![ok("", busy), ok("", &approvals(&[IDLE, PERMISSION]))]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
let mut poller = Poller::new(&server, None, true).unwrap();
|
||||
let everything = Activity {
|
||||
working: true,
|
||||
waiting: true,
|
||||
needs_you: true,
|
||||
};
|
||||
assert_eq!(poller.poll(), Poll::Seen(everything));
|
||||
assert!(seen_requests.lock().unwrap()[1][0].starts_with("get /api/approvals "));
|
||||
}
|
||||
|
||||
fn status(code: u16) -> String {
|
||||
format!("HTTP/1.1 {code} X\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_the_list_still_shows_work() {
|
||||
let busy = r#"{"data":[{"status":"busy","mode":"claude"}]}"#;
|
||||
let (port, _) = serve(vec![ok("", busy), status(404)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), plain(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_list_ends_the_polling_and_a_failed_one_keeps_the_last_answer() {
|
||||
let (port, _) = serve(vec![ok("", EMPTY), status(401)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), Poll::Refused);
|
||||
|
||||
let (port, _) = serve(vec![
|
||||
ok("", EMPTY),
|
||||
ok("", &approvals(&[PERMISSION])),
|
||||
ok("", EMPTY),
|
||||
status(500),
|
||||
]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
let mut poller = Poller::new(&server, None, true).unwrap();
|
||||
let red = Poll::Seen(Activity {
|
||||
needs_you: true,
|
||||
..Activity::default()
|
||||
});
|
||||
assert_eq!(poller.poll(), red);
|
||||
assert_eq!(
|
||||
poller.poll(),
|
||||
Poll::Seen(Activity {
|
||||
needs_you: true,
|
||||
..Activity::default()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alert_with_a_style_means_no_second_request() {
|
||||
let (port, seen_requests) = serve(vec![ok("", EMPTY)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
|
||||
assert_eq!(seen_requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn ok(extra_headers: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{extra_headers}\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_one_login_session_across_polls() {
|
||||
let body = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
|
||||
let (port, seen) = serve(vec![
|
||||
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
|
||||
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
|
||||
ok("Set-Cookie: codeman_session=second; Max-Age=86400; Path=/\r\n", EMPTY),
|
||||
ok("", EMPTY),
|
||||
]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
let mut poller = Poller::new(&server, Some("Basic abc".into()), false).unwrap();
|
||||
assert_eq!(poller.poll(), plain(true));
|
||||
assert_eq!(poller.poll(), plain(true));
|
||||
assert_eq!(poller.poll(), plain(false));
|
||||
assert_eq!(poller.poll(), plain(false));
|
||||
|
||||
let seen = seen.lock().unwrap();
|
||||
let cookie = |n: usize| seen[n].iter().find(|h| h.starts_with("cookie:")).cloned();
|
||||
assert_eq!(cookie(0), None);
|
||||
assert_eq!(cookie(1).as_deref(), Some("cookie: codeman_session=first"));
|
||||
assert_eq!(cookie(2).as_deref(), Some("cookie: codeman_session=first"));
|
||||
// A server restart hands out a new one; a response without one keeps it.
|
||||
assert_eq!(cookie(3).as_deref(), Some("cookie: codeman_session=second"));
|
||||
assert!(seen.iter().all(|h| h.contains(&"authorization: basic abc".to_string())));
|
||||
assert!(seen[0][0].starts_with("get /api/sessions "), "{:?}", seen[0][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asks_under_the_servers_base_path() {
|
||||
let (port, seen) = serve(vec![ok("", EMPTY)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/codeman/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
|
||||
let seen = seen.lock().unwrap();
|
||||
assert!(seen[0][0].starts_with("get /codeman/api/sessions "), "{:?}", seen[0][0]);
|
||||
assert!(!seen[0].iter().any(|h| h.starts_with("authorization:")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_answers_nothing_is_only_a_failed_poll() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), Poll::Failed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Files bundled into the binary: the connect page, its icon, and the script
|
||||
//! injected into every Codeman page (`window.CodemanHost`).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use wry::http::{header::CONTENT_TYPE, Request, Response, StatusCode};
|
||||
|
||||
const CONNECT_HTML: &[u8] = include_bytes!("../assets/connect.html");
|
||||
const ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
|
||||
|
||||
/// The connect page loads nothing from outside the app.
|
||||
const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src codemanapp:";
|
||||
|
||||
/// Serve `codemanapp://localhost/<path>`.
|
||||
pub fn respond(request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
|
||||
let (body, mime): (&'static [u8], &str) = match request.uri().path() {
|
||||
"/" | "/index.html" => (CONNECT_HTML, "text/html; charset=utf-8"),
|
||||
"/icon.png" => (ICON_PNG, "image/png"),
|
||||
_ => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Cow::Borrowed(&b"not found"[..]))
|
||||
.expect("static response");
|
||||
}
|
||||
};
|
||||
Response::builder()
|
||||
.header(CONTENT_TYPE, mime)
|
||||
.header("Content-Security-Policy", CSP)
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(Cow::Borrowed(body))
|
||||
.expect("static response")
|
||||
}
|
||||
|
||||
/// Injected at document start into the MAIN frame of every page (wry's
|
||||
/// default), where it does nothing unless the page is http(s). It provides the
|
||||
/// native-wrapper bridge Codeman's frontend already looks for
|
||||
/// (`window.CodemanHost.openWindow/closeWindow/focusWindow`, see
|
||||
/// `openInHostWindow` in src/web/public/app.js), so pop-outs become real app
|
||||
/// windows. `openWindow` answers false, posting nothing, for a URL the native
|
||||
/// side would drop (not http(s), or longer than its 4096-byte cap), so
|
||||
/// Codeman shows its own error instead of marking a session detached with no
|
||||
/// window behind it. Besides that it:
|
||||
///
|
||||
/// - turns a click on a same-origin download link (`download=true`, or a path
|
||||
/// ending in `/download`) into a real download by giving the link a
|
||||
/// `download` attribute. Codeman's file tree and attachment cards link to
|
||||
/// `file-raw?download=true` without one, and the server sends a type WebKit
|
||||
/// can show (png, pdf, txt), so WebKit would otherwise replace the Codeman
|
||||
/// window with the file, with no way back. This runs in the capture phase,
|
||||
/// before the file tree's own `stopPropagation`.
|
||||
/// - does the same for `window.open` of such a `download=true` URL (the
|
||||
/// attachment history), returning null instead of a window.
|
||||
/// - sends clicks on links to other sites to the default browser, after
|
||||
/// Codeman's own handlers have had their turn (bubble phase, skipped when
|
||||
/// one of them called preventDefault). Middle clicks and modifier clicks
|
||||
/// count too: a bare WKWebView has no new-tab gesture of its own, so they
|
||||
/// would do nothing.
|
||||
/// - reports the first same-origin 401 a `fetch` gets (`sessionExpired`),
|
||||
/// once per page load: the session cookie is gone, usually because the
|
||||
/// server restarted, and nothing in the page asks for the login again.
|
||||
/// - defines `__codemanDesktopRefused(id)` (not enumerable) for
|
||||
/// [`refused_script`].
|
||||
pub const HOST_SHIM: &str = r#"(function () {
|
||||
'use strict';
|
||||
if (!/^https?:$/.test(location.protocol) || window.CodemanHost) return;
|
||||
var MAX_URL = 4096;
|
||||
var post = function (msg) {
|
||||
try { window.ipc.postMessage(JSON.stringify(msg)); return true; } catch (e) { return false; }
|
||||
};
|
||||
// An http(s) URL object, or null. fetch and window.open resolve against
|
||||
// the document base (Codeman sets <base href>), so those pass it.
|
||||
var httpUrl = function (raw, base) {
|
||||
try {
|
||||
var u = new URL(String(raw), base || location.href);
|
||||
return /^https?:$/.test(u.protocol) ? u : null;
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
var sameOrigin = function (u) { return !!u && u.origin === location.origin; };
|
||||
var link = function (ev) {
|
||||
var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
|
||||
return a && typeof a.href === 'string' && !a.hasAttribute('download') ? a : null;
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'CodemanHost', {
|
||||
value: Object.freeze({
|
||||
desktop: true,
|
||||
openWindow: function (url) {
|
||||
var u = httpUrl(url);
|
||||
return u && u.href.length <= MAX_URL ? post({ cmd: 'openWindow', url: u.href }) : false;
|
||||
},
|
||||
closeWindow: function () { post({ cmd: 'closeWindow' }); },
|
||||
focusWindow: function () { post({ cmd: 'focusWindow' }); }
|
||||
})
|
||||
});
|
||||
|
||||
// The native side could not open a window: say so. `id` is a string only
|
||||
// when CodemanHost.openWindow asked for that session's pop-out; that session
|
||||
// goes back to the dashboard (a fresh channel object, so this window's own
|
||||
// channel hears it too). Anything else, null included, only shows the toast.
|
||||
Object.defineProperty(window, '__codemanDesktopRefused', {
|
||||
value: function (id) {
|
||||
try {
|
||||
if (window.app && typeof window.app.showToast === 'function') {
|
||||
window.app.showToast('Could not open a new window', 'error');
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (typeof id === 'string' && id !== '' && typeof BroadcastChannel === 'function') {
|
||||
var ch = new BroadcastChannel('codeman-windows');
|
||||
ch.postMessage({ type: 'redocked', id: id });
|
||||
setTimeout(function () { try { ch.close(); } catch (e) {} }, 2000);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
var markDownload = function (ev) {
|
||||
if (ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (sameOrigin(u) && (u.searchParams.get('download') === 'true' || /\/download$/.test(u.pathname))) {
|
||||
a.setAttribute('download', '');
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', markDownload, true);
|
||||
window.addEventListener('auxclick', markDownload, true);
|
||||
|
||||
var lastMiddle = null;
|
||||
var openOutside = function (ev) {
|
||||
if (ev.defaultPrevented || ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (!u || sameOrigin(u)) return;
|
||||
ev.preventDefault();
|
||||
// Older WebKit sends a middle click as 'click', newer as 'auxclick'; one
|
||||
// that sent both must still open the link once.
|
||||
if (ev.button === 1) {
|
||||
if (lastMiddle && lastMiddle.a === a && ev.timeStamp - lastMiddle.t < 500) return;
|
||||
lastMiddle = { a: a, t: ev.timeStamp };
|
||||
}
|
||||
post({ cmd: 'openExternal', url: u.href });
|
||||
};
|
||||
window.addEventListener('click', openOutside, false);
|
||||
window.addEventListener('auxclick', openOutside, false);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
if (typeof nativeOpen === 'function') {
|
||||
window.open = function open(url) {
|
||||
try {
|
||||
var u = arguments.length && url !== undefined ? httpUrl(url, document.baseURI) : null;
|
||||
if (sameOrigin(u) && u.searchParams.get('download') === 'true') {
|
||||
var a = document.createElement('a');
|
||||
a.href = u.href;
|
||||
a.setAttribute('download', '');
|
||||
a.click();
|
||||
return null;
|
||||
}
|
||||
} catch (e) {}
|
||||
return nativeOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the promise fetch itself returned, so callers see exactly what
|
||||
// they saw before; the 401 check hangs off a side branch that swallows its
|
||||
// own errors and never reads the body.
|
||||
var expired = false;
|
||||
var nativeFetch = window.fetch;
|
||||
if (typeof nativeFetch === 'function') {
|
||||
var check = function (input, r) {
|
||||
if (expired || !r || r.status !== 401) return;
|
||||
var raw = r.url || (input && typeof input === 'object' && typeof input.url === 'string' ? input.url : input);
|
||||
if (!sameOrigin(httpUrl(raw, document.baseURI))) return;
|
||||
expired = true;
|
||||
post({ cmd: 'sessionExpired' });
|
||||
};
|
||||
window.fetch = function fetch(input) {
|
||||
var p = nativeFetch.apply(this, arguments);
|
||||
try {
|
||||
if (!expired && p && typeof p.then === 'function') {
|
||||
p.then(function (r) { try { check(input, r); } catch (e) {} }, function () {});
|
||||
}
|
||||
} catch (e) {}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
})();"#;
|
||||
|
||||
/// A toast inside Codeman (its own `showToast`; nothing happens on a page
|
||||
/// without it). `kind` is one of Codeman's fixed toast kinds.
|
||||
pub fn toast_script(text: &str, kind: &str) -> String {
|
||||
let text = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
|
||||
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, \"{kind}\"); }} catch (e) {{}}")
|
||||
}
|
||||
|
||||
/// A toast inside Codeman when a download finished.
|
||||
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
|
||||
let text = if ok {
|
||||
format!("Downloaded {file_name} to Downloads")
|
||||
} else {
|
||||
format!("Download of {file_name} failed")
|
||||
};
|
||||
toast_script(&text, if ok { "success" } else { "error" })
|
||||
}
|
||||
|
||||
/// Runs in the window that asked for a window the native side did not open
|
||||
/// (pop-out limit reached, policy refused it, window or web view failed to
|
||||
/// build): the page shows an error toast. Does nothing on a page without the
|
||||
/// shim.
|
||||
///
|
||||
/// `session_id` is the decoded id of a session pop-out
|
||||
/// (`policy::session_id_of`), and only for a `CodemanHost.openWindow` request,
|
||||
/// which comes from a trusted frame: that session then gets its tab back in
|
||||
/// the dashboard. Pass `None` for every other refusal, above all the ones a
|
||||
/// `window.open` or `target=_blank` from any frame caused, so a page cannot
|
||||
/// make the dashboard drop the detached state of a session that is still
|
||||
/// popped out.
|
||||
pub fn refused_script(session_id: Option<&str>) -> String {
|
||||
let id = serde_json::to_string(&session_id).unwrap_or_else(|_| "null".into());
|
||||
format!(
|
||||
"try {{ if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused({id}); }} catch (e) {{}}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get(path: &str) -> Response<Cow<'static, [u8]>> {
|
||||
respond(
|
||||
Request::builder()
|
||||
.uri(format!("codemanapp://localhost{path}"))
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_page_and_icon_only() {
|
||||
let page = get("/index.html");
|
||||
assert_eq!(page.status(), StatusCode::OK);
|
||||
assert!(page.headers()["content-type"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("text/html"));
|
||||
assert!(page.headers().contains_key("content-security-policy"));
|
||||
assert!(String::from_utf8_lossy(page.body()).contains("window.__codemanDesktop"));
|
||||
assert_eq!(get("/icon.png").status(), StatusCode::OK);
|
||||
assert_eq!(get("/../etc/passwd").status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(get("/other.js").status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_escapes_the_session_id() {
|
||||
let js = refused_script(Some("a'\"); alert(1); //\u{2028}\\"));
|
||||
assert!(js.starts_with("try { if (typeof window.__codemanDesktopRefused === 'function') "));
|
||||
assert!(js.contains(r#"window.__codemanDesktopRefused("a'\"); alert(1); //"#));
|
||||
assert!(js.contains("\u{2028}\\\\\"); } catch (e) {}"));
|
||||
assert!(js.ends_with(" } catch (e) {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_without_a_session_passes_null() {
|
||||
assert_eq!(
|
||||
refused_script(None),
|
||||
"try { if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused(null); } catch (e) {}"
|
||||
);
|
||||
assert!(refused_script(Some("")).contains("__codemanDesktopRefused(\"\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shim_keeps_its_contract() {
|
||||
for needle in [
|
||||
"Object.freeze",
|
||||
"openWindow",
|
||||
"closeWindow",
|
||||
"focusWindow",
|
||||
"__codemanDesktopRefused",
|
||||
"sessionExpired",
|
||||
"'auxclick'",
|
||||
"MAX_URL = 4096",
|
||||
"typeof id === 'string' && id !== ''",
|
||||
] {
|
||||
assert!(HOST_SHIM.contains(needle), "{needle}");
|
||||
}
|
||||
// The refused hook takes the session id the native side resolved; it
|
||||
// never parses or decodes a URL to find one.
|
||||
assert!(!HOST_SHIM.contains("decodeURIComponent"));
|
||||
}
|
||||
|
||||
/// The connect page refuses what the app would drop without an answer, so
|
||||
/// its two limits have to be the app's own.
|
||||
#[test]
|
||||
fn connect_page_keeps_the_message_limits() {
|
||||
let page = String::from_utf8_lossy(CONNECT_HTML);
|
||||
for needle in [
|
||||
format!("var MAX_URL = {};", crate::policy::MAX_URL_LEN),
|
||||
format!("var MAX_FIELD = {};", crate::policy::MAX_FIELD_LEN),
|
||||
] {
|
||||
assert!(page.contains(&needle), "{needle}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy puts setup::INSTALL_ONE_LINER on the clipboard, and the page shows
|
||||
/// the command as text of its own: the two have to be the same command.
|
||||
#[test]
|
||||
fn connect_page_shows_the_command_that_copy_copies() {
|
||||
let page = String::from_utf8_lossy(CONNECT_HTML);
|
||||
let shown = format!("<code id=\"oneliner\">{}</code>", crate::setup::INSTALL_ONE_LINER);
|
||||
assert!(page.contains(&shown), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_escapes_file_names() {
|
||||
let js = download_toast_script("a\"); alert(1); (\".txt", true);
|
||||
assert!(js.contains(r#"Downloaded a\"); alert(1); (\".txt to Downloads"#));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
//! The app's own settings: recent servers, two preferences and the window
|
||||
//! frame. Stored as JSON next to other per-app data
|
||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||
//! never go here; they live in the Keychain (see `credentials`).
|
||||
|
||||
use crate::logo::BlinkStyles;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_SERVERS: usize = 8;
|
||||
/// How many recovery copies of an unreadable config can sit side by side:
|
||||
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
|
||||
const ASIDE_SLOTS: usize = 10;
|
||||
const MIN_WIDTH: f64 = 480.0;
|
||||
const MIN_HEIGHT: f64 = 360.0;
|
||||
const MAX_EDGE: f64 = 20_000.0;
|
||||
|
||||
/// Height of a standard title bar, in points.
|
||||
const TITLE_BAR: f64 = 28.0;
|
||||
/// How far the outer top of the window lies above `WindowFrame::y`. On macOS
|
||||
/// `y` is the content area's top (tao's `with_position` puts the content rect
|
||||
/// there and the title bar above it); elsewhere `y` is already the outer top.
|
||||
const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0 };
|
||||
/// How much of the title bar must be on a screen to grab it.
|
||||
const MIN_GRIP: f64 = 80.0;
|
||||
/// Room `fitted_to` leaves when it shrinks a window. Screens are full display
|
||||
/// bounds, so the menu bar and the Dock are inside them.
|
||||
const FIT_MARGIN_X: f64 = 40.0;
|
||||
const FIT_MARGIN_Y: f64 = 80.0;
|
||||
/// Room a first-run window leaves above its content (the menu bar, taller on
|
||||
/// a display with a notch, and its own title bar) and below it (a Dock of the
|
||||
/// default size): `Rect` is the whole display, so neither is taken off yet.
|
||||
const FIRST_RUN_ROOM_ABOVE: f64 = 66.0;
|
||||
const FIRST_RUN_ROOM_BELOW: f64 = 90.0;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
/// Most recent first.
|
||||
pub servers: Vec<ServerEntry>,
|
||||
/// Connect to the most recent server at launch.
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
/// What the lit Apple logo does for each state of the agents (logo.rs). The
|
||||
/// menu only shows on a Mac that has one. A value this version does not know
|
||||
/// reads as off: a bad style must not make the whole file unreadable.
|
||||
#[serde(deserialize_with = "lenient_blink")]
|
||||
pub logo_blink: BlinkStyles,
|
||||
/// Anything that is not a whole frame reads as None: a bad frame must not
|
||||
/// make the whole file unreadable, which would cost the server list.
|
||||
#[serde(deserialize_with = "lenient_window")]
|
||||
pub window: Option<WindowFrame>,
|
||||
}
|
||||
|
||||
fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<WindowFrame>, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles, D::Error> {
|
||||
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
servers: Vec::new(),
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
logo_blink: BlinkStyles::default(),
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerEntry {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub has_saved_password: bool,
|
||||
}
|
||||
|
||||
/// The main window's position and inner size, in logical (point)
|
||||
/// coordinates. `x`/`y` are where tao's `with_position` wants them: on macOS
|
||||
/// the top-left of the CONTENT area (the title bar, about 28 pt, sits above
|
||||
/// it), elsewhere the window's outer top-left. Saving the outer corner on
|
||||
/// macOS and restoring it as the content corner made the window creep up by
|
||||
/// a title bar on every launch.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowFrame {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
/// A display's full bounds in logical coordinates, as tao reports them. This
|
||||
/// is the whole display, not the work area: the menu bar and the Dock are
|
||||
/// inside it.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl WindowFrame {
|
||||
/// The frame of a first-run window on `screen`: `size` or what fits, never
|
||||
/// less than `min`, centred across and in the height between the menu bar
|
||||
/// and the Dock. Centring in the whole display put the bottom of a window
|
||||
/// under the Dock on a 900 pt high one.
|
||||
pub fn first_run(screen: &Rect, size: (f64, f64), min: (f64, f64)) -> WindowFrame {
|
||||
let width = size.0.min(screen.width - FIT_MARGIN_X).max(min.0);
|
||||
let room = screen.height - FIRST_RUN_ROOM_ABOVE - FIRST_RUN_ROOM_BELOW;
|
||||
let height = size.1.min(room).max(min.1);
|
||||
WindowFrame {
|
||||
x: screen.x + (screen.width - width) / 2.0,
|
||||
y: screen.y + FIRST_RUN_ROOM_ABOVE + ((room - height) / 2.0).max(0.0),
|
||||
width,
|
||||
height,
|
||||
fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame clamped to sane sizes, or None when it holds junk.
|
||||
pub fn sanitized(self) -> Option<WindowFrame> {
|
||||
let vals = [self.x, self.y, self.width, self.height];
|
||||
if vals.iter().any(|v| !v.is_finite()) || self.x.abs() > MAX_EDGE || self.y.abs() > MAX_EDGE {
|
||||
return None;
|
||||
}
|
||||
Some(WindowFrame {
|
||||
width: self.width.clamp(MIN_WIDTH, MAX_EDGE),
|
||||
height: self.height.clamp(MIN_HEIGHT, MAX_EDGE),
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame made to fit the screens it will open on, or None when its
|
||||
/// title bar is on none of them (the caller then falls back to a default
|
||||
/// frame). An edge that no screen covers is pulled onto the screen that
|
||||
/// holds the title bar, shrinking the window when it is too big for it:
|
||||
/// a frame saved on an external display that is gone would otherwise
|
||||
/// open with its right edge and resize corner far off screen. An edge
|
||||
/// that lands on some screen stays put, so a window spanning two
|
||||
/// displays keeps its span. `min` is the window's minimum inner size.
|
||||
pub fn fitted_to(&self, screens: &[Rect], min: (f64, f64)) -> Option<WindowFrame> {
|
||||
let home = *self.title_screen(screens)?;
|
||||
let top = self.outer_top();
|
||||
let outer_height = TITLE_BAR + self.height;
|
||||
let (x, width) = fit_axis(
|
||||
self.x,
|
||||
self.width,
|
||||
(
|
||||
covers_x(screens, self.x, top, top + outer_height),
|
||||
covers_x(screens, self.x + self.width, top, top + outer_height),
|
||||
),
|
||||
(home.x, home.x + home.width),
|
||||
FIT_MARGIN_X,
|
||||
min.0,
|
||||
);
|
||||
let (top, outer_height) = fit_axis(
|
||||
top,
|
||||
outer_height,
|
||||
(
|
||||
covers_y(screens, top, x, x + width),
|
||||
covers_y(screens, top + outer_height, x, x + width),
|
||||
),
|
||||
(home.y, home.y + home.height),
|
||||
FIT_MARGIN_Y - TITLE_BAR,
|
||||
min.1 + TITLE_BAR,
|
||||
);
|
||||
Some(WindowFrame {
|
||||
x,
|
||||
y: top + TITLE_ABOVE_Y,
|
||||
width,
|
||||
height: outer_height - TITLE_BAR,
|
||||
..*self
|
||||
})
|
||||
}
|
||||
|
||||
fn outer_top(&self) -> f64 {
|
||||
self.y - TITLE_ABOVE_Y
|
||||
}
|
||||
|
||||
/// The screen the title bar is on, or None when no screen has enough of
|
||||
/// it to grab (a display unplugged since the last run leaves the frame off
|
||||
/// screen). The middle of the title bar must be inside the screen and at
|
||||
/// least `MIN_GRIP` of the bar's width must overlap it; when several
|
||||
/// qualify, the one holding most of the bar wins.
|
||||
fn title_screen<'a>(&self, screens: &'a [Rect]) -> Option<&'a Rect> {
|
||||
let title_y = self.outer_top() + TITLE_BAR / 2.0;
|
||||
screens
|
||||
.iter()
|
||||
.filter(|m| title_y >= m.y && title_y <= m.y + m.height)
|
||||
.map(|m| {
|
||||
let grip = (self.x + self.width).min(m.x + m.width) - self.x.max(m.x);
|
||||
(m, grip)
|
||||
})
|
||||
.filter(|(_, grip)| *grip >= MIN_GRIP)
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(m, _)| m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the vertical edge at `x` that runs from
|
||||
/// `top` to `bottom`.
|
||||
fn covers_x(screens: &[Rect], x: f64, top: f64, bottom: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| x >= m.x && x <= m.x + m.width && top < m.y + m.height && bottom > m.y)
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the horizontal edge at `y` that runs
|
||||
/// from `left` to `right`.
|
||||
fn covers_y(screens: &[Rect], y: f64, left: f64, right: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| y >= m.y && y <= m.y + m.height && left < m.x + m.width && right > m.x)
|
||||
}
|
||||
|
||||
/// One axis of `WindowFrame::fitted_to`. The window runs from `start` for
|
||||
/// `len`; `covered` says whether some screen holds each end; `home` is the
|
||||
/// title bar screen's range on this axis. An uncovered end is pulled onto
|
||||
/// `home`, and the window shrinks (leaving `margin`, never below `min`) when
|
||||
/// it does not fit; a covered end may stay where it is, on another display.
|
||||
fn fit_axis(start: f64, len: f64, covered: (bool, bool), home: (f64, f64), margin: f64, min: f64) -> (f64, f64) {
|
||||
if covered.0 && covered.1 {
|
||||
return (start, len);
|
||||
}
|
||||
let lo = if covered.0 { start.min(home.0) } else { home.0 };
|
||||
let hi = if covered.1 { (start + len).max(home.1) } else { home.1 };
|
||||
let len = len.min(hi - lo - margin).max(min);
|
||||
// `max` last: a window still too big for the screen keeps its start (the
|
||||
// left edge, or the title bar) on screen rather than its end.
|
||||
let start = start.min(hi - len).max(lo);
|
||||
(start, len)
|
||||
}
|
||||
|
||||
/// The first name an unreadable `desktop.json` can be moved to without
|
||||
/// replacing an earlier recovery copy: `desktop.json.corrupt`, then
|
||||
/// `desktop.json.corrupt.1` to `.9`, next to it.
|
||||
/// A name is free only when nothing at all is there: a file, a directory or
|
||||
/// a dangling symlink left at it is taken, and so is a name that cannot be
|
||||
/// checked. None when every name is taken.
|
||||
fn free_aside_name(path: &Path) -> Option<PathBuf> {
|
||||
(0..ASIDE_SLOTS)
|
||||
.map(|n| match n {
|
||||
0 => path.with_extension("json.corrupt"),
|
||||
n => path.with_extension(format!("json.corrupt.{n}")),
|
||||
})
|
||||
.find(|aside| matches!(fs::symlink_metadata(aside), Err(e) if e.kind() == io::ErrorKind::NotFound))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Where the config lives, or None if the OS has no config directory.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("Codeman").join("desktop.json"))
|
||||
}
|
||||
|
||||
/// Read the config, and say whether saving back to `path` is safe this
|
||||
/// run. A missing file is the default. A file that is there but cannot
|
||||
/// be read (not UTF-8, no permission, a directory, an I/O error) or does
|
||||
/// not parse is moved aside for recovery, and the defaults are used; it
|
||||
/// is never silently overwritten with them. It goes to the first free
|
||||
/// name of `desktop.json.corrupt`, `desktop.json.corrupt.1` to `.9`, so
|
||||
/// a second unreadable file never replaces the copy kept from the first.
|
||||
/// If every name is taken or the move fails, the answer is false: the
|
||||
/// original is still at `path`, and the save at exit must not replace it.
|
||||
pub fn load_from(path: &Path) -> (Config, bool) {
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return (Config::default(), true),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(mut config) = text.and_then(|t| serde_json::from_str::<Config>(&t).ok()) {
|
||||
config.sanitize();
|
||||
return (config, true);
|
||||
}
|
||||
let Some(aside) = free_aside_name(path) else {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read, and {} to .{} are all taken; settings will not be saved this run",
|
||||
path.display(),
|
||||
path.with_extension("json.corrupt").display(),
|
||||
ASIDE_SLOTS - 1
|
||||
);
|
||||
return (Config::default(), false);
|
||||
};
|
||||
match fs::rename(path, &aside) {
|
||||
Ok(()) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read; moved it to {} and started with defaults",
|
||||
path.display(),
|
||||
aside.display()
|
||||
);
|
||||
(Config::default(), true)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read or moved aside ({e}); settings will not be saved this run",
|
||||
path.display()
|
||||
);
|
||||
(Config::default(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves half a file.
|
||||
pub fn save_to(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
|
||||
fs::write(&tmp, text)?;
|
||||
fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
fn sanitize(&mut self) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.servers.retain(|s| !s.url.is_empty() && seen.insert(s.url.clone()));
|
||||
self.servers.truncate(MAX_SERVERS);
|
||||
self.window = self.window.and_then(WindowFrame::sanitized);
|
||||
}
|
||||
|
||||
/// Move (or add) a server to the top of the recent list. Returns the
|
||||
/// Keychain accounts, as (url, username), of the entries with a username
|
||||
/// that fall off the end of the list (it keeps `MAX_SERVERS`), for the
|
||||
/// caller to delete. A different username on `url` itself is not
|
||||
/// reported: the caller deletes that account before it saves the new
|
||||
/// one, comparing the names exactly as the Keychain account string
|
||||
/// does. A delete made after the save could take the new password with
|
||||
/// it on a Keychain that treats two spellings of a name as one item.
|
||||
#[must_use = "the returned Keychain accounts must be deleted"]
|
||||
pub fn remember(&mut self, url: &str, username: Option<&str>, saved: bool) -> Vec<(String, String)> {
|
||||
self.servers.retain(|s| s.url != url);
|
||||
self.servers.insert(
|
||||
0,
|
||||
ServerEntry {
|
||||
url: url.to_string(),
|
||||
username: username.map(str::to_string),
|
||||
has_saved_password: saved,
|
||||
},
|
||||
);
|
||||
let evicted = self.servers.split_off(self.servers.len().min(MAX_SERVERS));
|
||||
evicted.into_iter().filter_map(|s| Some((s.url, s.username?))).collect()
|
||||
}
|
||||
|
||||
pub fn forget(&mut self, url: &str) -> Option<ServerEntry> {
|
||||
let idx = self.servers.iter().position(|s| s.url == url)?;
|
||||
Some(self.servers.remove(idx))
|
||||
}
|
||||
|
||||
pub fn find(&self, url: &str) -> Option<&ServerEntry> {
|
||||
self.servers.iter().find(|s| s.url == url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-test-{}-{name}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn frame(x: f64, y: f64, width: f64, height: f64) -> WindowFrame {
|
||||
WindowFrame {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect {
|
||||
Rect { x, y, width, height }
|
||||
}
|
||||
|
||||
const MIN: (f64, f64) = (MIN_WIDTH, MIN_HEIGHT);
|
||||
|
||||
fn reachable(f: &WindowFrame, screens: &[Rect]) -> bool {
|
||||
f.title_screen(screens).is_some()
|
||||
}
|
||||
|
||||
/// The outer frame (title bar included) as left, top, right, bottom.
|
||||
fn outer(f: &WindowFrame) -> (f64, f64, f64, f64) {
|
||||
let top = f.y - TITLE_ABOVE_Y;
|
||||
(f.x, top, f.x + f.width, top + TITLE_BAR + f.height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_default() {
|
||||
let dir = tmp_dir("missing");
|
||||
let (c, can_save) = Config::load_from(&dir.join("desktop.json"));
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.auto_connect);
|
||||
assert!(can_save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logo_blink_styles_round_trip_and_a_bad_one_reads_as_off() {
|
||||
use crate::logo::BlinkStyle;
|
||||
let mut c = Config::default();
|
||||
assert_eq!(c.logo_blink, BlinkStyles::default());
|
||||
c.logo_blink.needs_you = BlinkStyle::Alarm;
|
||||
let text = serde_json::to_string(&c).unwrap();
|
||||
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
|
||||
for bad in [
|
||||
r#"{"logoBlink":3}"#,
|
||||
r#"{"logoBlink":null}"#,
|
||||
r#"{"logoBlink":"strobe"}"#,
|
||||
] {
|
||||
let c: Config = serde_json::from_str(bad).unwrap();
|
||||
assert_eq!(c.logo_blink, BlinkStyles::default(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
|
||||
use crate::logo::BlinkStyle;
|
||||
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
|
||||
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_atomic_save() {
|
||||
let dir = tmp_dir("roundtrip");
|
||||
let path = dir.join("nested").join("desktop.json");
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember("http://127.0.0.1:3000/", None, false).is_empty());
|
||||
assert!(c.remember("https://box.ts.net/", Some("admin"), true).is_empty());
|
||||
c.start_fullscreen = true;
|
||||
c.window = Some(WindowFrame {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
width: 1200.0,
|
||||
height: 800.0,
|
||||
fullscreen: true,
|
||||
});
|
||||
c.save_to(&path).unwrap();
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
let (back, can_save) = Config::load_from(&path);
|
||||
assert!(can_save);
|
||||
assert_eq!(back, c);
|
||||
assert_eq!(back.servers[0].url, "https://box.ts.net/");
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
!text.to_lowercase().contains("password\":\""),
|
||||
"no password values on disk"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_moved_aside() {
|
||||
let dir = tmp_dir("corrupt");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_file_is_moved_aside() {
|
||||
let dir = tmp_dir("non-utf8");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
// Valid JSON apart from a Latin-1 "ü", which is not UTF-8.
|
||||
let bytes = b"{\"servers\":[{\"url\":\"http://a/\",\"username\":\"J\xfcrg\"}]}";
|
||||
fs::write(&path, bytes).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read(dir.join("desktop.json.corrupt")).unwrap(), bytes);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_path_is_moved_aside() {
|
||||
let dir = tmp_dir("unreadable");
|
||||
let path = dir.join("desktop.json");
|
||||
// A directory where the file should be: it exists but cannot be read.
|
||||
fs::create_dir_all(path.join("inside")).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").join("inside").is_dir());
|
||||
c.save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_corruption_keeps_the_first_copy() {
|
||||
let dir = tmp_dir("corrupt-twice");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ first").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
fs::write(&path, "{ second").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt")).unwrap(), "{ first");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(),
|
||||
"{ second"
|
||||
);
|
||||
// A good file written afterwards loads normally and moves nothing.
|
||||
Config::default().save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!dir.join("desktop.json.corrupt.2").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taken_aside_names_are_skipped() {
|
||||
let dir = tmp_dir("aside-taken");
|
||||
// A directory left at the first name and a file at the second: the
|
||||
// third is the first free one.
|
||||
fs::create_dir_all(dir.join("desktop.json.corrupt")).unwrap();
|
||||
fs::write(dir.join("desktop.json.corrupt.1"), "kept").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(dir.join("nowhere"), dir.join("desktop.json.corrupt.2")).unwrap();
|
||||
let next = if cfg!(unix) {
|
||||
"desktop.json.corrupt.3"
|
||||
} else {
|
||||
"desktop.json.corrupt.2"
|
||||
};
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").is_dir());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(), "kept");
|
||||
// The dangling symlink still points nowhere and still counts as taken.
|
||||
#[cfg(unix)]
|
||||
assert!(fs::symlink_metadata(dir.join("desktop.json.corrupt.2"))
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
assert_eq!(fs::read_to_string(dir.join(next)).unwrap(), "{ not json");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_free_aside_name_disables_saving() {
|
||||
let dir = tmp_dir("aside-full");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
let names: Vec<PathBuf> = std::iter::once(dir.join("desktop.json.corrupt"))
|
||||
.chain((1..ASIDE_SLOTS).map(|n| dir.join(format!("desktop.json.corrupt.{n}"))))
|
||||
.collect();
|
||||
assert_eq!(names.len(), 10);
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
fs::write(name, format!("copy {n}")).unwrap();
|
||||
}
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), false));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
assert_eq!(fs::read_to_string(name).unwrap(), format!("copy {n}"));
|
||||
}
|
||||
assert!(!dir.join("desktop.json.corrupt.10").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_move_aside_disables_saving() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tmp_dir("stuck");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores the read-only directory, so the rename cannot fail.
|
||||
if fs::write(dir.join("probe"), "").is_ok() {
|
||||
eprintln!("skipped: the directory is still writable (running as root?)");
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!can_save);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
assert!(!dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_list_is_deduped_and_capped() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..12 {
|
||||
assert!(c.remember(&format!("http://h{i}/"), None, false).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h5/", Some("me"), false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert_eq!(c.servers[0].url, "http://h5/");
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == "http://h5/").count(), 1);
|
||||
assert_eq!(c.forget("http://h5/").unwrap().username.as_deref(), Some("me"));
|
||||
assert!(c.find("http://h5/").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_leaves_a_username_change_to_the_caller() {
|
||||
let url = "https://box.ts.net/";
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember(url, Some("Alice"), true).is_empty());
|
||||
// Every change of the username on the same server, a case-only one,
|
||||
// another user, or none at all, returns nothing: the caller deletes
|
||||
// the old account before it saves the new one.
|
||||
for (user, saved) in [
|
||||
(Some("alice"), true),
|
||||
(Some(" alice "), true),
|
||||
(Some("bob"), true),
|
||||
(None, false),
|
||||
(None, false),
|
||||
(Some("dave"), true),
|
||||
] {
|
||||
assert!(c.remember(url, user, saved).is_empty(), "{user:?}");
|
||||
let entry = c.find(url).unwrap();
|
||||
assert_eq!((entry.username.as_deref(), entry.has_saved_password), (user, saved));
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == url).count(), 1);
|
||||
}
|
||||
assert!(c.remember("http://other/", Some("carol"), true).is_empty());
|
||||
assert!(c.remember(url, Some("erin"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), 2);
|
||||
assert_eq!(c.servers[0].url, url);
|
||||
assert_eq!(c.find("http://other/").unwrap().username.as_deref(), Some("carol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_returns_evicted_accounts() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
let user = (i % 2 == 0).then(|| format!("u{i}"));
|
||||
assert!(c.remember(&format!("http://h{i}/"), user.as_deref(), true).is_empty());
|
||||
}
|
||||
// Moving a listed server to the top evicts nothing.
|
||||
assert!(c.remember("http://h3/", None, false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
// A new server pushes the oldest (h0, with a username) off the end.
|
||||
assert_eq!(
|
||||
c.remember("http://new/", None, false),
|
||||
vec![("http://h0/".to_string(), "u0".to_string())]
|
||||
);
|
||||
// The next oldest is h1, which has no username: nothing to delete.
|
||||
assert!(c.remember("http://newer/", Some("x"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert!(c.find("http://h0/").is_none() && c.find("http://h1/").is_none());
|
||||
// A username change on a listed server returns nothing; the next new
|
||||
// server evicts the oldest entry, and only that account comes back.
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
assert!(c.remember(&format!("http://h{i}/"), Some("u"), true).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h7/", Some("v"), true).is_empty());
|
||||
assert_eq!(
|
||||
c.remember("http://h8/", Some("w"), true),
|
||||
vec![("http://h0/".to_string(), "u".to_string())]
|
||||
);
|
||||
assert_eq!(c.find("http://h7/").unwrap().username.as_deref(), Some("v"));
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_missing_fields_tolerated() {
|
||||
let c: Config = serde_json::from_str(r#"{"servers":[{"url":"http://a/"}],"future":1}"#).unwrap();
|
||||
assert!(c.auto_connect);
|
||||
assert_eq!(c.servers[0].username, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_window_frame_keeps_the_servers() {
|
||||
for (name, window) in [
|
||||
("missing-height", r#"{"x":1,"y":2,"width":3}"#),
|
||||
("not-a-frame", r#""maximized""#),
|
||||
("null-number", r#"{"x":null,"y":2,"width":3,"height":4}"#),
|
||||
] {
|
||||
let dir = tmp_dir(&format!("bad-window-{name}"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(
|
||||
&path,
|
||||
format!(r#"{{"servers":[{{"url":"http://a/"}}],"autoConnect":false,"window":{window}}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert!(can_save, "{name}");
|
||||
assert_eq!(c.servers.len(), 1, "{name}");
|
||||
assert!(!c.auto_connect, "{name}");
|
||||
assert_eq!(c.window, None, "{name}");
|
||||
// The file was read, so it stays where it is.
|
||||
assert!(path.is_file(), "{name}");
|
||||
assert!(!path.with_extension("json.corrupt").exists(), "{name}");
|
||||
}
|
||||
// A whole frame and an absent one read as before.
|
||||
let c: Config =
|
||||
serde_json::from_str(r#"{"window":{"x":1,"y":2,"width":900,"height":600,"fullscreen":true}}"#).unwrap();
|
||||
assert_eq!(c.window.map(|f| (f.width, f.fullscreen)), Some((900.0, true)));
|
||||
assert_eq!(serde_json::from_str::<Config>("{}").unwrap().window, None);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Config>(r#"{"window":null}"#).unwrap().window,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_window_sits_between_the_menu_bar_and_the_dock() {
|
||||
let screen = |width: f64, height: f64| Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let size = (1280.0, 820.0);
|
||||
let min = (480.0, 360.0);
|
||||
// A 13 inch MacBook: the window is shortened, and ends above the Dock.
|
||||
let f = WindowFrame::first_run(&screen(1440.0, 900.0), size, min);
|
||||
assert_eq!((f.width, f.height), (1280.0, 744.0));
|
||||
assert_eq!((f.x, f.y), (80.0, 66.0));
|
||||
assert!(f.y + f.height <= 900.0 - 90.0);
|
||||
// A large display: the full size, centred across and in the free height.
|
||||
let f = WindowFrame::first_run(&screen(2560.0, 1440.0), size, min);
|
||||
assert_eq!((f.width, f.height), (1280.0, 820.0));
|
||||
assert_eq!(f.x, 640.0);
|
||||
assert_eq!(f.y - 66.0, 1440.0 - 90.0 - (f.y + f.height));
|
||||
// A second display to the left of the first.
|
||||
let left = Rect {
|
||||
x: -1920.0,
|
||||
y: 120.0,
|
||||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
};
|
||||
let f = WindowFrame::first_run(&left, size, min);
|
||||
assert_eq!(f.x, -1920.0 + 320.0);
|
||||
assert!(f.y >= 120.0 + 66.0 && f.y + f.height <= 120.0 + 1080.0 - 90.0);
|
||||
// Never smaller than the minimum, on a display too small for it too.
|
||||
let f = WindowFrame::first_run(&screen(500.0, 400.0), size, min);
|
||||
assert_eq!((f.width, f.height), min);
|
||||
assert!(!f.fullscreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sanitizing_and_reachability() {
|
||||
assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none());
|
||||
let small = frame(0.0, 0.0, 10.0, 10.0).sanitized().unwrap();
|
||||
assert_eq!((small.width, small.height), (MIN_WIDTH, MIN_HEIGHT));
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let on = frame(100.0, 50.0, 1200.0, 800.0);
|
||||
assert!(reachable(&on, &screen));
|
||||
assert_eq!(on.fitted_to(&screen, MIN), Some(on));
|
||||
let off = frame(3000.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&off, &screen));
|
||||
assert_eq!(off.fitted_to(&screen, MIN), None);
|
||||
let above = frame(100.0, -900.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&above, &screen));
|
||||
assert_eq!(above.fitted_to(&screen, MIN), None);
|
||||
// Only a sliver of the title bar on screen is not enough to grab.
|
||||
let sliver = frame(-1150.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&sliver, &screen));
|
||||
assert_eq!(sliver.fitted_to(&screen, MIN), None);
|
||||
// No screens at all (none reported): nothing is reachable.
|
||||
assert_eq!(on.fitted_to(&[], MIN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_is_above_y_on_macos() {
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
// On macOS the title bar of a content top at y = 10 is at -18..10,
|
||||
// mostly above the screen; elsewhere it is at 10..38.
|
||||
let high = frame(100.0, 10.0, 1200.0, 800.0);
|
||||
assert_eq!(reachable(&high, &screen), !cfg!(target_os = "macos"));
|
||||
// Half the bar above the top edge: still grabbable, and fitting
|
||||
// brings all of it onto the screen.
|
||||
let straddling = frame(100.0, TITLE_ABOVE_Y - 10.0, 1200.0, 800.0);
|
||||
assert!(reachable(&straddling, &screen));
|
||||
let fitted = straddling.fitted_to(&screen, MIN).unwrap();
|
||||
assert_eq!(outer(&fitted).1, 0.0);
|
||||
assert_eq!((fitted.x, fitted.width, fitted.height), (100.0, 1200.0, 800.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_from_an_unplugged_ultrawide_is_fitted() {
|
||||
// Saved on a 3440x1440 display at the origin, restored on a laptop.
|
||||
let laptop = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let wide = frame(0.0, 30.0, 3300.0, 1300.0);
|
||||
let fitted = wide.fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(0.0, 30.0, 1512.0 - FIT_MARGIN_X, 982.0 - FIT_MARGIN_Y)
|
||||
);
|
||||
let (left, top, right, bottom) = outer(&fitted);
|
||||
assert!(left >= 0.0 && top >= 0.0 && right <= 1512.0 && bottom <= 982.0);
|
||||
// Hanging off the right of the laptop by a bit: shifted, not shrunk.
|
||||
let fitted = frame(500.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(312.0, 100.0, 1200.0, 700.0)
|
||||
);
|
||||
// Hanging off the left: shifted right.
|
||||
let fitted = frame(-1000.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!((fitted.x, fitted.width), (0.0, 1200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_spanning_two_displays_is_left_alone() {
|
||||
let screens = [rect(0.0, 0.0, 1512.0, 982.0), rect(1512.0, -200.0, 2560.0, 1440.0)];
|
||||
let span = frame(1000.0, 100.0, 1500.0, 700.0);
|
||||
assert_eq!(span.fitted_to(&screens, MIN), Some(span));
|
||||
// Only the edge that lands on no display moves: the left edge stays
|
||||
// on the laptop, the right edge comes back onto the external one.
|
||||
let past = frame(1000.0, 100.0, 3500.0, 700.0);
|
||||
let fitted = past.fitted_to(&screens, MIN).unwrap();
|
||||
assert_eq!(fitted.x, 1000.0);
|
||||
assert_eq!(fitted.x + fitted.width, 1512.0 + 2560.0 - FIT_MARGIN_X);
|
||||
assert_eq!((fitted.y, fitted.height), (100.0, 700.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_frame_keeps_the_minimum_size() {
|
||||
let tiny = [rect(0.0, 0.0, 400.0, 300.0)];
|
||||
let fitted = frame(0.0, 40.0, 1200.0, 800.0).fitted_to(&tiny, MIN).unwrap();
|
||||
assert_eq!((fitted.width, fitted.height), MIN);
|
||||
// Too big for the screen even at the minimum: the left edge and the
|
||||
// title bar stay on it.
|
||||
let (left, top, _, _) = outer(&fitted);
|
||||
assert_eq!((left, top), (0.0, 0.0));
|
||||
assert!(reachable(&fitted, &tiny));
|
||||
// The fullscreen flag rides along.
|
||||
let full = WindowFrame {
|
||||
fullscreen: true,
|
||||
..frame(0.0, 40.0, 1200.0, 800.0)
|
||||
};
|
||||
assert!(full.fitted_to(&tiny, MIN).unwrap().fullscreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Saved passwords, in the macOS Keychain (service "Codeman Desktop", one item
|
||||
//! per username + server). Nothing secret is ever written to the config file.
|
||||
//! Windows and Linux get their stores when those builds land.
|
||||
//!
|
||||
//! Every Keychain call runs on ONE long-lived worker thread, never on the
|
||||
//! event loop: any of them can bring up the system's access prompt (an
|
||||
//! ad-hoc build's items name the previous build), and the window must keep
|
||||
//! running while it is up. One serial worker rather than a thread per call,
|
||||
//! because jobs must run in the order they were asked for: a delete from
|
||||
//! Forget that ran after a later save of the same account would remove the
|
||||
//! password that was just saved.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
pub const SERVICE: &str = "Codeman Desktop";
|
||||
|
||||
/// What a job reports when the worker thread is gone.
|
||||
const WORKER_GONE: &str = "The Keychain could not be reached.";
|
||||
|
||||
/// The Keychain account name for a username on a server.
|
||||
pub fn account(server_url: &str, username: &str) -> String {
|
||||
format!("{username} @ {server_url}")
|
||||
}
|
||||
|
||||
/// The password store the worker runs jobs against: the Keychain on macOS,
|
||||
/// a stub elsewhere, an in-memory map in the tests.
|
||||
trait Backend: Send {
|
||||
fn load(&mut self, account: &str) -> Option<String>;
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String>;
|
||||
fn delete(&mut self, account: &str);
|
||||
}
|
||||
|
||||
enum Job {
|
||||
Load {
|
||||
account: String,
|
||||
reply: Sender<Option<String>>,
|
||||
},
|
||||
Save {
|
||||
account: String,
|
||||
password: String,
|
||||
reply: Sender<Result<(), String>>,
|
||||
},
|
||||
Delete {
|
||||
account: String,
|
||||
},
|
||||
/// Ends the worker, so the tests can check what callers see after it.
|
||||
#[cfg(test)]
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// A handle on the Keychain worker. Start it once with [`Keychain::spawn`]
|
||||
/// and clone the handle: every clone feeds the same queue, which is what
|
||||
/// keeps jobs in order. The worker ends when the last handle is dropped.
|
||||
#[derive(Clone)]
|
||||
pub struct Keychain {
|
||||
jobs: Sender<Job>,
|
||||
}
|
||||
|
||||
impl Keychain {
|
||||
/// Starts the worker. If the thread cannot be started, the handle still
|
||||
/// works and every call fails soft (no password, save refused).
|
||||
pub fn spawn() -> Keychain {
|
||||
start(Box::new(imp::System)).0
|
||||
}
|
||||
|
||||
/// The saved password for an account. Blocks until every job queued
|
||||
/// before it has run and the Keychain has answered, which can mean
|
||||
/// waiting on the access prompt, so call it from a worker thread.
|
||||
/// `None` when nothing is saved, the item is unreadable or the worker
|
||||
/// is gone.
|
||||
pub fn load_blocking(&self, account: &str) -> Option<String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Load {
|
||||
account: account.to_owned(),
|
||||
reply,
|
||||
};
|
||||
self.jobs.send(job).ok()?;
|
||||
answer.recv().ok().flatten()
|
||||
}
|
||||
|
||||
/// Saves a password, replacing any saved before. Blocks like
|
||||
/// [`Keychain::load_blocking`]; the result is the Keychain's own.
|
||||
pub fn save_blocking(&self, account: &str, password: &str) -> Result<(), String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Save {
|
||||
account: account.to_owned(),
|
||||
password: password.to_owned(),
|
||||
reply,
|
||||
};
|
||||
if self.jobs.send(job).is_err() {
|
||||
return Err(WORKER_GONE.into());
|
||||
}
|
||||
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
|
||||
}
|
||||
|
||||
/// Queues the removal of an account's password and returns at once.
|
||||
/// Jobs queued after it see the account gone. Nothing happens when the
|
||||
/// worker is gone.
|
||||
pub fn delete(&self, account: &str) {
|
||||
let _ = self.jobs.send(Job::Delete {
|
||||
account: account.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a worker on `backend`. The join handle is for the tests; `None`
|
||||
/// when the thread could not be started, in which case the receiver went
|
||||
/// down with the closure and every send fails.
|
||||
fn start(backend: Box<dyn Backend>) -> (Keychain, Option<JoinHandle<()>>) {
|
||||
let (jobs, queue) = mpsc::channel();
|
||||
let worker = thread::Builder::new()
|
||||
.name("keychain".into())
|
||||
.spawn(move || run(backend, queue))
|
||||
.ok();
|
||||
(Keychain { jobs }, worker)
|
||||
}
|
||||
|
||||
/// The worker loop: one job at a time, in the order they were sent, until
|
||||
/// every handle is gone. A reply nobody waits for any more is dropped.
|
||||
fn run(mut backend: Box<dyn Backend>, queue: Receiver<Job>) {
|
||||
for job in queue {
|
||||
match job {
|
||||
Job::Load { account, reply } => {
|
||||
let _ = reply.send(backend.load(&account));
|
||||
}
|
||||
Job::Save {
|
||||
account,
|
||||
password,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(backend.save(&account, &password));
|
||||
}
|
||||
Job::Delete { account } => backend.delete(&account),
|
||||
#[cfg(test)]
|
||||
Job::Stop => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::{Backend, SERVICE};
|
||||
use objc2::rc::autoreleasepool;
|
||||
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
|
||||
|
||||
/// The login Keychain, through security-framework. The worker lives as
|
||||
/// long as the app and a thread's implicit autorelease pool drains only
|
||||
/// when the thread ends, so each call gets its own pool and whatever
|
||||
/// Security autoreleases is freed with the job.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
autoreleasepool(|_| {
|
||||
get_generic_password(SERVICE, account)
|
||||
.ok()
|
||||
.and_then(|b| String::from_utf8(b).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
autoreleasepool(|_| set_generic_password(SERVICE, account, password.as_bytes()).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
fn delete(&mut self, account: &str) {
|
||||
autoreleasepool(|_| {
|
||||
let _ = delete_generic_password(SERVICE, account);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod imp {
|
||||
use super::Backend;
|
||||
|
||||
/// No password store yet on this platform.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Err("Saving passwords is not supported on this platform yet.".into())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Keychain on an in-memory store, for these tests and for the tests of the
|
||||
/// code that uses a Keychain (main.rs).
|
||||
#[cfg(test)]
|
||||
pub(crate) mod memory {
|
||||
use super::{start, Backend, Keychain};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// An in-memory store that logs every job it runs. Its delete is slow on
|
||||
/// purpose: a queue that did not keep order would let the next job
|
||||
/// overtake it, and the tests would see that.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Memory {
|
||||
items: Arc<Mutex<HashMap<String, String>>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
fn note(&self, entry: String) {
|
||||
self.log.lock().unwrap().push(entry);
|
||||
}
|
||||
/// The jobs the store ran, in order.
|
||||
pub(crate) fn log(&self) -> Vec<String> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Memory {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
self.note(format!("load {account}"));
|
||||
self.items.lock().unwrap().get(account).cloned()
|
||||
}
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
self.note(format!("save {account}"));
|
||||
self.items
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(account.to_owned(), password.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, account: &str) {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
self.note(format!("delete {account}"));
|
||||
self.items.lock().unwrap().remove(account);
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker on a fresh store, and the store to look into.
|
||||
pub(crate) fn keychain() -> (Keychain, Memory) {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert!(worker.is_some(), "the worker thread starts");
|
||||
(keychain, memory)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::memory::{keychain, Memory};
|
||||
use super::{start, Backend, Job, Keychain};
|
||||
|
||||
/// A store whose load fails the way a crashed worker would.
|
||||
struct Crashes;
|
||||
|
||||
impl Backend for Crashes {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
panic!("keychain worker crashed");
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_names_are_per_user_and_server() {
|
||||
assert_eq!(
|
||||
super::account("http://127.0.0.1:3000/", "admin"),
|
||||
"admin @ http://127.0.0.1:3000/"
|
||||
);
|
||||
assert_ne!(
|
||||
super::account("http://a/", "admin"),
|
||||
super::account("http://b/", "admin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_load_reads_what_a_save_wrote() {
|
||||
let (keychain, _) = keychain();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("secret"));
|
||||
assert_eq!(keychain.save_blocking("a", "changed"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("changed"));
|
||||
assert_eq!(keychain.load_blocking("b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_before_a_save_never_removes_the_saved_password() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "old"), Ok(()));
|
||||
keychain.delete("a");
|
||||
assert_eq!(keychain.save_blocking("a", "new"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("new"));
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "save a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_after_a_save_removes_it_before_the_next_job() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.delete("a");
|
||||
// No wait between the two: the load is queued behind the delete.
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_from_clones_on_other_threads_share_one_queue() {
|
||||
let (keychain, memory) = keychain();
|
||||
let other = keychain.clone();
|
||||
std::thread::spawn(move || {
|
||||
other.save_blocking("a", "secret").unwrap();
|
||||
other.delete("a");
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_call_fails_soft_once_the_worker_is_gone() {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.jobs.send(Job::Stop).unwrap();
|
||||
worker.unwrap().join().unwrap();
|
||||
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
keychain.clone().delete("a");
|
||||
assert_eq!(memory.log(), ["save a"]);
|
||||
}
|
||||
|
||||
#[cfg(panic = "unwind")]
|
||||
#[test]
|
||||
fn a_worker_that_dies_mid_job_answers_nothing_and_takes_no_more_jobs() {
|
||||
let (keychain, worker) = start(Box::new(Crashes));
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(worker.unwrap().join().is_err(), "the worker panicked");
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handle_is_send_and_sync() {
|
||||
fn send_sync_clone<T: Send + Sync + Clone + 'static>() {}
|
||||
send_sync_clone::<Keychain>();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,848 @@
|
||||
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
|
||||
//!
|
||||
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
|
||||
//! finds neither `codeman` nor `node`, and the server inherits whatever
|
||||
//! environment it is started with: every session it spawns looks for
|
||||
//! claude/codex/tmux on that PATH and reads provider keys from it. So the
|
||||
//! command runs with the login shell's environment, picking up Homebrew and the
|
||||
//! user's profile, and falls back to the interactive profile (`-i`) for setups
|
||||
//! that only extend PATH there (nvm in `.zshrc`). The URL's port and base path
|
||||
//! are passed as separate arguments (`"$@"`), never spliced into the script text.
|
||||
//!
|
||||
//! How depends on the shell, chosen by its basename as Codeman's own
|
||||
//! `src/utils/shell-resolver.ts` does:
|
||||
//! - A POSIX shell (zsh, bash, sh, ...) runs `SCRIPT` itself:
|
||||
//! `$SHELL -l [-i] -c SCRIPT codeman-desktop <args>`.
|
||||
//! - Any other shell (fish, nu, xonsh, elvish, pwsh, tcsh) cannot parse
|
||||
//! `SCRIPT`: fish has no `then`/`fi` and refuses `$@`. It is only asked to
|
||||
//! print its environment between two markers, and `SCRIPT` then runs under
|
||||
//! `/bin/sh` with exactly that environment.
|
||||
//!
|
||||
//! `codeman web -d` refuses to start a second server on the same data dir and
|
||||
//! returns once the new one answers `/api/status`, so this is safe to call
|
||||
//! only after the probe found nothing listening.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use url::Url;
|
||||
|
||||
use crate::policy;
|
||||
|
||||
/// For the whole start, every shell run and retry included.
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long the pipes may stay open after the shell exited. Something the
|
||||
/// profile started in the background can inherit them and keep them open for
|
||||
/// as long as it runs; whatever arrived by then is used.
|
||||
const PIPE_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Found on PATH first, then the `install.sh` clone at `~/.codeman/app`.
|
||||
const SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exec codeman "$@"; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then
|
||||
exec node "$HOME/.codeman/app/dist/index.js" "$@"
|
||||
fi
|
||||
echo "Could not find the codeman command or ~/.codeman/app. Install Codeman first: https://github.com/Ark0N/Codeman#install" >&2
|
||||
exit 127"#;
|
||||
|
||||
/// Bracket the environment a non-POSIX login shell prints, so whatever its
|
||||
/// profile writes to stdout before or after cannot be read as a variable.
|
||||
const ENV_BEGIN: &str = "__CODEMAN_DESKTOP_ENV_BEGIN__";
|
||||
const ENV_END: &str = "__CODEMAN_DESKTOP_ENV_END__";
|
||||
|
||||
/// What a non-POSIX login shell runs to report its environment. The shell
|
||||
/// itself only parses a command path and one single-quoted word with no `;`,
|
||||
/// `$`, backslash or `!` in it, so fish, nu, xonsh, elvish, pwsh and tcsh have
|
||||
/// no syntax of their own to disagree on. The markers and `env` run under
|
||||
/// `/bin/sh`, which inherits everything the profile exported.
|
||||
fn capture_command() -> String {
|
||||
format!("/bin/sh -c 'echo {ENV_BEGIN}; /usr/bin/env; echo {ENV_END}'")
|
||||
}
|
||||
|
||||
fn login_shell() -> PathBuf {
|
||||
std::env::var_os("SHELL")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute() && p.exists())
|
||||
.unwrap_or_else(|| PathBuf::from("/bin/zsh"))
|
||||
}
|
||||
|
||||
/// How the login shell takes part in a start.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Flavor {
|
||||
/// Runs `SCRIPT` itself.
|
||||
Posix,
|
||||
/// Only reports its environment. One entry per attempt, in order: a later
|
||||
/// one runs when the earlier one printed no environment or its environment
|
||||
/// had no `codeman` (exit 127).
|
||||
Capture(&'static [&'static [&'static str]]),
|
||||
}
|
||||
|
||||
fn flavor(shell: &Path) -> Flavor {
|
||||
match shell.file_name().and_then(|n| n.to_str()).unwrap_or("") {
|
||||
"sh" | "bash" | "zsh" | "dash" | "ksh" | "ash" | "ksh93" | "mksh" | "pdksh" => Flavor::Posix,
|
||||
// tcsh honors -l only when it is the ONLY flag, so a login shell that
|
||||
// also runs a command is impossible; -i at least reads all of .tcshrc
|
||||
// (csh: .cshrc), including the parts guarded by `$?prompt`.
|
||||
"csh" | "tcsh" => Flavor::Capture(&[&["-ic"]]),
|
||||
_ => Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
}
|
||||
}
|
||||
|
||||
/// What one process printed, stdout and stderr kept apart.
|
||||
pub struct Output {
|
||||
pub code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page.
|
||||
struct Run {
|
||||
code: Option<i32>,
|
||||
output: String,
|
||||
}
|
||||
|
||||
impl From<Output> for Run {
|
||||
fn from(out: Output) -> Self {
|
||||
let mut output = out.stdout;
|
||||
if !out.stderr.trim().is_empty() {
|
||||
// On a line of its own, also when stdout ended without a newline.
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&out.stderr);
|
||||
}
|
||||
Run {
|
||||
code: out.code,
|
||||
output: last_lines(&output, 6),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `pipe` into the channel as it arrives, tagged with `stream`, then send
|
||||
/// `None` once it closes.
|
||||
fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Sender<(usize, Option<Vec<u8>>)>) {
|
||||
std::thread::spawn(move || {
|
||||
if let Some(mut pipe) = pipe {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send((stream, Some(buf[..n].to_vec()))).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.send((stream, None));
|
||||
});
|
||||
}
|
||||
|
||||
/// `exec_bounded` for a start.
|
||||
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||
exec_bounded(
|
||||
cmd,
|
||||
deadline,
|
||||
"Starting Codeman took longer than a minute; it was stopped.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `cmd` to completion with stdin closed, or kill it at `deadline` and
|
||||
/// answer `timed_out`.
|
||||
pub fn exec_bounded(mut cmd: Command, deadline: Instant, timed_out: &str) -> Result<Output, String> {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not run {}: {e}", Path::new(cmd.get_program()).display()))?;
|
||||
|
||||
// Read both pipes on threads so a chatty profile cannot fill one and stall.
|
||||
let (tx, rx) = mpsc::channel();
|
||||
pump(child.stdout.take(), 0, tx.clone());
|
||||
pump(child.stderr.take(), 1, tx);
|
||||
|
||||
// A login shell usually answers within tens of milliseconds: look often
|
||||
// at first, then back off.
|
||||
let mut nap = Duration::from_millis(5);
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(nap);
|
||||
nap = (nap * 2).min(Duration::from_millis(100));
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(timed_out.to_string());
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let mut bufs = [Vec::new(), Vec::new()];
|
||||
let settle = (Instant::now() + PIPE_GRACE).min(deadline);
|
||||
let mut open = 2;
|
||||
while open > 0 {
|
||||
match rx.recv_timeout(settle.saturating_duration_since(Instant::now())) {
|
||||
Ok((stream, Some(bytes))) => bufs[stream].extend_from_slice(&bytes),
|
||||
Ok((_, None)) => open -= 1,
|
||||
Err(_) => {
|
||||
// Still held open past the grace: keep what already arrived.
|
||||
for (stream, bytes) in rx.try_iter() {
|
||||
if let Some(bytes) = bytes {
|
||||
bufs[stream].extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lossy rather than strict: one stray non-UTF-8 byte in a profile's output
|
||||
// must not cost the whole environment.
|
||||
let [out, err] = bufs;
|
||||
Ok(Output {
|
||||
code: status.code(),
|
||||
stdout: String::from_utf8_lossy(&out).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&err).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The tail of what the command printed, for the connect page.
|
||||
fn last_lines(text: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.collect();
|
||||
lines[lines.len().saturating_sub(n)..].join("\n")
|
||||
}
|
||||
|
||||
/// The variables `env` printed between the markers, or None when the output
|
||||
/// does not hold both. A line that does not start with a portable variable
|
||||
/// name followed by `=` continues the previous value (a value with a newline
|
||||
/// in it); one that does starts the next variable.
|
||||
fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
|
||||
let mut lines = output.split('\n');
|
||||
// ends_with: a profile line printed without its newline sits in front.
|
||||
lines.by_ref().find(|l| l.trim_end().ends_with(ENV_BEGIN))?;
|
||||
let mut vars: Vec<(String, String)> = Vec::new();
|
||||
for line in lines {
|
||||
if line.trim() == ENV_END {
|
||||
return Some(vars);
|
||||
}
|
||||
match env_entry(line) {
|
||||
Some((name, value)) => vars.push((name.to_string(), value.to_string())),
|
||||
None => {
|
||||
if let Some((_, value)) = vars.last_mut() {
|
||||
value.push('\n');
|
||||
value.push_str(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn env_entry(line: &str) -> Option<(&str, &str)> {
|
||||
let (name, value) = line.split_once('=')?;
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next()?;
|
||||
let portable =
|
||||
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
portable.then_some((name, value))
|
||||
}
|
||||
|
||||
/// Ask `shell` for its environment with `flags`. The outer error ends the
|
||||
/// start (the shell could not be run, or time is up); the inner one means this
|
||||
/// form printed no environment and the next may be tried.
|
||||
fn capture_env(
|
||||
shell: &Path,
|
||||
flags: &[&str],
|
||||
deadline: Instant,
|
||||
) -> Result<Result<Vec<(String, String)>, String>, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(flags).arg(capture_command());
|
||||
let out = exec(cmd, deadline)?;
|
||||
Ok(parse_env(&out.stdout).ok_or_else(|| {
|
||||
// stderr only: stdout can hold part of the environment, API keys included.
|
||||
let said = last_lines(&out.stderr, 6);
|
||||
let mut message = format!("Could not read the environment of your login shell {}", shell.display());
|
||||
if said.is_empty() {
|
||||
let code = out
|
||||
.code
|
||||
.map_or_else(|| "a signal".to_string(), |c| format!("exit code {c}"));
|
||||
message.push_str(&format!(" (it ended with {code})."));
|
||||
} else {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&said);
|
||||
}
|
||||
message
|
||||
}))
|
||||
}
|
||||
|
||||
/// The login shell's environment, for a process the app runs without it (the
|
||||
/// graphical install). `None` when the shell reports none.
|
||||
pub fn login_environment() -> Option<Vec<(String, String)>> {
|
||||
let shell = login_shell();
|
||||
let flags: &[&str] = match flavor(&shell) {
|
||||
Flavor::Posix => &["-l", "-c"],
|
||||
Flavor::Capture(attempts) => attempts.first().copied().unwrap_or(&["-c"]),
|
||||
};
|
||||
capture_env(&shell, flags, Instant::now() + FIND_TIMEOUT).ok()?.ok()
|
||||
}
|
||||
|
||||
/// Run `cmd` in the home folder, where a terminal and the LaunchAgent start
|
||||
/// Codeman too. The server keeps the directory it was started in and uses it
|
||||
/// as the default for a session that names none; an app opened from the Finder
|
||||
/// or the Dock runs in `/`.
|
||||
fn in_home(cmd: &mut Command) {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
cmd.current_dir(home);
|
||||
}
|
||||
}
|
||||
|
||||
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
|
||||
/// environment, so the server gets the profile's PATH, API keys and CODEMAN_*
|
||||
/// settings and nothing of launchd's bare one.
|
||||
fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadline: Instant) -> Result<Run, String> {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
|
||||
in_home(&mut cmd);
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// `script` run by the POSIX login shell itself.
|
||||
fn run_in_shell(
|
||||
script: &str,
|
||||
shell: &Path,
|
||||
interactive: bool,
|
||||
args: &[String],
|
||||
deadline: Instant,
|
||||
) -> Result<Run, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.arg("-l");
|
||||
if interactive {
|
||||
cmd.arg("-i");
|
||||
}
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
in_home(&mut cmd);
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// Run `script` the way the login shell allows (see the module docs). Exit
|
||||
/// 127 means "no codeman here" and moves on to the next way of asking; the
|
||||
/// interactive profile is asked only when `interactive` allows it.
|
||||
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
|
||||
let attempts = match flavor(shell) {
|
||||
Flavor::Posix => {
|
||||
let result = run_in_shell(script, shell, false, args, deadline)?;
|
||||
if result.code != Some(127) || !interactive {
|
||||
return Ok(result);
|
||||
}
|
||||
// Not on the login PATH; try the interactive profile too.
|
||||
return run_in_shell(script, shell, true, args, deadline);
|
||||
}
|
||||
Flavor::Capture(attempts) => attempts,
|
||||
};
|
||||
let mut last_run = None;
|
||||
let mut capture_error = None;
|
||||
let interactive_flag = |flags: &&&[&str]| {
|
||||
flags
|
||||
.iter()
|
||||
.any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('i'))
|
||||
};
|
||||
for flags in attempts.iter().filter(|f| interactive || !interactive_flag(f)) {
|
||||
match capture_env(shell, flags, deadline)? {
|
||||
Ok(env) => {
|
||||
let result = run_with_env(script, &env, args, deadline)?;
|
||||
if result.code != Some(127) {
|
||||
return Ok(result);
|
||||
}
|
||||
last_run = Some(result);
|
||||
}
|
||||
Err(e) => capture_error = Some(e),
|
||||
}
|
||||
}
|
||||
if let Some(result) = last_run {
|
||||
return Ok(result);
|
||||
}
|
||||
// No form printed an environment: a shell that knows neither -l nor -i
|
||||
// may still run a bare -c.
|
||||
match capture_env(shell, &["-c"], deadline)? {
|
||||
Ok(env) => run_with_env(script, &env, args, deadline),
|
||||
Err(e) => Err(capture_error.unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit 0 when SCRIPT would find Codeman (the `codeman` command or the
|
||||
/// `install.sh` clone), 127 when it would not. Same lookup, nothing started.
|
||||
const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
|
||||
exit 127"#;
|
||||
|
||||
/// How long the This Mac tab waits for the login shell to answer whether
|
||||
/// Codeman is installed.
|
||||
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// `installed` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn installed_with(shell: &Path, interactive: bool) -> bool {
|
||||
matches!(
|
||||
launch(FIND_SCRIPT, shell, &[], Instant::now() + FIND_TIMEOUT, interactive),
|
||||
Ok(Run { code: Some(0), .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether "Start Codeman on this Mac" would find Codeman: the `codeman`
|
||||
/// command on the login shell's PATH, or `~/.codeman/app` with `node`. Runs
|
||||
/// the login shell, so never on the event loop. `interactive` also asks the
|
||||
/// interactive profile (nvm in `.zshrc`, say), whose side effects only a
|
||||
/// user's own click should set off.
|
||||
pub fn installed(interactive: bool) -> bool {
|
||||
installed_with(&login_shell(), interactive)
|
||||
}
|
||||
|
||||
/// `start` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn start_with(shell: &Path, args: &[String]) -> Result<String, String> {
|
||||
let result = launch(SCRIPT, shell, args, Instant::now() + TIMEOUT, true)?;
|
||||
match result.code {
|
||||
Some(0) => Ok(result.output),
|
||||
_ if result.output.is_empty() => Err("codeman web -d failed without saying why.".into()),
|
||||
_ => Err(result.output),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a detached server for `url`. Ok carries what `codeman` printed.
|
||||
pub fn start(url: &Url) -> Result<String, String> {
|
||||
if !policy::can_start_locally(url) {
|
||||
return Err("Codeman can only be started here for http://127.0.0.1 or http://localhost on this Mac.".into());
|
||||
}
|
||||
start_with(&login_shell(), &policy::start_args(url))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One spawning test at a time. A test writes a fake executable and then
|
||||
/// runs it; a process forked by a parallel test in between can still hold
|
||||
/// the write descriptor, and the exec then fails with ETXTBSY.
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_of_output() {
|
||||
assert_eq!(last_lines("a\n\nb\nc\n", 2), "b\nc");
|
||||
assert_eq!(last_lines("", 3), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_starts_on_a_line_of_its_own() {
|
||||
let said = |stdout: &str, stderr: &str| {
|
||||
Run::from(Output {
|
||||
code: Some(1),
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
})
|
||||
.output
|
||||
};
|
||||
// stdout that ended without a newline used to be glued to stderr.
|
||||
assert_eq!(
|
||||
said("Starting Codeman", "Error: port 3000 is in use\n"),
|
||||
"Starting Codeman\nError: port 3000 is in use"
|
||||
);
|
||||
assert_eq!(said("Starting\n", "Error\n"), "Starting\nError");
|
||||
assert_eq!(said("", "Error\n"), "Error");
|
||||
assert_eq!(said("Started\n", " \n"), "Started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_non_local_or_https() {
|
||||
assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err());
|
||||
assert!(start(&Url::parse("http://192.168.1.4:3000/").unwrap()).is_err());
|
||||
}
|
||||
|
||||
/// The script passes its arguments through untouched and reports a missing
|
||||
/// `codeman` with exit 127 (run with an empty PATH and HOME so nothing is found).
|
||||
#[test]
|
||||
fn script_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "-d", "--port", "3000"])
|
||||
.env("PATH", "/nonexistent")
|
||||
.env("HOME", "/nonexistent")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(127));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("Could not find the codeman command"));
|
||||
}
|
||||
|
||||
/// Arguments are data: a hostile-looking value is passed as one argv entry
|
||||
/// to a fake `codeman`, never run by the shell.
|
||||
#[test]
|
||||
fn arguments_are_not_shell_text() {
|
||||
let _serial = serial();
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-fake-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fake = dir.join("codeman");
|
||||
std::fs::write(&fake, "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\"; done\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "--base-url", "/x; touch /tmp/pwned"])
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", dir.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
"[web][--base-url][/x; touch /tmp/pwned]"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn pairs(vars: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_capture_parser() {
|
||||
let out = format!(
|
||||
"Welcome to fish, the friendly interactive shell\nno newline here{ENV_BEGIN}\n\
|
||||
PATH=/opt/homebrew/bin:/usr/bin:/bin\n\
|
||||
MULTI=line one\nline two\n three = 3\n\
|
||||
EMPTY=\n\
|
||||
TRAIL=a \n\n\
|
||||
_=/usr/bin/env\n\
|
||||
{ENV_END} \nbye from the profile\nNOT_CAPTURED=1\n"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env(&out),
|
||||
Some(pairs(&[
|
||||
("PATH", "/opt/homebrew/bin:/usr/bin:/bin"),
|
||||
("MULTI", "line one\nline two\n three = 3"),
|
||||
("EMPTY", ""),
|
||||
("TRAIL", "a \n"),
|
||||
("_", "/usr/bin/env"),
|
||||
]))
|
||||
);
|
||||
// A value may itself contain `=`; only the first one splits.
|
||||
assert_eq!(
|
||||
parse_env(&format!("{ENV_BEGIN}\nOPTS=a=b\n{ENV_END}\n")),
|
||||
Some(pairs(&[("OPTS", "a=b")]))
|
||||
);
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\n{ENV_END}\n")), Some(vec![]));
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\nPATH=/usr/bin\n")), None);
|
||||
assert_eq!(parse_env(&format!("PATH=/usr/bin\n{ENV_END}\n")), None);
|
||||
assert_eq!(parse_env("fish: Unknown command: then\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_flavor_by_basename() {
|
||||
for posix in [
|
||||
"/bin/zsh",
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/dash",
|
||||
"/bin/ksh",
|
||||
"/opt/homebrew/bin/bash",
|
||||
] {
|
||||
assert_eq!(flavor(Path::new(posix)), Flavor::Posix, "{posix}");
|
||||
}
|
||||
assert_eq!(flavor(Path::new("/bin/tcsh")), Flavor::Capture(&[&["-ic"]]));
|
||||
assert_eq!(flavor(Path::new("/bin/csh")), Flavor::Capture(&[&["-ic"]]));
|
||||
for other in [
|
||||
"/opt/homebrew/bin/fish",
|
||||
"/usr/local/bin/nu",
|
||||
"/opt/homebrew/bin/xonsh",
|
||||
"/usr/local/bin/pwsh",
|
||||
] {
|
||||
assert_eq!(
|
||||
flavor(Path::new(other)),
|
||||
Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
"{other}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The login shell's part avoids everything only POSIX shells read.
|
||||
#[test]
|
||||
fn capture_command_is_shell_neutral() {
|
||||
let _serial = serial();
|
||||
let cmd = capture_command();
|
||||
for bad in ["then", "fi", "$", "\\", "!", "\"", "&&"] {
|
||||
assert!(!cmd.contains(bad), "{bad} in {cmd}");
|
||||
}
|
||||
assert_eq!(cmd.matches('\'').count(), 2);
|
||||
assert!(cmd.starts_with("/bin/sh -c '"));
|
||||
// /bin/sh, as the capture runs it, prints a parsable environment.
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.env("CODEMAN_DESKTOP_PARSER_PROBE", "two\nlines")
|
||||
.output()
|
||||
.unwrap();
|
||||
let env = parse_env(&String::from_utf8_lossy(&out.stdout)).expect("markers");
|
||||
assert!(env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "CODEMAN_DESKTOP_PARSER_PROBE" && v == "two\nlines"));
|
||||
assert!(env.iter().any(|(k, _)| k == "PATH"));
|
||||
}
|
||||
|
||||
/// A scratch directory with a fake `codeman` in `bin/` that prints its
|
||||
/// arguments, the profile's marker variable and the names of everything in
|
||||
/// its environment.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("home")).unwrap();
|
||||
let scratch = Scratch(dir);
|
||||
scratch.exe(
|
||||
"bin/codeman",
|
||||
"#!/bin/sh\nprintf '[%s]' \"$@\"; echo\necho \"profile: $CODEMAN_DESKTOP_TEST\"\n\
|
||||
printf 'names:'; env | cut -d= -f1 | tr '\\n' ' '; echo\n",
|
||||
);
|
||||
scratch
|
||||
}
|
||||
|
||||
/// Write an executable at `rel`, with `DIR` in `body` replaced by the scratch path.
|
||||
fn exe(&self, rel: &str, body: &str) -> PathBuf {
|
||||
let path = self.0.join(rel);
|
||||
std::fs::write(&path, body.replace("DIR", &self.0.display().to_string())).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn argv_log(&self) -> String {
|
||||
std::fs::read_to_string(self.0.join("argv.log")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE_ARGS: [&str; 3] = ["web", "--base-url", "/x; touch /tmp/pwned"];
|
||||
|
||||
fn hostile_args() -> Vec<String> {
|
||||
HOSTILE_ARGS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// A stand-in for fish: it refuses POSIX `then` and `$@` the way fish
|
||||
/// does, logs its flags, prints profile noise around whatever it runs, and
|
||||
/// its "profile" replaces the environment. On its interactive form only
|
||||
/// when `INTERACTIVE_ONLY` is yes, PATH reaches the fake `codeman`.
|
||||
const FAKE_FISH: &str = r#"#!/bin/sh
|
||||
args=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
*then*|*'$@'*) echo "fish: Unknown command: then" >&2; exit 127;;
|
||||
-*) args="$args$a ";;
|
||||
esac
|
||||
cmd="$a"
|
||||
done
|
||||
echo "$args" >> "DIR/argv.log"
|
||||
bin="DIR/bin"
|
||||
case "INTERACTIVE_ONLY:$args" in yes:*-i*|no:*) ;; *) bin=/nonexistent;; esac
|
||||
echo "Welcome to fish, the friendly interactive shell"
|
||||
/usr/bin/env -i PATH="$bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST="from the profile" /bin/sh -c "$cmd"
|
||||
echo "bye from the profile"
|
||||
"#;
|
||||
|
||||
/// SCRIPT never reaches the non-POSIX shell: it runs under /bin/sh with the
|
||||
/// environment the shell printed, and with nothing else.
|
||||
#[test]
|
||||
fn non_posix_shell_only_reports_its_environment() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "[web][--base-url][/x; touch /tmp/pwned]");
|
||||
assert_eq!(lines[1], "profile: from the profile");
|
||||
assert_eq!(s.argv_log(), "-l -c \n");
|
||||
|
||||
// env_clear: a variable this process has, but the profile did not
|
||||
// export, must not reach codeman.
|
||||
let names: Vec<&str> = lines[2].trim_start_matches("names:").split_whitespace().collect();
|
||||
assert!(names.contains(&"PATH") && names.contains(&"CODEMAN_DESKTOP_TEST"));
|
||||
let captured = ["PATH", "HOME", "CODEMAN_DESKTOP_TEST", "PWD", "OLDPWD", "SHLVL", "_"];
|
||||
let leaked: Vec<String> = std::env::vars_os()
|
||||
.filter_map(|(k, _)| k.into_string().ok())
|
||||
.filter(|k| !captured.contains(&k.as_str()) && !k.starts_with("__CF"))
|
||||
.filter(|k| names.contains(&k.as_str()))
|
||||
.collect();
|
||||
assert!(leaked.is_empty(), "leaked from the app: {leaked:?}");
|
||||
}
|
||||
|
||||
/// No codeman on the login PATH (exit 127): the environment is captured
|
||||
/// again from the interactive profile, and that run finds it.
|
||||
#[test]
|
||||
fn non_posix_shell_retries_with_the_interactive_profile() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish-retry");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "yes"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l -c \n-l -i -c \n");
|
||||
}
|
||||
|
||||
/// tcsh takes -l only as its only flag, so it is asked with -ic alone.
|
||||
#[test]
|
||||
fn tcsh_gets_ic_and_never_l() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("tcsh");
|
||||
let tcsh = s.exe(
|
||||
"tcsh",
|
||||
r#"#!/bin/sh
|
||||
echo "$*" >> "DIR/argv.log"
|
||||
if [ "$#" != 2 ] || [ "$1" != -ic ]; then echo "tcsh: Unknown option: $1" >&2; exit 1; fi
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=tcsh /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&tcsh, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: tcsh"), "{out}");
|
||||
assert_eq!(s.argv_log().lines().count(), 1);
|
||||
assert!(s.argv_log().starts_with("-ic /bin/sh -c '"));
|
||||
}
|
||||
|
||||
/// A shell that takes neither -l nor -i still gets a bare -c.
|
||||
#[test]
|
||||
fn shell_without_login_flags_falls_back_to_plain_c() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("nolflag");
|
||||
let shell = s.exe(
|
||||
"elvish",
|
||||
r#"#!/bin/sh
|
||||
echo "$1" >> "DIR/argv.log"
|
||||
case "$1" in -l|-i) echo "flag provided but not defined: $1" >&2; exit 2;; esac
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=plain /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&shell, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: plain"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l\n-l\n-c\n");
|
||||
}
|
||||
|
||||
/// A shell that never prints its environment is reported by name with what
|
||||
/// it said on stderr, never with stdout (which can carry secrets).
|
||||
#[test]
|
||||
fn capture_failure_names_the_shell() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("broken");
|
||||
let shell = s.exe(
|
||||
"weirdsh",
|
||||
"#!/bin/sh\necho 'API_KEY=sk-secret'\necho 'weirdsh: cannot start' >&2\nexit 3\n",
|
||||
);
|
||||
let err = start_with(&shell, &hostile_args()).unwrap_err();
|
||||
assert!(err.contains("Could not read the environment"), "{err}");
|
||||
assert!(err.contains("weirdsh: cannot start"), "{err}");
|
||||
assert!(!err.contains("sk-secret"), "{err}");
|
||||
}
|
||||
|
||||
/// POSIX shells keep running SCRIPT themselves: `-l -c SCRIPT codeman-desktop
|
||||
/// args`, then `-l -i -c` when the login PATH had no codeman.
|
||||
#[test]
|
||||
fn posix_shell_runs_script_itself_and_retries_interactive() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&zsh, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
// $0 of the script, once per run: login, then interactive.
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\n");
|
||||
}
|
||||
|
||||
/// The installed check asks the same shells the same way and runs nothing:
|
||||
/// found on the interactive PATH here, after the login PATH had none.
|
||||
#[test]
|
||||
fn installed_check_finds_codeman_without_running_it() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
// A codeman that would leave a mark if it ran.
|
||||
s.exe("bin/codeman", "#!/bin/sh\ntouch DIR/ran\n");
|
||||
// Without the interactive profile: one login-shell look, not found.
|
||||
assert!(!installed_with(&zsh, false));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\n");
|
||||
assert!(installed_with(&zsh, true));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\ncodeman-desktop\n");
|
||||
assert!(!s.0.join("ran").exists(), "the installed check ran codeman");
|
||||
}
|
||||
|
||||
/// Nothing on any PATH and no ~/.codeman/app: not installed. Through a
|
||||
/// non-POSIX shell too, which only reports its environment.
|
||||
#[test]
|
||||
fn installed_check_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-fish");
|
||||
std::fs::remove_file(s.0.join("bin/codeman")).unwrap();
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
assert!(!installed_with(&fish, true));
|
||||
// The login-only look asks fish once, with -l and never -i.
|
||||
let before = s.argv_log();
|
||||
assert!(!installed_with(&fish, false));
|
||||
assert_eq!(&s.argv_log()[before.len()..], "-l -c \n");
|
||||
|
||||
// The install.sh clone counts once node is there.
|
||||
let app = s.0.join("home/.codeman/app/dist");
|
||||
std::fs::create_dir_all(&app).unwrap();
|
||||
std::fs::write(app.join("index.js"), "").unwrap();
|
||||
s.exe("bin/node", "#!/bin/sh\nexit 0\n");
|
||||
assert!(installed_with(&fish, true));
|
||||
assert!(installed_with(&fish, false));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,777 @@
|
||||
//! "Is a Codeman server answering at this address, and does it want a login?"
|
||||
//!
|
||||
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
|
||||
//! The answer is classified into something the connect page can explain. It is
|
||||
//! asked once per user action, never in a loop: Codeman counts a request
|
||||
//! without valid credentials against the login rate limit (10 per IP).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// The path the probe asks for, relative to the server's base URL.
|
||||
const STATUS_PATH: &str = "api/status";
|
||||
|
||||
/// rustls' text for `Error::InvalidCertificate`, which is what a handshake
|
||||
/// with a certificate the OS does not trust fails with. ureq hands it over as
|
||||
/// an `io::Error` of kind `InvalidData`, so the text is all there is to match
|
||||
/// without a direct rustls dependency.
|
||||
const CERT_REJECTED_PREFIX: &str = "invalid peer certificate";
|
||||
|
||||
/// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which
|
||||
/// cannot be matched on stable Rust, so the prefix is the only handle. The
|
||||
/// prefix is the same on macOS and Linux; only the resolver's reason differs.
|
||||
const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProbeOutcome {
|
||||
Ok {
|
||||
version: String,
|
||||
},
|
||||
/// 401 and we sent no credentials.
|
||||
NeedsLogin,
|
||||
/// 401 although we sent credentials.
|
||||
BadCredentials,
|
||||
/// 429: too many failed logins from this address. A single-user Codeman
|
||||
/// still accepts the right password, so the page needs to know whether
|
||||
/// this probe carried one. `retry_after_secs` is the server's
|
||||
/// `Retry-After`, when it gave one in seconds.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RateLimited {
|
||||
sent_credentials: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
retry_after_secs: Option<u64>,
|
||||
},
|
||||
/// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a
|
||||
/// multi-user Codeman wants this account's password changed first. Page
|
||||
/// loads are exempt, so the web UI can show its change-password dialog.
|
||||
PasswordChangeRequired,
|
||||
/// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain).
|
||||
HostNotAllowed,
|
||||
/// 3xx. `location` is the target resolved against the probed URL.
|
||||
/// `suggested` is a server address to use instead, set only when the
|
||||
/// redirect stays on the same host, only moves to https or another port,
|
||||
/// and still points at the status API. Never followed: a second request
|
||||
/// would carry the credentials to an address the user did not type.
|
||||
Redirect {
|
||||
location: String,
|
||||
suggested: Option<String>,
|
||||
},
|
||||
/// Something answered, but not Codeman's status API.
|
||||
NotCodeman {
|
||||
status: u16,
|
||||
},
|
||||
Refused,
|
||||
/// No route to the address: the machine is off or on another network,
|
||||
/// or macOS's Local Network privacy setting blocked the app.
|
||||
Unreachable,
|
||||
/// An http:// probe whose connection was closed without an answer. A
|
||||
/// Codeman started with `--https` does exactly that to plain HTTP.
|
||||
MaybeHttps,
|
||||
Timeout,
|
||||
TlsUntrusted {
|
||||
detail: String,
|
||||
},
|
||||
Unresolvable,
|
||||
Failed {
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
|
||||
/// was asked, `location` and `retry_after` are those response headers.
|
||||
pub fn classify_response(
|
||||
request: &Url,
|
||||
status: u16,
|
||||
location: Option<&str>,
|
||||
retry_after: Option<&str>,
|
||||
sent_credentials: bool,
|
||||
body: &str,
|
||||
) -> ProbeOutcome {
|
||||
match status {
|
||||
200 => {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let version = parsed.as_ref().and_then(|v| {
|
||||
(v.get("success")? == &serde_json::Value::Bool(true))
|
||||
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
|
||||
.flatten()
|
||||
});
|
||||
match version {
|
||||
Some(version) => ProbeOutcome::Ok { version },
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
300..=399 => {
|
||||
// A 3xx without a usable Location (304, a garbled header) is not a
|
||||
// redirect anyone can act on.
|
||||
match location.and_then(|l| request.join(l.trim()).ok()) {
|
||||
Some(target) => ProbeOutcome::Redirect {
|
||||
suggested: suggested_server(request, &target),
|
||||
location: target.into(),
|
||||
},
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
401 if sent_credentials => ProbeOutcome::BadCredentials,
|
||||
401 => ProbeOutcome::NeedsLogin,
|
||||
403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed,
|
||||
// Codeman only says this after the Basic credentials verified (the
|
||||
// probe sends no cookie), so without credentials it is not Codeman's
|
||||
// answer to us, and loading the page without them would not help.
|
||||
403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => {
|
||||
ProbeOutcome::PasswordChangeRequired
|
||||
}
|
||||
429 => ProbeOutcome::RateLimited {
|
||||
sent_credentials,
|
||||
retry_after_secs: retry_after.and_then(|v| v.trim().parse::<u64>().ok()),
|
||||
},
|
||||
other => ProbeOutcome::NotCodeman { status: other },
|
||||
}
|
||||
}
|
||||
|
||||
/// `errorCode` of a Codeman `ApiResponse` error body.
|
||||
fn error_code(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
Some(parsed.get("errorCode")?.as_str()?.to_string())
|
||||
}
|
||||
|
||||
/// The server base URL a redirect points to, when it is the same server
|
||||
/// moved to https or another port (a reverse proxy upgrading plain HTTP, the
|
||||
/// usual case). Anything else, such as another host (a login gateway the
|
||||
/// window could not pass anyway) or a downgrade to http, gets no suggestion.
|
||||
fn suggested_server(request: &Url, target: &Url) -> Option<String> {
|
||||
let upgrade = request.scheme() == "http" && target.scheme() == "https";
|
||||
let port_change =
|
||||
request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default();
|
||||
if !(upgrade || port_change) || target.host() != request.host() {
|
||||
return None;
|
||||
}
|
||||
if !target.username().is_empty() || target.password().is_some() {
|
||||
return None;
|
||||
}
|
||||
// The redirect must keep pointing at the status API (a proxy may add a
|
||||
// base path in front of it); the base is what comes before it.
|
||||
if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) {
|
||||
return None;
|
||||
}
|
||||
let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()];
|
||||
let mut base = target.clone();
|
||||
base.set_path(base_path);
|
||||
base.set_query(None);
|
||||
base.set_fragment(None);
|
||||
Some(base.into())
|
||||
}
|
||||
|
||||
/// Transport failures, reduced to what the connect page explains differently.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TransportError {
|
||||
Refused,
|
||||
Unreachable,
|
||||
ClosedWithoutAnswer,
|
||||
Timeout,
|
||||
Tls(String),
|
||||
Dns,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub fn classify_transport(err: TransportError) -> ProbeOutcome {
|
||||
match err {
|
||||
TransportError::Refused => ProbeOutcome::Refused,
|
||||
TransportError::Unreachable => ProbeOutcome::Unreachable,
|
||||
TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps,
|
||||
TransportError::Timeout => ProbeOutcome::Timeout,
|
||||
TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail },
|
||||
TransportError::Dns => ProbeOutcome::Unresolvable,
|
||||
TransportError::Other(detail) => ProbeOutcome::Failed { detail },
|
||||
}
|
||||
}
|
||||
|
||||
/// `plain_http`: the probe went to an http:// URL. Only there does a closed
|
||||
/// connection hint at an https-only server; over https the same close is a
|
||||
/// failed handshake with nothing more to say about it.
|
||||
fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
|
||||
use std::io::ErrorKind;
|
||||
match err {
|
||||
ureq::Error::Timeout(_) => TransportError::Timeout,
|
||||
ureq::Error::HostNotFound => TransportError::Dns,
|
||||
ureq::Error::ConnectionFailed => TransportError::Refused,
|
||||
// ureq passes both a rejected certificate (rustls wraps it as
|
||||
// InvalidData during the handshake) and a failed name lookup (std's
|
||||
// own error) through as plain io errors, so they are told apart here
|
||||
// before the kinds below swallow them.
|
||||
ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => {
|
||||
TransportError::Tls(e.to_string())
|
||||
}
|
||||
ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => {
|
||||
TransportError::Dns
|
||||
}
|
||||
ureq::Error::Io(e) => match e.kind() {
|
||||
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => {
|
||||
TransportError::Refused
|
||||
}
|
||||
ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable,
|
||||
ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer,
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout,
|
||||
_ => TransportError::Other(e.to_string()),
|
||||
},
|
||||
// ureq's own rustls errors come from setting up the TLS client (the
|
||||
// handshake's arrive as Io above); only a certificate rejection is
|
||||
// "untrusted", anything else is reported as it is.
|
||||
ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()),
|
||||
other => TransportError::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server. Blocking: call it from a worker thread.
|
||||
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
probe_within(server, authorization, TIMEOUT)
|
||||
}
|
||||
|
||||
/// The app's one way of asking a server something: the OS's certificate trust,
|
||||
/// a bound on the whole exchange, and status codes as answers.
|
||||
pub fn agent(timeout: Duration) -> ureq::Agent {
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.build();
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_global(Some(timeout))
|
||||
.http_status_as_error(false)
|
||||
// Never follow: a 3xx comes back as an answer and is explained.
|
||||
.max_redirects(0)
|
||||
.tls_config(tls)
|
||||
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `probe` with the time the whole exchange may take.
|
||||
pub fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
};
|
||||
let mut req = agent(timeout)
|
||||
.get(status_url.as_str())
|
||||
.header("Accept", "application/json");
|
||||
if let Some(auth) = authorization {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
match req.call() {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let header = |name: &str| {
|
||||
resp.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let location = header("location");
|
||||
let retry_after = header("retry-after");
|
||||
let body = match resp.body_mut().with_config().limit(MAX_BODY).read_to_string() {
|
||||
Ok(body) => body,
|
||||
// The answer began but did not finish in time. Where the body
|
||||
// decides what answered (200, 403), that is a timeout, not
|
||||
// "something else answers here".
|
||||
Err(e) if matches!(status, 200 | 403) && transport_error(&e, false) == TransportError::Timeout => {
|
||||
return ProbeOutcome::Timeout;
|
||||
}
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
classify_response(
|
||||
&status_url,
|
||||
status,
|
||||
location.as_deref(),
|
||||
retry_after.as_deref(),
|
||||
authorization.is_some(),
|
||||
&body,
|
||||
)
|
||||
}
|
||||
Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
|
||||
fn status_url() -> Url {
|
||||
Url::parse("http://studio.lan:3000/api/status").unwrap()
|
||||
}
|
||||
|
||||
/// `classify_response` for the cases that need no headers.
|
||||
fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome {
|
||||
classify_response(&status_url(), status, None, None, sent_credentials, body)
|
||||
}
|
||||
|
||||
fn redirect(request: &str, location: &str) -> ProbeOutcome {
|
||||
classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "")
|
||||
}
|
||||
|
||||
fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome {
|
||||
let err = ureq::Error::Io(io::Error::new(kind, message.to_string()));
|
||||
classify_transport(transport_error(&err, plain_http))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_needs_success_and_version() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
200,
|
||||
false,
|
||||
r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"#
|
||||
),
|
||||
ProbeOutcome::Ok {
|
||||
version: "1.47.0".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, "<html>hello</html>"),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":true}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_and_guards() {
|
||||
assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin);
|
||||
assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials);
|
||||
assert_eq!(
|
||||
classify(403, false, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
// The Host guard runs before authentication, so it wins even with credentials.
|
||||
assert_eq!(
|
||||
classify(403, true, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 });
|
||||
assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 });
|
||||
}
|
||||
|
||||
/// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`.
|
||||
#[test]
|
||||
fn password_change_required_only_after_sent_credentials() {
|
||||
let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#;
|
||||
assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired);
|
||||
assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 });
|
||||
// Another Codeman error code, or the text without the JSON field, is not it.
|
||||
assert_eq!(
|
||||
classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(403, true, "PASSWORD_CHANGE_REQUIRED"),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_says_whether_credentials_went_along() {
|
||||
let body = "Too Many Requests, try again later";
|
||||
assert_eq!(
|
||||
classify(429, false, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(429, true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
// auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 429, None, Some(" 742 "), true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(742)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(
|
||||
&status_url(),
|
||||
429,
|
||||
None,
|
||||
Some("Wed, 21 Oct 2026 07:28:00 GMT"),
|
||||
false,
|
||||
body
|
||||
),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_https_on_the_same_host_is_suggested() {
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}
|
||||
);
|
||||
// A base path the proxy adds stays part of the suggestion; a query does not.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/api/status",
|
||||
"https://studio.lan/codeman/api/status?from=http"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/codeman/api/status?from=http".into(),
|
||||
suggested: Some("https://studio.lan/codeman/".into())
|
||||
}
|
||||
);
|
||||
// A relative Location resolves against the probed URL; another port is fine.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/base/api/status",
|
||||
"//studio.lan:3001/base/api/status"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan:3001/base/api/status".into(),
|
||||
suggested: Some("http://studio.lan:3001/base/".into())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_redirects_are_reported_without_a_suggestion() {
|
||||
// Another host (a login gateway, say).
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/login?next=x".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same host, but no longer the status API.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/login"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/login".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// A downgrade to plain http.
|
||||
assert_eq!(
|
||||
redirect("https://studio.lan/api/status", "http://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same scheme and port, only the path moved.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/x/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/x/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Credentials in the target are never suggested.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://u:p@studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// No Location (304), or one that does not parse: nothing to act on.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 304, None, None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 304 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 302 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_classes() {
|
||||
assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused);
|
||||
assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout);
|
||||
assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable);
|
||||
assert_eq!(
|
||||
classify_transport(TransportError::Tls("bad cert".into())),
|
||||
ProbeOutcome::TlsUntrusted {
|
||||
detail: "bad cert".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The io errors exactly as ureq hands them over (`Error::Io`).
|
||||
#[test]
|
||||
fn io_errors_are_told_apart() {
|
||||
use io::ErrorKind;
|
||||
// rustls' certificate rejection from the handshake.
|
||||
let cert = "invalid peer certificate: UnknownIssuer";
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::InvalidData, cert, false),
|
||||
ProbeOutcome::TlsUntrusted { detail: cert.into() }
|
||||
);
|
||||
// Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem.
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::InvalidData,
|
||||
"received corrupt message of type InvalidContentType",
|
||||
false
|
||||
),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "received corrupt message of type InvalidContentType".into()
|
||||
}
|
||||
);
|
||||
// std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable).
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::Other,
|
||||
"failed to lookup address information: nodename nor servname provided, or not known",
|
||||
true
|
||||
),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// The same text from the OS (an errno) is not a lookup failure.
|
||||
let os = ureq::Error::Io(io::Error::from_raw_os_error(1));
|
||||
assert!(!matches!(
|
||||
classify_transport(transport_error(&os, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
));
|
||||
// ureq's "Peer disconnected": over http a hint at https, over https just a failure.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true),
|
||||
ProbeOutcome::MaybeHttps
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Peer disconnected".into()
|
||||
}
|
||||
);
|
||||
// No route: Local Network privacy, or a machine that is off.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::HostUnreachable, "No route to host", true),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true),
|
||||
ProbeOutcome::Refused
|
||||
);
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(&ureq::Error::HostNotFound, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// A ureq TLS setup message is not an untrusted certificate.
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(
|
||||
&ureq::Error::Tls("Rustls invalid dns name error"),
|
||||
false
|
||||
)),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Rustls invalid dns name error".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_serializes_for_the_page() {
|
||||
let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap();
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::NotCodeman { status: 404 }),
|
||||
serde_json::json!({"kind":"notCodeman","status":404})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":true})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: Some(60)
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::PasswordChangeRequired),
|
||||
serde_json::json!({"kind":"passwordChangeRequired"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/".into(),
|
||||
suggested: None
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unreachable),
|
||||
serde_json::json!({"kind":"unreachable"})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::TlsUntrusted { detail: "x".into() }),
|
||||
serde_json::json!({"kind":"tlsUntrusted","detail":"x"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unresolvable),
|
||||
serde_json::json!({"kind":"unresolvable"})
|
||||
);
|
||||
}
|
||||
|
||||
/// Against real sockets: a closed port is `Refused`, a listener that
|
||||
/// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`,
|
||||
/// a 401 is `NeedsLogin`, a redirect is reported without being followed,
|
||||
/// and a listener that hangs up without answering is `MaybeHttps`.
|
||||
#[test]
|
||||
fn probe_against_local_sockets() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
let closed = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::Refused);
|
||||
|
||||
/// Accept one connection, read the request and send `response`
|
||||
/// (nothing, then close, for an empty one).
|
||||
fn serve_raw(response: String) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
// Read the whole request head (a GET has no body) so that
|
||||
// closing sends a FIN, not a reset for unread bytes.
|
||||
let mut head = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => head.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let _ = s.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 {
|
||||
serve_raw(format!(
|
||||
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
))
|
||||
}
|
||||
|
||||
let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Ok {
|
||||
version: "9.9.9".into()
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_once("404 Not Found", "", "nope");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 });
|
||||
|
||||
let p = serve_once("401 Unauthorized", "", "Unauthorized");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin);
|
||||
|
||||
let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, Some("Basic YWRtaW46eA==")),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(30)
|
||||
}
|
||||
);
|
||||
|
||||
// Nothing listens on the redirect target: had it been followed, the
|
||||
// answer would be Refused instead.
|
||||
let p = serve_once(
|
||||
"301 Moved Permanently",
|
||||
&format!("Location: https://127.0.0.1:{closed}/api/status\r\n"),
|
||||
"",
|
||||
);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Redirect {
|
||||
location: format!("https://127.0.0.1:{closed}/api/status"),
|
||||
suggested: Some(format!("https://127.0.0.1:{closed}/"))
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_raw(String::new());
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
|
||||
}
|
||||
|
||||
/// A Codeman that sends its status headers and then stalls (a blocked
|
||||
/// event loop, a lossy link) did not answer in time. It is not "something
|
||||
/// else on this port", which is what an empty body read as before.
|
||||
#[test]
|
||||
fn a_body_that_stalls_is_a_timeout() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// Send `head` and the start of a body, then hold the connection open.
|
||||
fn serve_stalling(status_line: &'static str) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = s.read(&mut buf);
|
||||
let _ = s.write_all(
|
||||
format!("HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: 4096\r\n\r\n{{\"success\":true,").as_bytes(),
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
let short = Duration::from_millis(400);
|
||||
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("200 OK"))).unwrap();
|
||||
assert_eq!(probe_within(&url, None, short), ProbeOutcome::Timeout);
|
||||
|
||||
// A 401 has said all it needs to with its status line.
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("401 Unauthorized"))).unwrap();
|
||||
assert_eq!(probe_within(&url, None, short), ProbeOutcome::NeedsLogin);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* @fileoverview The shared runner of a STOCK CLI's install command: the one place Codeman
|
||||
* executes an install one-liner, used by `POST /api/clis/:id/install`
|
||||
* (src/web/routes/cli-registry-routes.ts) and by the local model install job.
|
||||
*
|
||||
* The code was moved here from cli-registry-routes.ts, and that route keeps its behaviour
|
||||
* byte for byte (same answers, same audit entry, same 409 for a second install of one id,
|
||||
* same environment).
|
||||
*
|
||||
* What lives here:
|
||||
* - `installEnv()` and `npmGlobalPrefixWritable()`: the installer's environment is the
|
||||
* server's minus every `CODEMAN_*` variable, with npm's global prefix pointed at a place
|
||||
* the server user can write. test/routes/cli-registry-routes.test.ts imports both from the
|
||||
* route module, which re-exports them from here.
|
||||
* - `runInstallCommand()`: `shell: true`, its own process group, output capped, the whole
|
||||
* group killed on timeout. New: an abort signal that kills the group the same way, so the
|
||||
* install job's Cancel has something to stop.
|
||||
* - `forgetResolvedCli()`: after an install, successful or not, every cached lookup of the
|
||||
* CLI's binaries is dropped, or a pre-install miss is replayed for up to five minutes.
|
||||
* - The set of ids with an install running. ONE set for both callers
|
||||
* (`tryBeginCliInstall()`), so the route and the job can never run two installers of one
|
||||
* CLI over the same prefix.
|
||||
*
|
||||
* Rules:
|
||||
* - Which command runs is the CALLER's choice, and the two callers choose differently. The
|
||||
* local model job passes `stockInstallCommand()`, which reads the compiled `STOCK_CLIS`
|
||||
* and never the registry merged with `clis.json`, so an override file cannot change what
|
||||
* that job runs. The CLI management route passes the merged entry's command
|
||||
* (`resolveInstallCommandForPlatform(listClis()...)`), as it did before this module
|
||||
* existed: a `clis.json` override of a stock entry's install command IS what that route
|
||||
* runs (behind `cliManagementEnabled`, after a confirm that shows the exact text). A custom
|
||||
* entry's install text is never executed by either. Switching the route to
|
||||
* `stockInstallCommand()` would close that gap at the cost of a behaviour change the route
|
||||
* tests pin; it is an owner decision, not made here.
|
||||
* - Who may call it is the caller's rule, not this module's: the route keeps its
|
||||
* `cliManagementEnabled` gate; the install job runs it only for a harness row of the local
|
||||
* model catalogue, after the person's consent click on a screen that showed the literal
|
||||
* command, admin only in multi-user mode, and writes the admin audit entry itself.
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { resolveInstallCommandForPlatform } from './config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from './config/cli-registry/stock.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { invalidateCliExecutableResolvers } from './utils/cli-executable-resolver.js';
|
||||
import { invalidateCliResolverCache } from './utils/cli-resolver.js';
|
||||
|
||||
/** How long an install command may run before its process group is killed. */
|
||||
export const CLI_INSTALL_TIMEOUT_MS = 300_000;
|
||||
|
||||
/** How an install command ended. */
|
||||
export interface CliInstallResult {
|
||||
/** The exit code, `null` when the command was killed or could not be started. */
|
||||
code: number | null;
|
||||
/** stdout and stderr together, capped. */
|
||||
output: string;
|
||||
/** The timeout killed it. */
|
||||
timedOut: boolean;
|
||||
/** The abort signal killed it. */
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
export interface RunInstallCommandOptions {
|
||||
/** Kills the installer's whole process group (SIGTERM, then SIGKILL). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids with an install running right now. A second request for the same id gets 409 rather
|
||||
* than a second `curl | bash` or `npm install -g` racing the first over the same prefix.
|
||||
*/
|
||||
const installsInFlight = new Set<string>();
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
|
||||
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
|
||||
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
|
||||
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
|
||||
*
|
||||
* Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on
|
||||
* timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is
|
||||
* judged by the nearest ancestor that does: npm creates the missing directories, so a user
|
||||
* `.npmrc` pointing at `~/.npm-global` before it was made is not moved.
|
||||
*/
|
||||
export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], {
|
||||
env: source,
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
const prefix = stdout.trim();
|
||||
if (!prefix) return true;
|
||||
// npm creates lib/node_modules under the prefix; walk up to the first directory that exists.
|
||||
let dir = join(prefix, 'lib', 'node_modules');
|
||||
for (;;) {
|
||||
try {
|
||||
await access(dir, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return false;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
||||
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
||||
* data dir, the tmux socket), none of which an installer needs.
|
||||
*
|
||||
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
|
||||
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
|
||||
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
|
||||
* update (the image's own prefix is image content);
|
||||
* - on a native install whose npm global prefix is not writable by the server user (a system node
|
||||
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
|
||||
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
|
||||
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
|
||||
*/
|
||||
export function installEnv(
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
||||
}
|
||||
// ⚠️ In the Docker Compose deployment the image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is IMAGE
|
||||
// content: `Update-Codeman.sh` recreates the container and every CLI installed there (dsh, pi, ...)
|
||||
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
|
||||
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
|
||||
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
|
||||
redirectNpmPrefix(env, source.HOME);
|
||||
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
|
||||
redirectNpmPrefix(env, source.HOME);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a
|
||||
* lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings
|
||||
* are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit
|
||||
* operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one.
|
||||
*/
|
||||
function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void {
|
||||
for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key];
|
||||
env.NPM_CONFIG_PREFIX = `${home}/.local`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `installEnv` for this process, with the (async) npm prefix probe done first and only when the
|
||||
* command runs npm at all: a `curl | bash` installer never pays for it.
|
||||
*/
|
||||
async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> {
|
||||
const usesNpm = /\bnpm\b/.test(command);
|
||||
const probeNeeded =
|
||||
usesNpm &&
|
||||
source.CODEMAN_IN_CONTAINER !== '1' &&
|
||||
process.platform !== 'win32' &&
|
||||
!!source.HOME &&
|
||||
!source.NPM_CONFIG_PREFIX;
|
||||
const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true;
|
||||
return installEnv(source, () => writable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a STOCK entry's already-vetted install command. `shell: true` is unavoidable here:
|
||||
* the shipped commands are genuinely `curl | bash` / `npm install -g` one-liners. This is NOT
|
||||
* a reopening of the config-shell-text concern the registry's `shellToken` pattern exists to
|
||||
* prevent: the string executed here is NEVER user input, only ever what is already hardcoded
|
||||
* and reviewed in `stock.ts`, and a CUSTOM entry can never reach this function at all (see the
|
||||
* guard of each caller).
|
||||
*
|
||||
* Never throws: a spawn that fails is a result with `code: null`.
|
||||
*/
|
||||
export async function runInstallCommand(
|
||||
command: string,
|
||||
options: RunInstallCommandOptions = {}
|
||||
): Promise<CliInstallResult> {
|
||||
const { signal } = options;
|
||||
if (signal?.aborted) return { code: null, output: '', timedOut: false, aborted: true };
|
||||
const env = await installEnvFor(command);
|
||||
// The environment probe above can take a moment (npm boots): an abort during it starts nothing.
|
||||
if (signal?.aborted) return { code: null, output: '', timedOut: false, aborted: true };
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command, {
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Own process group; the timeout below kills the whole tree by hand, mirroring
|
||||
// the DeepSeek profile-install endpoint's own reasoning: an install command fans
|
||||
// out into package-manager children, and spawn's own `timeout` option signals
|
||||
// only the direct child, leaving survivors holding the pipes open forever.
|
||||
detached: true,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false, aborted: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
let settled = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
let reapTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const capture = (chunk: Buffer) => {
|
||||
if (output.length < 16_384) output += chunk.toString('utf-8');
|
||||
};
|
||||
child.stdout?.on('data', capture);
|
||||
child.stderr?.on('data', capture);
|
||||
|
||||
const killTree = (killSignal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, killSignal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
/** SIGTERM to the group, SIGKILL three seconds later, and give up waiting after eight. */
|
||||
const stopTree = () => {
|
||||
if (killTimer) return;
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
||||
reapTimer = setTimeout(() => finish(null), 8_000);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
stopTree();
|
||||
};
|
||||
|
||||
const finish = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
if (reapTimer) clearTimeout(reapTimer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve({ code, output, timedOut, aborted });
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
stopTree();
|
||||
}, CLI_INSTALL_TIMEOUT_MS);
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
child.on('error', (err) => {
|
||||
output = `${output}\n${err.message}`;
|
||||
finish(null);
|
||||
});
|
||||
child.on('close', (code) => finish(code));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The install command of the STOCK CLI `id` for this platform, read from the compiled
|
||||
* `STOCK_CLIS`. `undefined` for an id that is not a stock CLI or has no command here.
|
||||
*/
|
||||
export function stockInstallCommand(id: string): string | undefined {
|
||||
const entry = STOCK_CLIS.find((candidate) => (candidate.id as string) === id);
|
||||
if (!entry) return undefined;
|
||||
const command = resolveInstallCommandForPlatform(entry);
|
||||
return command !== undefined && command.trim() !== '' ? command : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims the one install slot of `id`. Returns the function that releases it, or `null` when
|
||||
* an install of that CLI is already running (the caller answers 409 or waits).
|
||||
*/
|
||||
export function tryBeginCliInstall(id: string): (() => void) | null {
|
||||
if (installsInFlight.has(id)) return null;
|
||||
installsInFlight.add(id);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
installsInFlight.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets every cached binary lookup of a CLI: the generic per-id resolver (which captures
|
||||
* the entry's binaries when first built) and every per-binary cache, hits and negative-cache
|
||||
* backoff alike. Called after anything that changes what is on disk or what the CLI's binary
|
||||
* IS; see `invalidateCliExecutableResolvers`.
|
||||
*/
|
||||
export function forgetResolvedCli(id: string, binaries: readonly string[]): void {
|
||||
invalidateCliExecutableResolvers(binaries);
|
||||
invalidateCliResolverCache(id);
|
||||
}
|
||||
+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));
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* @fileoverview The local model catalogue: which models, harnesses and chips the recommendation
|
||||
* knows, as a compiled constant validated by a Zod schema.
|
||||
*
|
||||
* Rules:
|
||||
* - There is NO override file and no runtime download data here: nothing in this file reads
|
||||
* the data dir, and a data file can never decide what gets recommended. Which runtime build
|
||||
* 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
|
||||
* src/local-model/harness-facts.ts, never here.
|
||||
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
|
||||
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
|
||||
* row, and `chips` most specific first.
|
||||
* - `getLocalModelCatalog()` parses the constant once, on first use and never at import, and
|
||||
* 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-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. 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 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 |
|
||||
*
|
||||
* 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. 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';
|
||||
import type { ChipEntry, LocalModelCatalog } from '../types.js';
|
||||
|
||||
/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */
|
||||
export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
/**
|
||||
* Whether `chip` (a `machdep.cpu.brand_string`, 'Apple M4 Pro') is the chip a `ChipEntry.match`
|
||||
* ('M4 Pro') names: the string must END with the match, and the match must start at the
|
||||
* beginning or after a space. Case is ignored.
|
||||
*
|
||||
* Stricter than "the match occurs in the string" on purpose. 'M4' is not the chip of
|
||||
* 'Apple M4 Pro', 'M1' is not the chip of 'Apple M10', and a variant this table has never heard
|
||||
* of ('Apple M6 Pro') matches no row instead of borrowing the bandwidth of its base chip.
|
||||
*/
|
||||
export function chipNameMatches(chip: string, match: string): boolean {
|
||||
const name = chip.trim().toLowerCase();
|
||||
const wanted = match.trim().toLowerCase();
|
||||
if (wanted === '' || !name.endsWith(wanted)) return false;
|
||||
const start = name.length - wanted.length;
|
||||
return start === 0 || name[start - 1] === ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory bandwidth (GB/s) of `chip`, from the first row that names it. `undefined` when the
|
||||
* chip string is absent or no row names it: the speed is then UNKNOWN, a bandwidth is never guessed.
|
||||
*/
|
||||
export function chipBandwidthGbps(chips: readonly ChipEntry[], chip: string | undefined): number | undefined {
|
||||
if (!chip) return undefined;
|
||||
return chips.find((row) => chipNameMatches(chip, row.match))?.gbps;
|
||||
}
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)');
|
||||
const positiveInt = z.number().int().positive();
|
||||
|
||||
const TestedOnSchema = z
|
||||
.object({
|
||||
chip: z.string().min(1).max(80),
|
||||
memGb: positiveInt,
|
||||
cli: z.string().min(1).max(24),
|
||||
date: isoDate,
|
||||
tokensPerSecond: z.number().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LocalModelEntrySchema = z
|
||||
.object({
|
||||
id: z.string().max(100).regex(LOCAL_MODEL_ID_PATTERN, 'must be name:tag in lowercase, without a slash'),
|
||||
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(),
|
||||
peakExtraBytes: z.number().int().nonnegative(),
|
||||
figures: z.enum(['measured', 'estimated']),
|
||||
contexts: z.array(positiveInt).min(1),
|
||||
rank: z.number().int(),
|
||||
speedPer100Gbps: z.number().positive().optional(),
|
||||
tested: z.array(TestedOnSchema),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LocalHarnessEntrySchema = z
|
||||
.object({
|
||||
cli: z.string().min(1).max(24),
|
||||
minContext: positiveInt,
|
||||
rank: z.number().int(),
|
||||
gated: z.boolean().optional(),
|
||||
slowFirstTurn: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ChipEntrySchema = z
|
||||
.object({
|
||||
match: z.string().min(1).max(40),
|
||||
gbps: z.number().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** The index of the first value that already occurred earlier in the list, or -1. */
|
||||
function firstRepeat<T>(values: readonly T[]): number {
|
||||
const seen = new Set<T>();
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (seen.has(values[i])) return i;
|
||||
seen.add(values[i]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a whole catalogue: every field, then the rules that hold across rows (see the file
|
||||
* header). Typed against `LocalModelCatalog`, so the two cannot drift apart.
|
||||
*/
|
||||
export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
|
||||
.object({
|
||||
schema: z.literal(1),
|
||||
updated: isoDate,
|
||||
minMacosMajor: positiveInt,
|
||||
models: z.array(LocalModelEntrySchema),
|
||||
harnesses: z.array(LocalHarnessEntrySchema),
|
||||
chips: z.array(ChipEntrySchema),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((catalog, ctx) => {
|
||||
const issue = (path: (string | number)[], message: string): void => {
|
||||
ctx.addIssue({ code: 'custom', path, message });
|
||||
};
|
||||
|
||||
// A repeated id would make "the model" ambiguous, a repeated rank would make the order of
|
||||
// two models depend on where they sit in the array.
|
||||
const repeatedId = firstRepeat(catalog.models.map((m) => m.id));
|
||||
if (repeatedId >= 0) issue(['models', repeatedId, 'id'], 'model ids must be unique');
|
||||
const repeatedRank = firstRepeat(catalog.models.map((m) => m.rank));
|
||||
if (repeatedRank >= 0) issue(['models', repeatedRank, 'rank'], 'model ranks must be unique');
|
||||
|
||||
const harnessClis = catalog.harnesses.map((h) => h.cli);
|
||||
const repeatedCli = firstRepeat(harnessClis);
|
||||
if (repeatedCli >= 0) issue(['harnesses', repeatedCli, 'cli'], 'a harness may be listed only once');
|
||||
const repeatedHarnessRank = firstRepeat(catalog.harnesses.map((h) => h.rank));
|
||||
if (repeatedHarnessRank >= 0) issue(['harnesses', repeatedHarnessRank, 'rank'], 'harness ranks must be unique');
|
||||
|
||||
catalog.models.forEach((model, m) => {
|
||||
for (let i = 1; i < model.contexts.length; i++) {
|
||||
if (model.contexts[i] >= model.contexts[i - 1]) {
|
||||
issue(['models', m, 'contexts', i], 'contexts must be listed largest first, without repeats');
|
||||
break;
|
||||
}
|
||||
}
|
||||
// An entry for a harness the catalogue does not list could never match a candidate: a typo.
|
||||
model.tested.forEach((entry, t) => {
|
||||
if (!harnessClis.includes(entry.cli)) {
|
||||
issue(['models', m, 'tested', t, 'cli'], 'a tested entry must name a harness of this catalogue');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Most specific first ('M4 Max' before 'M4'): a row whose text sits inside a LATER row's text
|
||||
// is the less specific of the two, so it is in the wrong place. A repeated match is the same
|
||||
// mistake.
|
||||
for (let later = 1; later < catalog.chips.length; later++) {
|
||||
const laterMatch = catalog.chips[later].match.toLowerCase();
|
||||
const shadowing = catalog.chips.slice(0, later).find((row) => laterMatch.includes(row.match.toLowerCase()));
|
||||
if (shadowing) {
|
||||
issue(
|
||||
['chips', later, 'match'],
|
||||
`chips must be listed most specific first: '${shadowing.match}' comes before '${catalog.chips[later].match}'`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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. 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. */
|
||||
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 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-08',
|
||||
// The oldest macOS the model runtime supports.
|
||||
minMacosMajor: 14,
|
||||
// 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: [
|
||||
{
|
||||
// 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,
|
||||
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: [131072, 65536],
|
||||
rank: 1,
|
||||
// Measured: 37.1 tokens per second on 400 GB/s (this Q4_K_M build, llama.cpp).
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// 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,
|
||||
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: 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,
|
||||
// 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: 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 }],
|
||||
},
|
||||
{
|
||||
// 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,
|
||||
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, 65536],
|
||||
rank: 6,
|
||||
// Not measured: the 2.95 of the row below scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// 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: 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: [262144, 131072, 65536],
|
||||
rank: 7,
|
||||
// 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: [],
|
||||
},
|
||||
],
|
||||
// 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 },
|
||||
],
|
||||
};
|
||||
|
||||
let validated: LocalModelCatalog | undefined;
|
||||
|
||||
/**
|
||||
* The catalogue in force: the stock constant, validated once on first use. Callers go through
|
||||
* this function, so a later stage can change where the catalogue comes from without touching
|
||||
* them. Throws when the constant breaks its own schema, which the catalogue test rules out.
|
||||
*/
|
||||
export function getLocalModelCatalog(): LocalModelCatalog {
|
||||
validated ??= LocalModelCatalogSchema.parse(STOCK_LOCAL_MODEL_CATALOG);
|
||||
return validated;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @fileoverview The serialized writer of `custom-model-hosts.json`, the store of the saved
|
||||
* custom model endpoints (src/custom-model-hosts.ts).
|
||||
*
|
||||
* Why it exists: `writeCustomModelHosts()` writes through ONE temp file name per process and
|
||||
* has no queue, and `readCustomModelHosts()` reads a file that does not parse as an empty
|
||||
* list. Two overlapping writers (an endpoint route and the 5-minute discovery sweep) can
|
||||
* leave one body over the tail of a longer one, and the next writer then saves a file that
|
||||
* holds only its own record: every other endpoint and its API key is gone. The same fault
|
||||
* `mutateSettingsFile()` ended for `settings.json`.
|
||||
*
|
||||
* The shape of `mutateSettingsFile()` (src/web/settings-writer.ts):
|
||||
* - Serialized: mutations run one at a time on one promise chain. The check a route makes
|
||||
* (the id is free, the record still exists) runs INSIDE `fn`, in the same step as the write.
|
||||
* - Atomic: each write goes to a temp file with a random name beside the target and is
|
||||
* renamed over it. The file is mode 0600 (it holds API keys).
|
||||
* - Refuses a file it must not trust: only a missing or empty file starts from an empty
|
||||
* list. A file that cannot be read, does not parse or is not an array is left alone and
|
||||
* the write rejects with `CustomModelHostsWriteRefusedError`. The message never repeats
|
||||
* the parser's own text, which can quote an API key.
|
||||
*
|
||||
* EVERY writer goes through it: the four endpoint routes of
|
||||
* src/web/routes/custom-model-routes.ts (create, update, delete, discover-models), the
|
||||
* discovery sweep `refreshAllCustomModelHosts()`, and the local model's connect step. The one
|
||||
* direct writer left is config-backup restore, which must work on a file that does not parse.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { getDataDir } from './config/instance.js';
|
||||
import { customModelHostsPath, type CustomModelHost } from './custom-model-hosts.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from './types.js';
|
||||
|
||||
/** A write refused because the existing `custom-model-hosts.json` must not be overwritten. The message is user-facing. */
|
||||
export class CustomModelHostsWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CustomModelHostsWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The list a write starts from. Only a missing or empty file is an empty list; anything else that is not an array is refused. */
|
||||
async function readHostsForWrite(path: string): Promise<CustomModelHost[]> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw new CustomModelHostsWriteRefusedError(
|
||||
`Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
|
||||
);
|
||||
}
|
||||
// An empty file holds no endpoint to lose.
|
||||
if (raw.trim() === '') return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new CustomModelHostsWriteRefusedError(
|
||||
`${path} is not a valid JSON list, so this change was not saved (saving would have replaced every other model endpoint in it, with its API key). Fix or delete the file, or restore it from a config backup, then try again.`
|
||||
);
|
||||
}
|
||||
return parsed as CustomModelHost[];
|
||||
}
|
||||
|
||||
async function writeHostsFile(path: string, hosts: CustomModelHost[]): Promise<void> {
|
||||
// Through a symlink: the rename lands on the real file.
|
||||
const target = await fs.realpath(path).catch(() => path);
|
||||
await fs.mkdir(dirname(target), { recursive: true });
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(hosts, null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** What a mutation returns: the list to write (absent: write nothing) and what the caller gets back. */
|
||||
export interface CustomModelHostsMutation<T> {
|
||||
/** The whole list to store. Absent: the file is left as it is. */
|
||||
hosts?: CustomModelHost[];
|
||||
/** Handed back to the caller of `mutateCustomModelHosts()`. */
|
||||
result: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one change to the endpoint store. `fn` receives the current list (a copy it may change)
|
||||
* and returns what to write and what to hand back. Resolves with `result`. A refused or failed
|
||||
* mutation rejects its own caller only; the chain keeps going.
|
||||
*
|
||||
* `configDir` is the folder of the file, as for `readCustomModelHosts()`. Default: the data dir.
|
||||
*/
|
||||
export function mutateCustomModelHosts<T>(
|
||||
fn: (hosts: CustomModelHost[]) => CustomModelHostsMutation<T>,
|
||||
configDir?: string
|
||||
): Promise<T> {
|
||||
const run = mutationChain.then(async () => {
|
||||
// Resolved inside the chain: the data dir is read when the mutation runs, like the file.
|
||||
const path = customModelHostsPath(configDir ?? getDataDir());
|
||||
const current = await readHostsForWrite(path).catch((err: unknown) => {
|
||||
console.warn(`[custom-model] write refused: ${getErrorMessage(err)}`);
|
||||
throw err;
|
||||
});
|
||||
const mutation = fn(current);
|
||||
if (mutation.hosts) await writeHostsFile(path, mutation.hosts);
|
||||
return mutation.result;
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. Like `settingsWriteErrorResponse()`. */
|
||||
export function customModelHostsWriteErrorResponse(err: unknown): ApiResponse<never> {
|
||||
if (err instanceof CustomModelHostsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
+20
-1
@@ -54,6 +54,7 @@ import { lstat, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -728,6 +729,17 @@ interface GitRun {
|
||||
* signal is used rather than `child.kill()`.
|
||||
*/
|
||||
async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise<GitRun> {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as a missing git.
|
||||
if (!gitSpawnAllowed()) {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
timedOut: false,
|
||||
started: false,
|
||||
spawnError: String(gitMissingError()),
|
||||
};
|
||||
}
|
||||
// The queue wait spends the SAME deadline as the operation: `timeoutMs` is a
|
||||
// promise about the whole call, not about git's runtime after some unbounded
|
||||
// wait. A full queue is refused outright rather than queued.
|
||||
@@ -814,10 +826,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
}
|
||||
}
|
||||
|
||||
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
|
||||
/**
|
||||
* Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. The one
|
||||
* exception is a Mac whose only git is Apple's placeholder with no developer tools behind it: running
|
||||
* that, even as `git --version`, opens macOS's "install the developer tools" window, so it is never
|
||||
* run, and that refusal is not memoized here (the gate keeps its own short memory), so the page finds
|
||||
* git once the tools are installed.
|
||||
*/
|
||||
let gitAvailable: boolean | null = null;
|
||||
export function isGitAvailable(): boolean {
|
||||
if (gitAvailable !== null) return gitAvailable;
|
||||
if (!gitSpawnAllowed()) return false;
|
||||
try {
|
||||
execFileSync('git', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
resolveWorkspaceRepos,
|
||||
type GitRunner,
|
||||
} from './git-workspace-status.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -205,6 +206,7 @@ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | nu
|
||||
* add a fifth git process or push a fetch past the user's timeout: it spends part of it.
|
||||
*/
|
||||
export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> {
|
||||
if (!gitSpawnAllowed()) return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
|
||||
cwd,
|
||||
@@ -240,6 +242,8 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis
|
||||
* as a timeout without starting the fetch.
|
||||
*/
|
||||
export const runFetch: GitRunner = async (cwd, args, opts) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as GIT_MISSING.
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
||||
const startedAt = Date.now();
|
||||
const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
|
||||
|
||||
@@ -50,6 +50,7 @@ import { homedir } from 'node:os';
|
||||
import { basename, join, relative, sep } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -265,6 +266,8 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
|
||||
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
|
||||
|
||||
export const runGit: GitRunner = async (cwd, args, opts) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||
|
||||
@@ -62,6 +62,7 @@ import { homedir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
|
||||
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
// ─── Limits ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -371,6 +372,11 @@ const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=fals
|
||||
*/
|
||||
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
|
||||
new Promise<WorktreeGitResult>((resolveRun) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as "git is not installed".
|
||||
if (!gitSpawnAllowed()) {
|
||||
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(gitMissingError()) });
|
||||
return;
|
||||
}
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', args, {
|
||||
|
||||
+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 };
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @fileoverview Connecting the installed local model to Codeman, and undoing it: the endpoint
|
||||
* record, the settings flag, and the checks around them.
|
||||
*
|
||||
* These are the ONLY two writes of the local model outside its own folder:
|
||||
* 1. One record in `custom-model-hosts.json`, through `mutateCustomModelHosts()`:
|
||||
* `{ id: 'local-ollama', label: 'This Mac', baseUrl: 'http://127.0.0.1:<port>',
|
||||
* authStyle: 'bearer', models: [<id as /v1/models lists it>], defaultModelId,
|
||||
* defaultContextLength: <the chosen context> }`, validated by `CustomModelHostSchema`
|
||||
* before it is written. No `apiKey`. `defaultContextLength` is what reaches the coding
|
||||
* agent as its context limit, because the runtime's model listing states none.
|
||||
* 2. `customModelEndpointsEnabled: true` in `settings.json`, through `mutateSettingsFile()`.
|
||||
* The value it had before is returned and kept in the state file.
|
||||
*
|
||||
* Rules:
|
||||
* - The id is reserved. A record with it that this feature did not write is never
|
||||
* overwritten: the connect step fails with `endpoint-id-taken`.
|
||||
* - The connection is CLAIMED before it is written: the install job records `connection` in
|
||||
* the state file first (`inspectLocalEndpoint()` tells it what is there and what the flag
|
||||
* is), then calls `connectLocalModel()` with `ours: true`. A state write that fails, or a
|
||||
* server that is killed between the two, therefore never leaves a record of ours that the
|
||||
* state file does not know: the worst case is a claim with nothing behind it, which a
|
||||
* resumed job fills in and Remove undoes.
|
||||
* - Second net: a record with the reserved id that already says exactly what ours would say
|
||||
* (`isSameLocalEndpoint()`) is ours whatever `ours` says. Nobody else can have made it: the
|
||||
* endpoint routes refuse the reserved id.
|
||||
* - Both writes are idempotent, so a resumed job writes them again.
|
||||
* - Undoing removes our record, and switches the flag off again only when this feature
|
||||
* switched it on AND no other endpoint is saved.
|
||||
* - The model's digest is compared again before a session starts and at boot
|
||||
* (`compareModelDigest()`): a model that changed under the same name is not launched.
|
||||
* - The record is MANAGED while the state file records it as ours (`isManagedLocalEndpoint()`):
|
||||
* the endpoint routes then refuse to edit or delete it and the discovery sweep leaves it
|
||||
* alone, so its `models` stays the one model the install job checked.
|
||||
*/
|
||||
|
||||
import { mutateCustomModelHosts } from '../custom-model-hosts-writer.js';
|
||||
import type { CustomModelHost } from '../custom-model-hosts.js';
|
||||
import { getDataDir } from '../config/instance.js';
|
||||
import { readCustomModelHosts } from '../custom-model-hosts.js';
|
||||
import { getErrorMessage, type LocalModelErrorCode } from '../types.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
|
||||
import { CustomModelHostSchema } from '../web/schemas.js';
|
||||
import { mutateSettingsFile } from '../web/settings-writer.js';
|
||||
import { readLocalModelState } from './state-store.js';
|
||||
|
||||
/** The key of the settings flag that shows saved endpoints in the Run menu and runs the discovery sweep. */
|
||||
const ENDPOINTS_FLAG = 'customModelEndpointsEnabled';
|
||||
|
||||
/** The id of the endpoint record this feature writes. Reserved: nobody else may use it. */
|
||||
export const LOCAL_MODEL_ENDPOINT_ID = 'local-ollama';
|
||||
/** The label of that record, what the Run menu shows in brackets. */
|
||||
export const LOCAL_MODEL_ENDPOINT_LABEL = 'This Mac';
|
||||
|
||||
/** What the endpoint record is built from. */
|
||||
export interface LocalEndpointInput {
|
||||
/** The runtime's loopback port. */
|
||||
port: number;
|
||||
/** The model id exactly as the runtime's `/v1/models` lists it. */
|
||||
modelId: string;
|
||||
/** The chosen context length in tokens. */
|
||||
context: number;
|
||||
}
|
||||
|
||||
/** The endpoint record of the file header. Pure; the caller validates it with `CustomModelHostSchema`. */
|
||||
export function buildLocalEndpointRecord(input: LocalEndpointInput): CustomModelHost {
|
||||
return {
|
||||
id: LOCAL_MODEL_ENDPOINT_ID,
|
||||
label: LOCAL_MODEL_ENDPOINT_LABEL,
|
||||
baseUrl: `http://127.0.0.1:${input.port}`,
|
||||
authStyle: 'bearer',
|
||||
models: [input.modelId],
|
||||
defaultModelId: input.modelId,
|
||||
defaultContextLength: input.context,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a saved record says exactly what our record for `input` says: the same address, the
|
||||
* one model, its context, no key. What discovery may have added to it (context lengths, a
|
||||
* time stamp) does not count. Pure.
|
||||
*/
|
||||
export function isSameLocalEndpoint(host: CustomModelHost, input: LocalEndpointInput): boolean {
|
||||
const ours = buildLocalEndpointRecord(input);
|
||||
return (
|
||||
host.id === ours.id &&
|
||||
host.label === ours.label &&
|
||||
host.baseUrl === ours.baseUrl &&
|
||||
host.authStyle === ours.authStyle &&
|
||||
host.apiKey === undefined &&
|
||||
Array.isArray(host.models) &&
|
||||
host.models.length === 1 &&
|
||||
host.models[0] === input.modelId &&
|
||||
host.defaultModelId === ours.defaultModelId &&
|
||||
host.defaultContextLength === ours.defaultContextLength
|
||||
);
|
||||
}
|
||||
|
||||
/** What the connect step finds before it claims the connection. */
|
||||
export interface LocalEndpointInspection {
|
||||
/**
|
||||
* The saved record with the reserved id: there is none, it says what ours would say, or it
|
||||
* says something else (somebody else's: `endpoint-id-taken`).
|
||||
*/
|
||||
record: 'absent' | 'same' | 'other';
|
||||
/** `customModelEndpointsEnabled` right now: what the claim keeps as the value before this feature. */
|
||||
flagEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks at the endpoint store and the settings flag WITHOUT writing either. The install job
|
||||
* calls it before it claims the connection in the state file. Rejects when the endpoint store
|
||||
* cannot be read; a settings file that cannot be read counts as "the flag is off".
|
||||
*/
|
||||
export async function inspectLocalEndpoint(input: LocalEndpointInput): Promise<LocalEndpointInspection> {
|
||||
const hosts = await readCustomModelHosts(getDataDir());
|
||||
const existing = hosts.find((host) => host.id === LOCAL_MODEL_ENDPOINT_ID);
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return {
|
||||
record: !existing ? 'absent' : isSameLocalEndpoint(existing, input) ? 'same' : 'other',
|
||||
flagEnabled: settings[ENDPOINTS_FLAG] === true,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectLocalModelInput extends LocalEndpointInput {
|
||||
/**
|
||||
* True when the state file already records a connection of ours: an existing record with
|
||||
* our id is then ours and is replaced. False: such a record is somebody else's, unless it
|
||||
* says exactly what ours would say (`isSameLocalEndpoint()`).
|
||||
*/
|
||||
ours: boolean;
|
||||
/**
|
||||
* The flag's value before this feature first switched it on, when the state file already
|
||||
* holds one. It is handed back unchanged, so a second connect cannot overwrite it with `true`.
|
||||
*/
|
||||
flagWasEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** How the connect step ended. */
|
||||
export type ConnectResult =
|
||||
| {
|
||||
ok: true;
|
||||
/** The value `customModelEndpointsEnabled` had before this feature switched it on. */
|
||||
flagWasEnabled: boolean;
|
||||
}
|
||||
| { ok: false; code: Extract<LocalModelErrorCode, 'endpoint-id-taken' | 'connect-failed'>; detail?: string };
|
||||
|
||||
/**
|
||||
* Writes the record and the flag. Idempotent. Never throws: a refused write is `connect-failed`
|
||||
* with its message as `detail`.
|
||||
*
|
||||
* The record goes first, with the "is the id free" check in the same serialized step as the
|
||||
* write, so a record that is somebody else's is found before anything is changed. Should the
|
||||
* flag then fail to save, a record this call ADDED is taken out again: the pair is never left
|
||||
* half done, and a later attempt starts from the same place.
|
||||
*/
|
||||
export async function connectLocalModel(input: ConnectLocalModelInput): Promise<ConnectResult> {
|
||||
const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(input));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, code: 'connect-failed', detail: `The endpoint record is not valid: ${parsed.error.message}` };
|
||||
}
|
||||
const record: CustomModelHost = parsed.data;
|
||||
|
||||
let outcome: 'added' | 'replaced' | 'taken';
|
||||
try {
|
||||
outcome = await mutateCustomModelHosts<'added' | 'replaced' | 'taken'>((hosts) => {
|
||||
const index = hosts.findIndex((host) => host.id === record.id);
|
||||
if (index === -1) return { hosts: [...hosts, record], result: 'added' };
|
||||
if (!input.ours && !isSameLocalEndpoint(hosts[index], input)) return { result: 'taken' };
|
||||
const next = [...hosts];
|
||||
next[index] = record;
|
||||
return { hosts: next, result: 'replaced' };
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, code: 'connect-failed', detail: getErrorMessage(err) };
|
||||
}
|
||||
if (outcome === 'taken') return { ok: false, code: 'endpoint-id-taken' };
|
||||
|
||||
let flagBefore: boolean;
|
||||
try {
|
||||
const { before } = await mutateSettingsFile((current) =>
|
||||
current[ENDPOINTS_FLAG] === true ? undefined : { ...current, [ENDPOINTS_FLAG]: true }
|
||||
);
|
||||
flagBefore = before[ENDPOINTS_FLAG] === true;
|
||||
} catch (err) {
|
||||
if (outcome === 'added') await removeLocalEndpoint().catch(() => {});
|
||||
return { ok: false, code: 'connect-failed', detail: getErrorMessage(err) };
|
||||
}
|
||||
return { ok: true, flagWasEnabled: input.flagWasEnabled ?? flagBefore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts our record back when it is gone, and does nothing when a record with our id exists
|
||||
* (it is never replaced here). For the start of the server while a model is installed: a
|
||||
* config-backup restore can bring back an older endpoint store without our record while the
|
||||
* model, the runtime and the state file stay. The settings flag is NOT touched: a person who
|
||||
* switched it off meant to. Resolves true when the record was added. Never throws.
|
||||
*/
|
||||
export async function ensureLocalEndpoint(input: LocalEndpointInput): Promise<boolean> {
|
||||
const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(input));
|
||||
if (!parsed.success) return false;
|
||||
const record: CustomModelHost = parsed.data;
|
||||
try {
|
||||
return await mutateCustomModelHosts<boolean>((hosts) =>
|
||||
hosts.some((host) => host.id === record.id) ? { result: false } : { hosts: [...hosts, record], result: true }
|
||||
);
|
||||
} catch {
|
||||
// A store that must not be overwritten stays as it is; a session start then says what is wrong.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes our record out of the store. Resolves with how many OTHER endpoints are saved. */
|
||||
async function removeLocalEndpoint(): Promise<number> {
|
||||
return mutateCustomModelHosts<number>((hosts) => {
|
||||
const others = hosts.filter((host) => host.id !== LOCAL_MODEL_ENDPOINT_ID);
|
||||
return others.length === hosts.length ? { result: others.length } : { hosts: others, result: others.length };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves our record to another port (the answer to "Use another port" once connected). A
|
||||
* missing record is not an error, and a record that is already there is not written again.
|
||||
*/
|
||||
export async function moveLocalEndpoint(port: number): Promise<void> {
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
await mutateCustomModelHosts<void>((hosts) => {
|
||||
const index = hosts.findIndex((host) => host.id === LOCAL_MODEL_ENDPOINT_ID);
|
||||
if (index === -1 || hosts[index].baseUrl === baseUrl) return { result: undefined };
|
||||
const next = [...hosts];
|
||||
next[index] = { ...next[index], baseUrl };
|
||||
return { hosts: next, result: undefined };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes our record, and restores the settings flag when this feature switched it on
|
||||
* (`flagWasEnabled` is false) and no other endpoint is left. Idempotent.
|
||||
*/
|
||||
export async function disconnectLocalModel(input: { flagWasEnabled: boolean }): Promise<void> {
|
||||
const othersLeft = await removeLocalEndpoint();
|
||||
if (input.flagWasEnabled || othersLeft > 0) return;
|
||||
await mutateSettingsFile((current) =>
|
||||
current[ENDPOINTS_FLAG] === true ? { ...current, [ENDPOINTS_FLAG]: false } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether `id` is the reserved endpoint id. For the endpoint routes and the discovery sweep, which leave that record alone. */
|
||||
export function isLocalModelEndpointId(id: string): boolean {
|
||||
return id === LOCAL_MODEL_ENDPOINT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the saved endpoint `id` is the record THIS feature wrote and still owns: the id is
|
||||
* the reserved one and the state file records a connection under it. A record with the
|
||||
* reserved id that somebody made by hand (before this feature, or while nothing is installed)
|
||||
* is not managed, so its owner can still edit and delete it. Never throws.
|
||||
*/
|
||||
export async function isManagedLocalEndpoint(id: string): Promise<boolean> {
|
||||
if (!isLocalModelEndpointId(id)) return false;
|
||||
const state = await readLocalModelState();
|
||||
return state.connection?.endpointId === id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of the live sessions that use the endpoint `endpointId`, in the order given.
|
||||
* Pure. What `DELETE /api/local-model` answers its 409 with.
|
||||
*/
|
||||
export function sessionNamesUsingEndpoint(
|
||||
sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>,
|
||||
endpointId: string = LOCAL_MODEL_ENDPOINT_ID
|
||||
): string[] {
|
||||
const names: string[] = [];
|
||||
for (const session of sessions) {
|
||||
if (session.customModel?.endpointId === endpointId) names.push(session.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the digest stored at install time with the one the runtime reports now. Pure.
|
||||
* - `same`: launch.
|
||||
* - `changed`: refuse (`model-changed`): the name now stands for other weights or another template.
|
||||
* - `unknown`: one of the two is absent (the runtime does not answer, the model is gone).
|
||||
* The caller refuses as well, with the reason it knows.
|
||||
*/
|
||||
export function compareModelDigest(
|
||||
stored: string | undefined,
|
||||
current: string | undefined
|
||||
): 'same' | 'changed' | 'unknown' {
|
||||
const a = normalizeDigest(stored);
|
||||
const b = normalizeDigest(current);
|
||||
if (!a || !b) return 'unknown';
|
||||
return a === b ? 'same' : 'changed';
|
||||
}
|
||||
|
||||
/** A digest as it is compared: lowercase hex, without the `sha256:` some listings put in front. */
|
||||
function normalizeDigest(digest: string | undefined): string | undefined {
|
||||
if (typeof digest !== 'string') return undefined;
|
||||
const hex = digest
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^sha256:/, '');
|
||||
return hex === '' ? undefined : hex;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @fileoverview Whether this Mac has the developer tools a coding agent calls (git, Python).
|
||||
*
|
||||
* Why it exists: on a Mac without Xcode or the Command Line Tools, `git` and `python3` are
|
||||
* stubs that open macOS's "install the command line developer tools" window. A coding agent
|
||||
* runs both, so the install window says one honest sentence when the tools are missing
|
||||
* (`LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing`).
|
||||
*
|
||||
* Rules:
|
||||
* - The ONLY command ever run is `/usr/bin/xcode-select -p`, bounded by a timeout. Never
|
||||
* `git`, `python3`, `xcrun` or anything else that would open that window itself.
|
||||
* - macOS only. Anywhere else, and when the command does not answer in time, the answer is
|
||||
* `unknown`, and nothing is said.
|
||||
* - Never throws. The answer is cached for a short while, so a window that polls once a
|
||||
* second does not spawn once a second. Only `present` and `missing` are cached: an
|
||||
* `unknown` is asked again, because it says the check itself did not work.
|
||||
* - Under vitest the default `run` spawns nothing and answers "did not start", so a test
|
||||
* that wants an answer injects its own.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import type { LocalModelDeveloperTools } from '../types.js';
|
||||
|
||||
/** How long `/usr/bin/xcode-select -p` may take. */
|
||||
export const DEVELOPER_TOOLS_TIMEOUT_MS = 2_000;
|
||||
|
||||
/** How long an answer is reused. Installing the tools takes minutes, so a minute of staleness is not seen. */
|
||||
const DEVELOPER_TOOLS_CACHE_MS = 60_000;
|
||||
|
||||
/** The one command this module runs. It prints the active developer directory and exits 0, or exits with an error when there is none. */
|
||||
const XCODE_SELECT = '/usr/bin/xcode-select';
|
||||
|
||||
/** What the check calls. Each member defaults to the real thing; a test injects its own. */
|
||||
export interface DeveloperToolsDeps {
|
||||
/** Default `process.platform`. */
|
||||
platform: string;
|
||||
/** Runs `file` with `args` and resolves with its exit code (`null` when it timed out or could not be started). */
|
||||
run: (file: string, args: readonly string[], timeoutMs: number) => Promise<number | null>;
|
||||
}
|
||||
|
||||
/** The default `run`: one bounded `execFile`, no shell. Spawns nothing under vitest. */
|
||||
function runBounded(file: string, args: readonly string[], timeoutMs: number): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (process.env.VITEST) return resolve(null);
|
||||
try {
|
||||
execFile(file, [...args], { timeout: timeoutMs }, (err) => {
|
||||
if (!err) return resolve(0);
|
||||
// A number is the exit code of a command that ran. Anything else (ENOENT, a timeout
|
||||
// that killed it) is "it did not answer".
|
||||
const code = (err as { code?: unknown }).code;
|
||||
resolve(typeof code === 'number' && !(err as { killed?: boolean }).killed ? code : null);
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function check(deps: DeveloperToolsDeps): Promise<LocalModelDeveloperTools> {
|
||||
if (deps.platform !== 'darwin') return 'unknown';
|
||||
let code: number | null;
|
||||
try {
|
||||
code = await deps.run(XCODE_SELECT, ['-p'], DEVELOPER_TOOLS_TIMEOUT_MS);
|
||||
} catch {
|
||||
code = null;
|
||||
}
|
||||
if (code === null || !Number.isInteger(code)) return 'unknown';
|
||||
return code === 0 ? 'present' : 'missing';
|
||||
}
|
||||
|
||||
/**
|
||||
* A check that remembers its answer for a minute and shares one run among callers that ask at
|
||||
* the same time. `now` is the clock of the cache; a test injects its own.
|
||||
*/
|
||||
export function createDeveloperToolsCheck(
|
||||
deps: Partial<DeveloperToolsDeps> & { now?: () => number } = {}
|
||||
): () => Promise<LocalModelDeveloperTools> {
|
||||
const resolved: DeveloperToolsDeps = {
|
||||
platform: deps.platform ?? process.platform,
|
||||
run: deps.run ?? runBounded,
|
||||
};
|
||||
const now = deps.now ?? Date.now;
|
||||
let cached: { answer: LocalModelDeveloperTools; at: number } | undefined;
|
||||
let inFlight: Promise<LocalModelDeveloperTools> | undefined;
|
||||
return () => {
|
||||
if (cached && now() - cached.at < DEVELOPER_TOOLS_CACHE_MS) return Promise.resolve(cached.answer);
|
||||
inFlight ??= check(resolved)
|
||||
.then((answer) => {
|
||||
if (answer !== 'unknown') cached = { answer, at: now() };
|
||||
return answer;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
});
|
||||
return inFlight;
|
||||
};
|
||||
}
|
||||
|
||||
/** The check of this server's own Mac. */
|
||||
let shared = createDeveloperToolsCheck();
|
||||
|
||||
/** Forgets the cached answer of this server's own Mac. For tests. */
|
||||
export function resetDeveloperToolsCache(): void {
|
||||
shared = createDeveloperToolsCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* `present` when `/usr/bin/xcode-select -p` exits 0, `missing` when it exits with anything
|
||||
* else, `unknown` otherwise. Without `deps` it is the cached check of this server's Mac; with
|
||||
* a platform or a runner of the caller's it asks once and remembers nothing.
|
||||
*/
|
||||
export async function checkDeveloperTools(deps: Partial<DeveloperToolsDeps> = {}): Promise<LocalModelDeveloperTools> {
|
||||
if (deps.platform !== undefined || deps.run !== undefined) {
|
||||
return check({ platform: deps.platform ?? process.platform, run: deps.run ?? runBounded });
|
||||
}
|
||||
return shared();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @fileoverview The formatters of the local model recommendation, and its byte units.
|
||||
*
|
||||
* `formatGb()` is the ONE place a byte count becomes a size a person reads: decimal GB (the
|
||||
* unit the Finder and the model registry use). `hardware.ts` and `recommend.ts` call it to fill
|
||||
* the `...Gb` text fields of the payload (`memGb`, `freeDiskGb`, `needGb`, `downloadGb`,
|
||||
* `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a
|
||||
* size for display, the browser included: it prints the text it was sent.
|
||||
*
|
||||
* GiB and MiB exist only for the budget math (reserves, the wired limit, the device total the
|
||||
* runtime reports) and never reach a person, with one exception: `memClassGb()`, the memory
|
||||
* size a Mac is sold as, is how the card names the installed memory ("48 GB memory", what
|
||||
* About This Mac shows). It is a name of the machine, not a computed size: `formatGb()` of the
|
||||
* same bytes reads 51.5, which no owner recognises. Every computed size (need, budget,
|
||||
* download, free disk) is `formatGb()`.
|
||||
*
|
||||
* Pure: no IO, no state.
|
||||
*/
|
||||
|
||||
/** One decimal gigabyte, the unit a person reads. */
|
||||
export const GB = 1_000_000_000;
|
||||
/** One binary gigabyte. Budget math only. */
|
||||
export const GIB = 1024 ** 3;
|
||||
/** One binary megabyte. Budget math only. */
|
||||
export const MIB = 1024 ** 2;
|
||||
|
||||
/**
|
||||
* A byte count as decimal GB text, WITHOUT the unit: one decimal below 100 ('6.6', '17.7',
|
||||
* '25.5'), a whole number from 100 ('212'). The caller's sentence supplies ' GB'.
|
||||
* Anything that is not a positive finite number reads '0.0'.
|
||||
*/
|
||||
export function formatGb(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0.0';
|
||||
const tenths = Math.round((bytes / GB) * 10) / 10;
|
||||
return tenths >= 100 ? String(Math.round(bytes / GB)) : tenths.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory size a Mac is sold as: whole binary gigabytes (17179869184 bytes is 16). This is
|
||||
* `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`,
|
||||
* and what the card and the `noneMemory` sentence call the installed memory. It names a class
|
||||
* of Mac; 0 means the size is unknown and is never shown. Any other size for display is
|
||||
* `formatGb()`.
|
||||
*/
|
||||
export function memClassGb(memBytes: number): number {
|
||||
if (!Number.isFinite(memBytes) || memBytes <= 0) return 0;
|
||||
return Math.round(memBytes / GIB);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* @fileoverview The hardware probe of the local model recommendation: what this Mac is.
|
||||
*
|
||||
* - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a
|
||||
* key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the
|
||||
* others, and a line it does not know is ignored.
|
||||
* - `readHardware()`: ONE bounded async spawn of `SYSCTL_PATH` with `SYSCTL_KEYS`, the free disk
|
||||
* of the data dir through `fs.promises.statfs`, fallbacks from `node:os` when the spawn
|
||||
* fails, and a 60 s cache.
|
||||
*
|
||||
* Rules:
|
||||
* - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and
|
||||
* off macOS nothing is spawned and no disk is read (`source: 'not-probed'`).
|
||||
* - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second
|
||||
* spawn, never a synchronous exec on the server's event loop.
|
||||
* - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1).
|
||||
* Without `hw.memsize` nothing else of that output is trusted: the answer is the fallback.
|
||||
* - Only a line that ends in a newline counts. A cut-off tail is not a value: half of
|
||||
* `hw.memsize` would read as a smaller, valid number.
|
||||
* - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0
|
||||
* is ever used as a limit.
|
||||
* - The data dir can sit on a network mount that stopped answering, so the disk read is
|
||||
* bounded too: the folder is probed with the bounded path probe first, the `statfs` is raced
|
||||
* against a timeout, and a `statfs` that never came back is not started a second time.
|
||||
* - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts,
|
||||
* the one place a size is formatted.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { statfs } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { dirname } from 'node:path';
|
||||
import type { HardwareFacts } from '../types.js';
|
||||
import { getDataDir } from '../config/instance.js';
|
||||
import { probePathKind, type PathProbeKind } from '../utils/bounded-path-probe.js';
|
||||
import { formatGb, memClassGb } from './format.js';
|
||||
|
||||
/** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */
|
||||
export const SYSCTL_PATH = '/usr/sbin/sysctl';
|
||||
|
||||
/** The keys of the one spawn, in the order they are passed. */
|
||||
export const SYSCTL_KEYS = [
|
||||
'hw.memsize',
|
||||
'machdep.cpu.brand_string',
|
||||
'hw.optional.arm64',
|
||||
'sysctl.proc_translated',
|
||||
'iogpu.wired_limit_mb',
|
||||
'kern.osproductversion',
|
||||
'hw.model',
|
||||
] as const;
|
||||
|
||||
/** Upper bound of the spawn (it answers in about 2 ms on a real Mac). */
|
||||
export const SYSCTL_TIMEOUT_MS = 2000;
|
||||
|
||||
/** How long one probe result is reused. */
|
||||
export const HARDWARE_CACHE_TTL_MS = 60_000;
|
||||
|
||||
/** Upper bound of the wait for the free disk figure. */
|
||||
export const FREE_DISK_TIMEOUT_MS = 2000;
|
||||
|
||||
/** Seven short lines are well under this. A larger output is not sysctl's. */
|
||||
const SYSCTL_MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
/** Longest text value kept (a chip name, a model identifier, a version). */
|
||||
const MAX_TEXT_VALUE_LENGTH = 100;
|
||||
|
||||
/** How many parent folders are tried when the data dir does not exist yet. */
|
||||
const FREE_DISK_MAX_PARENT_STEPS = 6;
|
||||
|
||||
/** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */
|
||||
export interface SysctlFacts {
|
||||
/** `hw.memsize`. */
|
||||
memBytes?: number;
|
||||
/** `machdep.cpu.brand_string`. */
|
||||
chip?: string;
|
||||
/** `hw.optional.arm64` is 1. */
|
||||
arm64?: boolean;
|
||||
/** `sysctl.proc_translated` is 1. */
|
||||
translated?: boolean;
|
||||
/** `iogpu.wired_limit_mb`, as reported (0 stays 0). */
|
||||
wiredLimitMb?: number;
|
||||
/** `kern.osproductversion`. */
|
||||
macosVersion?: string;
|
||||
/** The leading number of `macosVersion`. */
|
||||
macosMajor?: number;
|
||||
/** `hw.model`. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** The IO of `readHardware()`, injectable so a test spawns nothing and reads no real disk. Each member defaults to the real thing. */
|
||||
export interface HardwareProbeDeps {
|
||||
/** Default `process.platform`. */
|
||||
platform?: string;
|
||||
/** Default `os.arch()`. */
|
||||
arch?: string;
|
||||
/** Runs the one spawn. Resolves its stdout, also after a non-zero exit, or `undefined` when it could not run or timed out. Never rejects. */
|
||||
runSysctl?: () => Promise<string | undefined>;
|
||||
/** Free bytes on the data dir's filesystem, or `undefined` when unknown. Never rejects. */
|
||||
freeDiskBytes?: () => Promise<number | undefined>;
|
||||
/** Default `os.totalmem()`. */
|
||||
totalMemBytes?: () => number;
|
||||
/** Default `os.cpus()[0]?.model`. */
|
||||
cpuModel?: () => string | undefined;
|
||||
/** Default `Date.now`. The clock of the cache. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** A whole number without sign, as sysctl prints one. */
|
||||
function parseCount(value: string): number | undefined {
|
||||
if (!/^\d+$/.test(value)) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** sysctl prints a flag as 0 or 1. Anything else is not a flag. */
|
||||
function parseFlag(value: string): boolean | undefined {
|
||||
if (value === '1') return true;
|
||||
if (value === '0') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseText(value: string): string | undefined {
|
||||
return value !== '' && value.length <= MAX_TEXT_VALUE_LENGTH ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `sysctl <keys>` (no `-n`): one `key: value` line per key it knows.
|
||||
* Pure. Unknown lines, blank lines and error lines are skipped, and so is a last line that does
|
||||
* not end in a newline. The first line of a key wins.
|
||||
*/
|
||||
export function parseSysctl(text: string): SysctlFacts {
|
||||
const facts: SysctlFacts = {};
|
||||
const lines = text.split('\n');
|
||||
// What follows the last newline is either empty or a cut-off line.
|
||||
lines.pop();
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const value = line.slice(colon + 1).trim();
|
||||
switch (key) {
|
||||
case 'hw.memsize': {
|
||||
const bytes = parseCount(value);
|
||||
if (bytes !== undefined && bytes > 0) facts.memBytes ??= bytes;
|
||||
break;
|
||||
}
|
||||
case 'machdep.cpu.brand_string':
|
||||
facts.chip ??= parseText(value);
|
||||
break;
|
||||
case 'hw.optional.arm64':
|
||||
facts.arm64 ??= parseFlag(value);
|
||||
break;
|
||||
case 'sysctl.proc_translated':
|
||||
facts.translated ??= parseFlag(value);
|
||||
break;
|
||||
case 'iogpu.wired_limit_mb':
|
||||
facts.wiredLimitMb ??= parseCount(value);
|
||||
break;
|
||||
case 'kern.osproductversion': {
|
||||
const version = /^(\d+)(?:\.\d+)*$/.exec(value);
|
||||
if (version && value.length <= MAX_TEXT_VALUE_LENGTH && facts.macosVersion === undefined) {
|
||||
facts.macosVersion = value;
|
||||
facts.macosMajor = Number(version[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'hw.model':
|
||||
facts.model ??= parseText(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one spawn: `command` with `SYSCTL_KEYS` as its arguments and nothing else (no `-n`).
|
||||
* Resolves stdout when the program ran, also after a non-zero exit (a key this machine lacks
|
||||
* makes sysctl exit 1 while the other keys are printed). Resolves `undefined` when it could not
|
||||
* be started, was killed (the timeout) or printed too much. Never rejects.
|
||||
*
|
||||
* The two parameters exist for the test, which runs a script of its own in place of sysctl.
|
||||
*/
|
||||
export function spawnSysctl(
|
||||
command: string = SYSCTL_PATH,
|
||||
timeoutMs: number = SYSCTL_TIMEOUT_MS
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
execFile(
|
||||
command,
|
||||
[...SYSCTL_KEYS],
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: SYSCTL_MAX_OUTPUT_BYTES,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
// sysctl needs nothing from the server's environment, which can hold a password.
|
||||
env: { LC_ALL: 'C' },
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (!error) return resolve(stdout);
|
||||
// A numeric code is an exit status: the program ran. A string code (ENOENT, EACCES,
|
||||
// the buffer limit) or a signal means it did not run to its end.
|
||||
const ranToEnd = typeof error.code === 'number' && !error.killed && !error.signal;
|
||||
resolve(ranToEnd ? stdout : undefined);
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Free bytes on the filesystem that holds `path` (`bavail * bsize`: what an unprivileged
|
||||
* process can use), or `undefined` when the filesystem does not say. Not bounded by itself: see
|
||||
* `dataDirFreeBytes()`.
|
||||
*/
|
||||
export async function freeDiskBytesOf(path: string): Promise<number | undefined> {
|
||||
try {
|
||||
const stat = await statfs(path);
|
||||
const free = Number(stat.bavail) * Number(stat.bsize);
|
||||
return Number.isFinite(free) && free >= 0 ? free : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The one `statfs` that may be pending. While it is, no second one is started. */
|
||||
let pendingStatfs: Promise<number | undefined> | undefined;
|
||||
|
||||
/** The IO of `dataDirFreeBytes()`. Each member defaults to the real thing; a test passes its own. */
|
||||
export interface FreeDiskDeps {
|
||||
/** Default `getDataDir()`. */
|
||||
dataDir?: () => string;
|
||||
/** Default the bounded path probe (`probePathKind`). */
|
||||
probeKind?: (path: string) => Promise<PathProbeKind>;
|
||||
/** Default `freeDiskBytesOf`. */
|
||||
freeBytesOf?: (path: string) => Promise<number | undefined>;
|
||||
/** Default `FREE_DISK_TIMEOUT_MS`. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free bytes on the data dir's filesystem, bounded. The folder (or, when it does not exist yet,
|
||||
* its nearest existing parent) is found with the bounded path probe, which never waits on a
|
||||
* dead mount twice; the `statfs` itself is raced against a timeout. `undefined` when the
|
||||
* filesystem does not answer in time, or is not known to be a folder.
|
||||
*/
|
||||
export async function dataDirFreeBytes(deps: FreeDiskDeps = {}): Promise<number | undefined> {
|
||||
// Concurrent probes share one promise in `readHardware()`, so a `statfs` that is still pending
|
||||
// here was started by an EARLIER probe and has outlived its timeout: a dead mount. It holds
|
||||
// one threadpool worker, and that is the most this module may ever hold.
|
||||
if (pendingStatfs) return undefined;
|
||||
|
||||
const probeKind = deps.probeKind ?? probePathKind;
|
||||
let folder: string;
|
||||
try {
|
||||
folder = (deps.dataDir ?? getDataDir)();
|
||||
let kind = await probeKind(folder);
|
||||
for (let step = 0; kind === 'absent' && step < FREE_DISK_MAX_PARENT_STEPS; step++) {
|
||||
const parent = dirname(folder);
|
||||
if (parent === folder) break;
|
||||
folder = parent;
|
||||
kind = await probeKind(folder);
|
||||
}
|
||||
// 'unknown' is a mount that does not answer (or an unreadable path): do not touch it.
|
||||
if (kind !== 'directory') return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const started = (deps.freeBytesOf ?? freeDiskBytesOf)(folder).catch(() => undefined);
|
||||
pendingStatfs = started;
|
||||
const forget = (): void => {
|
||||
if (pendingStatfs === started) pendingStatfs = undefined;
|
||||
};
|
||||
started.then(forget, forget);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
started,
|
||||
new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(() => resolve(undefined), deps.timeoutMs ?? FREE_DISK_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCpuModel(): string | undefined {
|
||||
return os.cpus()[0]?.model;
|
||||
}
|
||||
|
||||
/** A dependency that must never break the probe: its value, or `undefined` when it threw or rejected. */
|
||||
async function attempt<T>(read: () => T | Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await read();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Memory and the three fields derived from it. */
|
||||
function memoryFields(memBytes: number | undefined): Pick<HardwareFacts, 'memBytes' | 'memGb' | 'memClassGb'> {
|
||||
const bytes = memBytes !== undefined && Number.isFinite(memBytes) && memBytes > 0 ? memBytes : 0;
|
||||
return { memBytes: bytes, memGb: formatGb(bytes), memClassGb: memClassGb(bytes) };
|
||||
}
|
||||
|
||||
async function probeHardware(deps: HardwareProbeDeps): Promise<HardwareFacts> {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const arch = deps.arch ?? os.arch();
|
||||
const totalMemBytes = deps.totalMemBytes ?? os.totalmem;
|
||||
const cpuModel = deps.cpuModel ?? defaultCpuModel;
|
||||
|
||||
if (platform !== 'darwin') {
|
||||
return { platform, arch, ...memoryFields(await attempt(totalMemBytes)), source: 'not-probed' };
|
||||
}
|
||||
|
||||
const [stdout, freeDisk] = await Promise.all([
|
||||
attempt(deps.runSysctl ?? (() => spawnSysctl())),
|
||||
attempt(deps.freeDiskBytes ?? (() => dataDirFreeBytes())),
|
||||
]);
|
||||
const disk: Pick<HardwareFacts, 'freeDiskBytes' | 'freeDiskGb'> =
|
||||
freeDisk !== undefined && Number.isFinite(freeDisk) && freeDisk >= 0
|
||||
? { freeDiskBytes: freeDisk, freeDiskGb: formatGb(freeDisk) }
|
||||
: {};
|
||||
|
||||
const facts = typeof stdout === 'string' ? parseSysctl(stdout) : {};
|
||||
if (facts.memBytes === undefined) {
|
||||
const chip = await attempt(cpuModel);
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
...memoryFields(await attempt(totalMemBytes)),
|
||||
...(chip ? { chip } : {}),
|
||||
...disk,
|
||||
source: 'os-fallback',
|
||||
};
|
||||
}
|
||||
|
||||
const chip = facts.chip ?? (await attempt(cpuModel));
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
...(facts.arm64 !== undefined ? { arm64: facts.arm64 } : {}),
|
||||
...(facts.translated !== undefined ? { translated: facts.translated } : {}),
|
||||
...memoryFields(facts.memBytes),
|
||||
...(chip ? { chip } : {}),
|
||||
...(facts.model !== undefined ? { model: facts.model } : {}),
|
||||
...(facts.macosVersion !== undefined ? { macosVersion: facts.macosVersion } : {}),
|
||||
...(facts.macosMajor !== undefined ? { macosMajor: facts.macosMajor } : {}),
|
||||
...(facts.wiredLimitMb !== undefined ? { wiredLimitMb: facts.wiredLimitMb } : {}),
|
||||
...disk,
|
||||
source: 'sysctl',
|
||||
};
|
||||
}
|
||||
|
||||
let cached: { at: number; facts: HardwareFacts } | undefined;
|
||||
let inFlight: Promise<HardwareFacts> | undefined;
|
||||
|
||||
/**
|
||||
* What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe.
|
||||
* Never rejects.
|
||||
*
|
||||
* The cache is one per process and does not look at `deps`: within the 60 s a second call
|
||||
* returns the first call's answer whatever it injects. A test resets it first.
|
||||
*/
|
||||
export async function readHardware(deps: HardwareProbeDeps = {}): Promise<HardwareFacts> {
|
||||
const now = deps.now ?? Date.now;
|
||||
if (cached) {
|
||||
const age = now() - cached.at;
|
||||
if (age >= 0 && age < HARDWARE_CACHE_TTL_MS) return cached.facts;
|
||||
}
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const probe = probeHardware(deps)
|
||||
.catch(
|
||||
// Unreachable by design (every read above is guarded), and still an answer if it is reached.
|
||||
(): HardwareFacts => ({
|
||||
platform: deps.platform ?? process.platform,
|
||||
arch: deps.arch ?? os.arch(),
|
||||
...memoryFields(undefined),
|
||||
source: (deps.platform ?? process.platform) === 'darwin' ? 'os-fallback' : 'not-probed',
|
||||
})
|
||||
)
|
||||
.then((facts) => {
|
||||
cached = { at: now(), facts };
|
||||
return facts;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight === probe) inFlight = undefined;
|
||||
});
|
||||
inFlight = probe;
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** Drop the cached probe result and the memory of a pending disk read (tests, and nothing else). */
|
||||
export function resetHardwareCache(): void {
|
||||
cached = undefined;
|
||||
inFlight = undefined;
|
||||
pendingStatfs = undefined;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @fileoverview What the server knows about each catalogue harness on this machine, for the
|
||||
* local model recommendation. Server side: this is the one module of src/local-model/ that
|
||||
* reads the CLI registry, so `recommend()` can stay pure and name no CLI.
|
||||
*
|
||||
* `buildHarnessFacts()` walks the catalogue's `harnesses` rows and, for every row:
|
||||
* - resolves its `cli` in the CLI registry AT CALL TIME (never in a module-level constant),
|
||||
* and leaves the row out when the CLI is unknown, disabled, or has no custom model
|
||||
* mechanism (`capabilities.customModelInjection.kind` is `unsupported`);
|
||||
* - reports `label` from the registry entry and `installed` from the same probe the page
|
||||
* render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build;
|
||||
* - reports `installable` only for a STOCK CLI that is not installed and whose install
|
||||
* command for this platform needs no administrator rights (`installCommandNeedsAdmin()`),
|
||||
* and then carries that command as `installCommand`, so the consent screen can print the
|
||||
* literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the
|
||||
* one a `clis.json` override supplies: it is exactly what the install job would run, and
|
||||
* an override file must not be able to change that;
|
||||
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
|
||||
* applies to the model id before anything is written.
|
||||
*
|
||||
* Rules:
|
||||
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue
|
||||
* row and everything else from the registry entry it resolves to.
|
||||
* - Read-only: nothing is installed, written or spawned beyond the existing availability
|
||||
* probe. An install command is only read as text, never run.
|
||||
* - Never throws: a probe that fails reads as "not installed" (with a warning in the log),
|
||||
* and the registry's install data still answers `installable`.
|
||||
*
|
||||
* `cliAcceptsCustomModelId()` composes the two pure pieces the launch path itself calls
|
||||
* (`buildCustomModelInjection()` and `matchesPattern()` on the entry's `model` launch param).
|
||||
* The launch path keeps that composition inline at two sites in
|
||||
* src/web/routes/session-routes.ts (the quick-start `customModel` branch and
|
||||
* `POST /api/sessions/:id/custom-model`). A change to the rule there must be made here too,
|
||||
* until the three share one helper.
|
||||
*/
|
||||
|
||||
import type { CliEntry } from '../config/cli-registry/types.js';
|
||||
import { matchesPattern } from '../config/cli-registry/patterns.js';
|
||||
import { stockInstallCommand } from '../cli-install-runner.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
import { buildCustomModelInjection, type CustomModelEndpoint } from '../custom-model-injection.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
|
||||
import { getErrorMessage, type HarnessFact, type LocalModelCatalog } from '../types.js';
|
||||
|
||||
/** What `buildHarnessFacts()` reads. Each member defaults to the real module; a test overrides what it needs. */
|
||||
export interface HarnessFactsDeps {
|
||||
/** One CLI of the registry, looked up when the facts are built. */
|
||||
getCli: (id: string) => CliEntry | undefined;
|
||||
/**
|
||||
* The availability probe, asked ONCE per build: it answers with a test for single entries.
|
||||
* Under vitest the real probe is inert (it finds no CLI), so a test that cares about
|
||||
* `installed` passes its own.
|
||||
*/
|
||||
probeInstalled: () => Promise<(entry: CliEntry) => boolean>;
|
||||
/**
|
||||
* The install command the install job would run for this CLI on this platform: the compiled
|
||||
* stock command, `undefined` for a CLI that is not stock or has none here. Read as text,
|
||||
* never run.
|
||||
*/
|
||||
installCommandFor: (entry: CliEntry) => string | undefined;
|
||||
}
|
||||
|
||||
export const defaultHarnessFactsDeps: HarnessFactsDeps = {
|
||||
getCli,
|
||||
probeInstalled: async () => {
|
||||
const stockAvailability = await probeStockCliAvailability();
|
||||
return (entry) => isCliEntryInstalled(entry, stockAvailability);
|
||||
},
|
||||
installCommandFor: (entry) => stockInstallCommand(entry.id as string),
|
||||
};
|
||||
|
||||
/**
|
||||
* A command segment that starts with `sudo` or `brew`: at the start of the text, or after a
|
||||
* pipe, `&&`, `||`, `;`, a line break or an opening parenthesis.
|
||||
*/
|
||||
const ADMIN_COMMAND = /(?:^|[;&|(\n])\s*(?:sudo|brew)(?=\s|$)/;
|
||||
|
||||
/**
|
||||
* Whether an install command needs administrator rights, read off its text.
|
||||
* - `sudo ...` asks for an administrator password.
|
||||
* - `brew ...` needs Homebrew, and installing Homebrew needs an administrator (the same rule
|
||||
* the installer's no-admin route applies to the CLI menu).
|
||||
* `curl ... | bash` installers write into the home directory, and the install runner points an
|
||||
* `npm install -g` at a prefix the server user can write, so neither counts.
|
||||
*/
|
||||
export function installCommandNeedsAdmin(command: string): boolean {
|
||||
return ADMIN_COMMAND.test(command);
|
||||
}
|
||||
|
||||
/** Stands in for a configured endpoint: the launch id a recipe forces depends on the model id alone. */
|
||||
const PROBE_ENDPOINT: CustomModelEndpoint = {
|
||||
id: 'local-model-probe',
|
||||
label: 'local model probe',
|
||||
baseUrl: 'http://127.0.0.1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a custom model launch of this CLI would accept `modelId`.
|
||||
*
|
||||
* A CLI whose config alone cannot select the model gets the id forced onto its `model` launch
|
||||
* param (the recipe's `launchModel` template). The argv engine DROPS a token that fails the
|
||||
* param's pattern, which would start the CLI on its own default provider, so the launch path
|
||||
* refuses such an id up front. This is that test: false when the forced value does not match
|
||||
* the pattern, and false for a CLI with no custom model mechanism at all.
|
||||
*/
|
||||
export function cliAcceptsCustomModelId(entry: Pick<CliEntry, 'capabilities' | 'launch'>, modelId: string): boolean {
|
||||
try {
|
||||
const injection = buildCustomModelInjection(entry, PROBE_ENDPOINT, modelId);
|
||||
if (injection.kind === 'unsupported') return false;
|
||||
const forcedLaunchModel = injection.launchModel;
|
||||
const modelSpec = entry.launch.params.model;
|
||||
return !(
|
||||
forcedLaunchModel !== undefined &&
|
||||
modelSpec?.type === 'token' &&
|
||||
!matchesPattern(modelSpec.pattern, forcedLaunchModel)
|
||||
);
|
||||
} catch {
|
||||
// A recipe that cannot be rendered cannot be launched either.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** One fact per catalogue harness that resolves in the CLI registry, in catalogue order. */
|
||||
export async function buildHarnessFacts(
|
||||
catalog: LocalModelCatalog,
|
||||
deps: Partial<HarnessFactsDeps> = {}
|
||||
): Promise<HarnessFact[]> {
|
||||
const resolved: HarnessFactsDeps = { ...defaultHarnessFactsDeps, ...deps };
|
||||
|
||||
let isInstalled: (entry: CliEntry) => boolean;
|
||||
try {
|
||||
isInstalled = await resolved.probeInstalled();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[local-model] The CLI availability probe failed, reading every harness as not installed: ${getErrorMessage(err)}`
|
||||
);
|
||||
isInstalled = () => false;
|
||||
}
|
||||
|
||||
const facts: HarnessFact[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of catalog.harnesses) {
|
||||
// The catalogue lists a CLI once; a second row for it adds nothing a fact could carry.
|
||||
if (seen.has(row.cli)) continue;
|
||||
seen.add(row.cli);
|
||||
|
||||
const entry = resolved.getCli(row.cli);
|
||||
if (!entry || !entry.enabled) continue;
|
||||
if (entry.capabilities.customModelInjection.kind === 'unsupported') continue;
|
||||
|
||||
let installed = false;
|
||||
try {
|
||||
installed = isInstalled(entry);
|
||||
} catch (err) {
|
||||
console.warn(`[local-model] Probing ${entry.label} failed, reading it as not installed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
let installCommand: string | undefined;
|
||||
if (!installed && entry.stock) {
|
||||
const command = resolved.installCommandFor(entry);
|
||||
if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) {
|
||||
installCommand = command;
|
||||
}
|
||||
}
|
||||
|
||||
facts.push({
|
||||
cli: row.cli,
|
||||
label: entry.label,
|
||||
installed,
|
||||
installable: installCommand !== undefined,
|
||||
...(installCommand !== undefined ? { installCommand } : {}),
|
||||
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
|
||||
});
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* @fileoverview The HTTP client of the model runtime: what Codeman asks its own runtime on
|
||||
* the loopback port, and the pure functions that read the answers.
|
||||
*
|
||||
* What it asks:
|
||||
* - `GET /api/version`, `GET /api/tags`, `GET /api/ps`, `GET /v1/models`, `DELETE /api/delete`.
|
||||
* - `POST /api/pull` with `stream: true`. The answer is one JSON object per line, and a
|
||||
* 23 GB pull sends about 1.4 million of them, so the stream is parsed INCREMENTALLY
|
||||
* (`createPullParser()`, pure): only the unfinished last line and the running totals are
|
||||
* kept, never the body. Aborting the request stops the download; pulling again continues
|
||||
* it. An error line or a stream that ends without `success` is retried, at most
|
||||
* `PULL_RETRIES` times.
|
||||
* - The check after the pull (`verifyModel()`), in this order: one tiny chat with
|
||||
* `reasoning_effort: "none"` (it loads the model), then `GET /api/ps` must show the whole
|
||||
* model on the GPU (`size_vram` equal to `size`) at the chosen context
|
||||
* (`judgeLoaded()`, pure), then one tool probe on `/v1/chat/completions` with a `tools`
|
||||
* array, `reasoning_effort: "none"` and `max_tokens` of `TOOL_PROBE_MAX_TOKENS`, which
|
||||
* passes on a tool call whose arguments parse as JSON (`judgeToolProbe()`, pure).
|
||||
*
|
||||
* Rules:
|
||||
* - Nothing here throws for something a runtime can answer: every outcome is a result with
|
||||
* the job's error code. An abort signal is the result `cancelled`.
|
||||
* - Every request is bounded: a listing by seconds, the two probes by minutes (the first
|
||||
* chat loads the model). Only the pull has no deadline of its own, because its length is
|
||||
* the download; it ends with the stream, and the caller's signal cancels it.
|
||||
* - The runtime does not enforce the context length (a prompt of 167,012 tokens was accepted
|
||||
* at 131072), so nothing here relies on it to.
|
||||
* - Every function takes its `fetch` from the target, so a test passes the fake runtime's
|
||||
* address and spawns nothing. The target is always a loopback address the caller built from
|
||||
* the stored port: no function here takes a URL from a file or a request.
|
||||
*/
|
||||
|
||||
import type { LocalModelErrorCode } from '../types.js';
|
||||
import { formatGb } from './format.js';
|
||||
|
||||
/** How often a pull that ended on an error line or a cut stream is started again. */
|
||||
export const PULL_RETRIES = 5;
|
||||
/** `max_tokens` of the tool probe. */
|
||||
export const TOOL_PROBE_MAX_TOKENS = 1024;
|
||||
|
||||
/** How long `/api/version` may take: it is asked while a runtime starts, many times. */
|
||||
const VERSION_TIMEOUT_MS = 2_000;
|
||||
/** How long a listing or a delete may take. */
|
||||
const LIST_TIMEOUT_MS = 10_000;
|
||||
/** How long the chat that loads the model may take. A 23 GB model loaded in 5.9 s on the test Mac; a cold disk is slower. */
|
||||
const CHAT_PROBE_TIMEOUT_MS = 180_000;
|
||||
/** How long the tool probe may take. The model is loaded by then. */
|
||||
const TOOL_PROBE_TIMEOUT_MS = 120_000;
|
||||
/** `max_tokens` of the chat that loads the model: its answer is not read. */
|
||||
const CHAT_PROBE_MAX_TOKENS = 16;
|
||||
/** The least time between two progress reports of a pull. */
|
||||
const PULL_PROGRESS_INTERVAL_MS = 250;
|
||||
/** The window the download speed is averaged over. */
|
||||
const PULL_SPEED_WINDOW_MS = 5_000;
|
||||
/** The first wait before a pull is started again; it doubles with every retry. */
|
||||
const PULL_RETRY_DELAY_MS = 1_000;
|
||||
/** A pull line longer than this is not a pull line. It is dropped, so a stream without line breaks cannot grow the buffer. */
|
||||
const MAX_PULL_LINE_CHARS = 1024 * 1024;
|
||||
/** More layers than any model has. Lines for further ones are counted and not added up, so the layer table cannot grow without bound. */
|
||||
const MAX_PULL_LAYERS = 1024;
|
||||
/** How much of a runtime's error text is kept as `detail`. */
|
||||
const MAX_DETAIL_CHARS = 400;
|
||||
|
||||
/** Which runtime to ask. */
|
||||
export interface OllamaTarget {
|
||||
/** `http://127.0.0.1:<port>`, without a trailing slash. */
|
||||
baseUrl: string;
|
||||
/** Default: the global `fetch`. */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
// ---------- Plumbing ----------
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
/** A byte count or a token count as the runtime reports it: a finite number that is not negative. */
|
||||
function asCount(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function clip(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > MAX_DETAIL_CHARS ? `${trimmed.slice(0, MAX_DETAIL_CHARS)}...` : trimmed;
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
// undici wraps the reason ("fetch failed") and keeps the useful part in `cause`.
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
return clip(cause instanceof Error && cause.message ? `${err.message}: ${cause.message}` : err.message);
|
||||
}
|
||||
return clip(String(err));
|
||||
}
|
||||
|
||||
/** The error text of a runtime answer: `{"error":"..."}` (its own routes) or `{"error":{"message":"..."}}` (the OpenAI routes). */
|
||||
function errorOfBody(body: unknown): string | undefined {
|
||||
if (!isRecord(body)) return undefined;
|
||||
const { error } = body;
|
||||
if (typeof error === 'string') return asString(error);
|
||||
if (isRecord(error)) return asString(error.message);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The caller's signal joined with a deadline. */
|
||||
function bounded(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const deadline = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, deadline]) : deadline;
|
||||
}
|
||||
|
||||
/** An answer of the runtime: its status and its body as JSON (`undefined` when it is not JSON). */
|
||||
interface Answer {
|
||||
status: number;
|
||||
body: unknown;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bounded request. `undefined` when nothing answered (refused, timed out, aborted): the
|
||||
* caller looks at its own signal to tell an abort from the rest.
|
||||
*/
|
||||
async function ask(
|
||||
target: OllamaTarget,
|
||||
path: string,
|
||||
init: { method?: string; json?: unknown },
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Answer | undefined> {
|
||||
const fetchFn = target.fetch ?? fetch;
|
||||
try {
|
||||
const res = await fetchFn(`${target.baseUrl}${path}`, {
|
||||
method: init.method ?? 'GET',
|
||||
...(init.json !== undefined
|
||||
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(init.json) }
|
||||
: {}),
|
||||
signal: bounded(signal, timeoutMs),
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: unknown;
|
||||
try {
|
||||
body = text === '' ? undefined : JSON.parse(text);
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
return { status: res.status, body, text };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** A digest as it is stored and compared: lowercase hex without the `sha256:` prefix. */
|
||||
function normalizeDigest(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^sha256:/, '')
|
||||
: '';
|
||||
}
|
||||
|
||||
// ---------- Listings ----------
|
||||
|
||||
/** One model of `GET /api/tags`: what is on disk. */
|
||||
export interface OllamaTagModel {
|
||||
/** `name:tag`. */
|
||||
name: string;
|
||||
/** Size on disk in bytes. */
|
||||
size: number;
|
||||
/** The model's digest, lowercase hex without a prefix. What is stored and compared again before a session starts. */
|
||||
digest: string;
|
||||
/** `details.format` ('safetensors' for an MLX build, 'gguf'). */
|
||||
format?: string;
|
||||
/** `details.runner` ('mlx', 'ggml'). */
|
||||
runner?: string;
|
||||
/** `capabilities` ('completion', 'tools', 'thinking', 'vision'). */
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
/** One model of `GET /api/ps`: what is loaded. */
|
||||
export interface OllamaPsModel {
|
||||
name: string;
|
||||
/** Memory the loaded model takes, in bytes. */
|
||||
size: number;
|
||||
/** The part of `size` that is on the GPU. Equal to `size` when all of it is. */
|
||||
sizeVram: number;
|
||||
/** The context length the model was loaded with. */
|
||||
contextLength: number;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
/** The `models` array of a listing, or an empty one. */
|
||||
function modelsOf(answer: Answer | undefined): Record<string, unknown>[] {
|
||||
if (!answer || answer.status !== 200 || !isRecord(answer.body)) return [];
|
||||
const { models } = answer.body;
|
||||
return Array.isArray(models) ? models.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
/** The runtime's version ('0.40.15'), or `undefined` when nothing answers there or the answer is not a runtime's. */
|
||||
export async function getRuntimeVersion(target: OllamaTarget, signal?: AbortSignal): Promise<string | undefined> {
|
||||
const answer = await ask(target, '/api/version', {}, VERSION_TIMEOUT_MS, signal);
|
||||
if (!answer || answer.status !== 200 || !isRecord(answer.body)) return undefined;
|
||||
return asString(answer.body.version);
|
||||
}
|
||||
|
||||
/** The models on disk. An empty list when the runtime has none or does not answer. */
|
||||
export async function listTags(target: OllamaTarget, signal?: AbortSignal): Promise<OllamaTagModel[]> {
|
||||
const listed: OllamaTagModel[] = [];
|
||||
for (const row of modelsOf(await ask(target, '/api/tags', {}, LIST_TIMEOUT_MS, signal))) {
|
||||
const name = asString(row.name) ?? asString(row.model);
|
||||
if (!name) continue;
|
||||
const details = isRecord(row.details) ? row.details : {};
|
||||
const format = asString(details.format);
|
||||
const runner = asString(details.runner) ?? asString(row.runner);
|
||||
const capabilities = Array.isArray(row.capabilities)
|
||||
? row.capabilities.filter((entry): entry is string => typeof entry === 'string')
|
||||
: undefined;
|
||||
listed.push({
|
||||
name,
|
||||
size: asCount(row.size) ?? 0,
|
||||
digest: normalizeDigest(row.digest),
|
||||
...(format ? { format } : {}),
|
||||
...(runner ? { runner } : {}),
|
||||
...(capabilities ? { capabilities } : {}),
|
||||
});
|
||||
}
|
||||
return listed;
|
||||
}
|
||||
|
||||
/** The models loaded right now. An empty list when none is or the runtime does not answer. */
|
||||
export async function listRunning(target: OllamaTarget, signal?: AbortSignal): Promise<OllamaPsModel[]> {
|
||||
const listed: OllamaPsModel[] = [];
|
||||
for (const row of modelsOf(await ask(target, '/api/ps', {}, LIST_TIMEOUT_MS, signal))) {
|
||||
const name = asString(row.name) ?? asString(row.model);
|
||||
if (!name) continue;
|
||||
const digest = normalizeDigest(row.digest);
|
||||
listed.push({
|
||||
name,
|
||||
size: asCount(row.size) ?? 0,
|
||||
sizeVram: asCount(row.size_vram) ?? 0,
|
||||
contextLength: asCount(row.context_length) ?? 0,
|
||||
...(digest ? { digest } : {}),
|
||||
});
|
||||
}
|
||||
return listed;
|
||||
}
|
||||
|
||||
/** The model ids `GET /v1/models` lists, exactly as listed (the runtime answers `data: null` for none). */
|
||||
export async function listOpenAiModels(target: OllamaTarget, signal?: AbortSignal): Promise<string[]> {
|
||||
const answer = await ask(target, '/v1/models', {}, LIST_TIMEOUT_MS, signal);
|
||||
if (!answer || answer.status !== 200 || !isRecord(answer.body)) return [];
|
||||
const { data } = answer.body;
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.flatMap((row) => (isRecord(row) && typeof row.id === 'string' && row.id !== '' ? [row.id] : []));
|
||||
}
|
||||
|
||||
/** The digest `GET /api/tags` reports for `modelId`, or `undefined` when the model is not on disk. */
|
||||
export async function readModelDigest(
|
||||
target: OllamaTarget,
|
||||
modelId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | undefined> {
|
||||
const found = (await listTags(target, signal)).find((model) => model.name === modelId);
|
||||
return found && found.digest !== '' ? found.digest : undefined;
|
||||
}
|
||||
|
||||
/** Deletes a model from the runtime's store. True when it is gone afterwards (also when it never was there). */
|
||||
export async function deleteModel(target: OllamaTarget, modelId: string, signal?: AbortSignal): Promise<boolean> {
|
||||
const answer = await ask(
|
||||
target,
|
||||
'/api/delete',
|
||||
{ method: 'DELETE', json: { model: modelId } },
|
||||
LIST_TIMEOUT_MS,
|
||||
signal
|
||||
);
|
||||
// 404 is the runtime's answer for a model it does not have.
|
||||
return answer !== undefined && (answer.status === 200 || answer.status === 404);
|
||||
}
|
||||
|
||||
// ---------- Pull ----------
|
||||
|
||||
/** What the pull's lines add up to so far. */
|
||||
export interface PullSnapshot {
|
||||
/** Sum of the last `completed` of every layer seen. */
|
||||
completedBytes: number;
|
||||
/** Sum of the `total` of every layer seen. Grows while new layers appear; 0 before the first. */
|
||||
totalBytes: number;
|
||||
/** The `status` text of the last line that had one ('pulling manifest', 'writing manifest'). */
|
||||
status?: string;
|
||||
/** A line `{"status":"success"}` was seen. */
|
||||
succeeded: boolean;
|
||||
/** The text of the first `{"error":"..."}` line. */
|
||||
error?: string;
|
||||
/** How many complete lines were parsed. */
|
||||
lines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a pull stream piece by piece. It keeps the unfinished last line and the totals per
|
||||
* layer, never the lines themselves, so its memory does not grow with the stream.
|
||||
*/
|
||||
export interface PullParser {
|
||||
/** Feed the next piece of the body. A line may be split anywhere across pieces, a multi-byte character too. */
|
||||
push(chunk: string | Uint8Array): void;
|
||||
/** The body ended: reads a last line that had no line break. */
|
||||
end(): void;
|
||||
snapshot(): PullSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parser for one pull stream. Pure: no IO, no clock. Lines it understands (both real):
|
||||
*
|
||||
* - an MLX build: `{"status":"pulling model","digest":"sha256:model","total":23105798956,"completed":N}`
|
||||
* - a GGUF build, one layer after the other:
|
||||
* `{"status":"pulling c5396e06af29","digest":"sha256:c539...","total":397807936,"completed":N}`
|
||||
* (`completed` is absent until the layer's first bytes arrived)
|
||||
* - `{"status":"pulling manifest"}`, `{"status":"verifying sha256 digest"}`,
|
||||
* `{"status":"writing manifest"}`, `{"status":"success"}`
|
||||
* - `{"error":"max retries exceeded: local error: tls: bad record MAC"}`
|
||||
*
|
||||
* A line that is not JSON is skipped and still counted. A blank line is not a line.
|
||||
*/
|
||||
export function createPullParser(): PullParser {
|
||||
const decoder = new TextDecoder();
|
||||
// One entry per layer: what the layer's last line said. A model has a handful of layers.
|
||||
const layers = new Map<string, { total: number; completed: number }>();
|
||||
let pending = '';
|
||||
// True while a line longer than the limit is being skipped up to its line break.
|
||||
let skipping = false;
|
||||
let status: string | undefined;
|
||||
let succeeded = false;
|
||||
let error: string | undefined;
|
||||
let lines = 0;
|
||||
|
||||
function line(text: string): void {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === '') return;
|
||||
lines += 1;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!isRecord(parsed)) return;
|
||||
const failure = errorOfBody(parsed);
|
||||
if (failure !== undefined) {
|
||||
error ??= clip(failure);
|
||||
return;
|
||||
}
|
||||
const lineStatus = asString(parsed.status);
|
||||
if (lineStatus !== undefined) {
|
||||
status = lineStatus;
|
||||
if (lineStatus === 'success') succeeded = true;
|
||||
}
|
||||
const total = asCount(parsed.total);
|
||||
if (total === undefined) return;
|
||||
// A layer is named by its digest; a line without one by its status text.
|
||||
const key = asString(parsed.digest) ?? lineStatus;
|
||||
if (key === undefined) return;
|
||||
const known = layers.get(key);
|
||||
if (!known && layers.size >= MAX_PULL_LAYERS) return;
|
||||
// `completed` is absent until the layer's first bytes arrived: what was seen before stays.
|
||||
const completed = asCount(parsed.completed) ?? known?.completed ?? 0;
|
||||
layers.set(key, { total, completed: Math.min(completed, total) });
|
||||
}
|
||||
|
||||
function feed(text: string): void {
|
||||
let rest = text;
|
||||
for (;;) {
|
||||
const breakAt = rest.indexOf('\n');
|
||||
if (breakAt < 0) break;
|
||||
const head = rest.slice(0, breakAt);
|
||||
rest = rest.slice(breakAt + 1);
|
||||
if (skipping) {
|
||||
// The end of the line that was too long: it was counted when it was dropped.
|
||||
skipping = false;
|
||||
} else {
|
||||
line(pending + head);
|
||||
}
|
||||
pending = '';
|
||||
}
|
||||
if (skipping) return;
|
||||
pending += rest;
|
||||
if (pending.length > MAX_PULL_LINE_CHARS) {
|
||||
pending = '';
|
||||
skipping = true;
|
||||
lines += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push(chunk) {
|
||||
feed(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
|
||||
},
|
||||
end() {
|
||||
feed(decoder.decode());
|
||||
if (!skipping && pending !== '') line(pending);
|
||||
pending = '';
|
||||
skipping = false;
|
||||
},
|
||||
snapshot() {
|
||||
let completedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
for (const layer of layers.values()) {
|
||||
completedBytes += layer.completed;
|
||||
totalBytes += layer.total;
|
||||
}
|
||||
return {
|
||||
completedBytes,
|
||||
totalBytes,
|
||||
...(status !== undefined ? { status } : {}),
|
||||
succeeded,
|
||||
...(error !== undefined ? { error } : {}),
|
||||
lines,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** How far a pull is, for the caller's progress display. */
|
||||
export interface PullProgress {
|
||||
completedBytes: number;
|
||||
totalBytes: number;
|
||||
/** Over the last few seconds. Absent until it can be told. */
|
||||
bytesPerSecond?: number;
|
||||
}
|
||||
|
||||
export interface PullOptions {
|
||||
/** Aborts the request, which stops the download. The result is `cancelled`. */
|
||||
signal?: AbortSignal;
|
||||
/** Called at most a few times a second, and once more when the pull ends. */
|
||||
onProgress?: (progress: PullProgress) => void;
|
||||
/** Default `PULL_RETRIES`. */
|
||||
retries?: number;
|
||||
/** Waits between retries. A test injects its own. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** The clock of the speed figure, in milliseconds. A test injects its own. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** How a pull ended. */
|
||||
export type PullResult =
|
||||
| {
|
||||
ok: true;
|
||||
/** The pull's own total: what every size shown for the model comes from once it is known. */
|
||||
totalBytes: number;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
code: Extract<LocalModelErrorCode, 'pull-failed' | 'model-not-found' | 'disk-full'>;
|
||||
/** The runtime's last error text. */
|
||||
detail?: string;
|
||||
}
|
||||
| { ok: false; code: 'cancelled' };
|
||||
|
||||
/** How one request of a pull ended. */
|
||||
type PullAttempt =
|
||||
| { kind: 'success'; totalBytes: number }
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'fatal'; code: 'model-not-found' | 'disk-full'; detail: string }
|
||||
| { kind: 'retry'; detail: string };
|
||||
|
||||
/**
|
||||
* What an error text of the runtime means for the pull. A model the registry does not have and
|
||||
* a full disk do not get better by asking again; everything else (a damaged download, a
|
||||
* dropped connection) may.
|
||||
*/
|
||||
function classifyPullError(text: string): PullAttempt {
|
||||
const detail = clip(text);
|
||||
if (/file does not exist|manifest unknown|manifest\b.*\bnot found|model\b.*\bnot found/i.test(text)) {
|
||||
return { kind: 'fatal', code: 'model-not-found', detail };
|
||||
}
|
||||
if (/no space left on device|disk (is )?full|not enough (free )?(disk )?space/i.test(text)) {
|
||||
return { kind: 'fatal', code: 'disk-full', detail };
|
||||
}
|
||||
return { kind: 'retry', detail };
|
||||
}
|
||||
|
||||
/** A wait that ends early when the signal aborts. */
|
||||
function waitFor(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const done = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, ms);
|
||||
signal?.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** One `POST /api/pull`, read to its end. */
|
||||
async function pullOnce(target: OllamaTarget, modelId: string, options: PullOptions): Promise<PullAttempt> {
|
||||
const { signal, onProgress } = options;
|
||||
const now = options.now ?? Date.now;
|
||||
const fetchFn = target.fetch ?? fetch;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchFn(`${target.baseUrl}/api/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelId, stream: true }),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return signal?.aborted ? { kind: 'cancelled' } : { kind: 'retry', detail: errorText(err) };
|
||||
}
|
||||
|
||||
const parser = createPullParser();
|
||||
// The speed is the bytes of the last few seconds: (when, how far) pairs, the oldest first.
|
||||
const samples: { at: number; completed: number }[] = [];
|
||||
let lastReport = Number.NEGATIVE_INFINITY;
|
||||
const report = (force: boolean): void => {
|
||||
if (!onProgress) return;
|
||||
const at = now();
|
||||
if (!force && at - lastReport < PULL_PROGRESS_INTERVAL_MS) return;
|
||||
const snap = parser.snapshot();
|
||||
// Before the first layer line there is no size to show.
|
||||
if (snap.totalBytes <= 0) return;
|
||||
lastReport = at;
|
||||
samples.push({ at, completed: snap.completedBytes });
|
||||
while (samples.length > 2 && at - samples[1].at >= PULL_SPEED_WINDOW_MS) samples.shift();
|
||||
const first = samples[0];
|
||||
const seconds = (at - first.at) / 1000;
|
||||
const moved = snap.completedBytes - first.completed;
|
||||
try {
|
||||
onProgress({
|
||||
completedBytes: snap.completedBytes,
|
||||
totalBytes: snap.totalBytes,
|
||||
...(seconds > 0 && moved >= 0 ? { bytesPerSecond: Math.round(moved / seconds) } : {}),
|
||||
});
|
||||
} catch {
|
||||
// A display that fails must not fail the download.
|
||||
}
|
||||
};
|
||||
|
||||
let streamError: string | undefined;
|
||||
try {
|
||||
if (res.body) {
|
||||
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
parser.push(chunk);
|
||||
report(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
streamError = errorText(err);
|
||||
}
|
||||
// An abort ends the stream with an error too: it is a cancel, not a cut.
|
||||
if (signal?.aborted) return { kind: 'cancelled' };
|
||||
parser.end();
|
||||
const snap = parser.snapshot();
|
||||
|
||||
if (snap.error !== undefined) return classifyPullError(snap.error);
|
||||
if (res.status !== 200) return classifyPullError(`The runtime answered ${res.status} to the pull`);
|
||||
if (!snap.succeeded) {
|
||||
return { kind: 'retry', detail: streamError ?? 'The download ended before the runtime reported success' };
|
||||
}
|
||||
report(true);
|
||||
if (snap.totalBytes > 0) return { kind: 'success', totalBytes: snap.totalBytes };
|
||||
// A pull of a model that was already complete may name no layer. The size is then what the
|
||||
// runtime lists for the model, which is the same figure a full pull reports as its total.
|
||||
const listed = (await listTags(target, signal)).find((model) => model.name === modelId);
|
||||
return { kind: 'success', totalBytes: listed?.size ?? 0 };
|
||||
}
|
||||
|
||||
/** Pulls `modelId` and waits for the end. Resuming is calling it again. Never throws. */
|
||||
export async function pullModel(target: OllamaTarget, modelId: string, options: PullOptions = {}): Promise<PullResult> {
|
||||
const { signal } = options;
|
||||
const retries =
|
||||
options.retries !== undefined && Number.isInteger(options.retries) && options.retries >= 0
|
||||
? options.retries
|
||||
: PULL_RETRIES;
|
||||
const sleep = options.sleep ?? ((ms: number) => waitFor(ms, signal));
|
||||
let lastDetail: string | undefined;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
if (signal?.aborted) return { ok: false, code: 'cancelled' };
|
||||
if (attempt > 0) {
|
||||
await sleep(PULL_RETRY_DELAY_MS * 2 ** (attempt - 1));
|
||||
if (signal?.aborted) return { ok: false, code: 'cancelled' };
|
||||
}
|
||||
let outcome: PullAttempt;
|
||||
try {
|
||||
outcome = await pullOnce(target, modelId, options);
|
||||
} catch (err) {
|
||||
// Only an injected `fetch` or clock can throw here. It must not end the pull as a crash.
|
||||
outcome = { kind: 'retry', detail: errorText(err) };
|
||||
}
|
||||
if (outcome.kind === 'success') return { ok: true, totalBytes: outcome.totalBytes };
|
||||
if (outcome.kind === 'cancelled') return { ok: false, code: 'cancelled' };
|
||||
if (outcome.kind === 'fatal') return { ok: false, code: outcome.code, detail: outcome.detail };
|
||||
lastDetail = outcome.detail;
|
||||
}
|
||||
return { ok: false, code: 'pull-failed', ...(lastDetail !== undefined ? { detail: lastDetail } : {}) };
|
||||
}
|
||||
|
||||
// ---------- The check after the pull ----------
|
||||
|
||||
/** The codes the check can end on. */
|
||||
export type VerifyFailureCode = Extract<
|
||||
LocalModelErrorCode,
|
||||
'load-failed' | 'does-not-fit' | 'context-not-applied' | 'no-tool-call' | 'probe-unfinished'
|
||||
>;
|
||||
|
||||
/** What the runtime reported about the loaded model. */
|
||||
export interface LoadedModel {
|
||||
context: number;
|
||||
sizeBytes: number;
|
||||
sizeVramBytes: number;
|
||||
}
|
||||
|
||||
/** The body of the tiny chat that loads the model: one short user message, `reasoning_effort: "none"`, a small `max_tokens`. Pure. */
|
||||
export function buildChatProbeRequest(modelId: string): Record<string, unknown> {
|
||||
return {
|
||||
model: modelId,
|
||||
messages: [{ role: 'user', content: 'Reply with the single word: ready' }],
|
||||
reasoning_effort: 'none',
|
||||
max_tokens: CHAT_PROBE_MAX_TOKENS,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** A function tool of the tool probe, in the shape `/v1/chat/completions` takes. */
|
||||
function probeTool(name: string, description: string, properties: Record<string, string>): Record<string, unknown> {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(properties).map(([key, about]) => [key, { type: 'string', description: about }])
|
||||
),
|
||||
required: Object.keys(properties),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The body of the tool probe: a `tools` array, `reasoning_effort: "none"`, `max_tokens: TOOL_PROBE_MAX_TOKENS`. Pure. */
|
||||
export function buildToolProbeRequest(modelId: string): Record<string, unknown> {
|
||||
return {
|
||||
model: modelId,
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a coding agent. Use the tools you are given to do what the user asks.' },
|
||||
{ role: 'user', content: 'Read the file README.md and tell me what it says.' },
|
||||
],
|
||||
// The three tools a coding agent always has. The task needs the first.
|
||||
tools: [
|
||||
probeTool('read_file', 'Read a file and return its content.', { path: 'The path of the file.' }),
|
||||
probeTool('write_file', 'Write a file.', { path: 'The path of the file.', content: 'The new content.' }),
|
||||
probeTool('run_command', 'Run a shell command and return its output.', { command: 'The command line.' }),
|
||||
],
|
||||
reasoning_effort: 'none',
|
||||
max_tokens: TOOL_PROBE_MAX_TOKENS,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Judges the process list after the chat. Pure.
|
||||
* - the model is not listed: `load-failed`;
|
||||
* - `sizeVram` is not `size`: `does-not-fit` (part of the model is not on the GPU);
|
||||
* - `contextLength` is not `context`: `context-not-applied`.
|
||||
*/
|
||||
export function judgeLoaded(
|
||||
models: readonly OllamaPsModel[],
|
||||
modelId: string,
|
||||
context: number
|
||||
): { ok: true; loaded: LoadedModel } | { ok: false; code: VerifyFailureCode; detail?: string } {
|
||||
const model = models.find((entry) => entry.name === modelId);
|
||||
if (!model) return { ok: false, code: 'load-failed', detail: 'The runtime does not list the model as loaded' };
|
||||
if (model.size <= 0)
|
||||
return { ok: false, code: 'load-failed', detail: 'The runtime reports no size for the loaded model' };
|
||||
if (model.sizeVram !== model.size) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'does-not-fit',
|
||||
// A size a person reads: decimal GB, like every other size of this feature.
|
||||
detail: `${formatGb(model.sizeVram)} of ${formatGb(model.size)} GB of the model are on the GPU`,
|
||||
};
|
||||
}
|
||||
if (model.contextLength !== context) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'context-not-applied',
|
||||
detail: `The runtime reports a context of ${model.contextLength} tokens, not ${context}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, loaded: { context: model.contextLength, sizeBytes: model.size, sizeVramBytes: model.sizeVram } };
|
||||
}
|
||||
|
||||
/** Whether the arguments of a tool call are a JSON object: as text that parses to one, or already as one. */
|
||||
function argumentsAreObject(value: unknown): boolean {
|
||||
if (isRecord(value)) return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
return isRecord(JSON.parse(value));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Judges the answer of the tool probe. Pure. `status` is the HTTP status, `body` the parsed
|
||||
* JSON (or `undefined` when it did not parse).
|
||||
* - a tool call whose `arguments` parse as a JSON object: passed (when the answer holds
|
||||
* several calls, every one must have a name and such arguments);
|
||||
* - `finish_reason` is `length`: `probe-unfinished` (the token limit ended it, so it proves nothing);
|
||||
* - any other answer of status 200: `no-tool-call`;
|
||||
* - another status: `load-failed`.
|
||||
*/
|
||||
export function judgeToolProbe(
|
||||
status: number,
|
||||
body: unknown
|
||||
): { ok: true } | { ok: false; code: VerifyFailureCode; detail?: string } {
|
||||
if (status !== 200) {
|
||||
const said = errorOfBody(body);
|
||||
return { ok: false, code: 'load-failed', detail: clip(said ?? `The runtime answered ${status}`) };
|
||||
}
|
||||
const choice = isRecord(body) && Array.isArray(body.choices) ? body.choices[0] : undefined;
|
||||
const message = isRecord(choice) && isRecord(choice.message) ? choice.message : undefined;
|
||||
const calls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
if (calls.length > 0) {
|
||||
const wellFormed = calls.every(
|
||||
(call) =>
|
||||
isRecord(call) &&
|
||||
isRecord(call.function) &&
|
||||
asString(call.function.name) !== undefined &&
|
||||
argumentsAreObject(call.function.arguments)
|
||||
);
|
||||
if (wellFormed) return { ok: true };
|
||||
return { ok: false, code: 'no-tool-call', detail: 'The model called a tool with arguments that are not JSON' };
|
||||
}
|
||||
if (isRecord(choice) && choice.finish_reason === 'length') {
|
||||
return { ok: false, code: 'probe-unfinished', detail: `The answer ended at ${TOOL_PROBE_MAX_TOKENS} tokens` };
|
||||
}
|
||||
const said = message ? asString(message.content) : undefined;
|
||||
return {
|
||||
ok: false,
|
||||
code: 'no-tool-call',
|
||||
detail: said ? clip(`The model answered in text: ${said}`) : 'The answer holds no tool call',
|
||||
};
|
||||
}
|
||||
|
||||
/** How the check ended. */
|
||||
export type VerifyResult =
|
||||
| { ok: true; loaded: LoadedModel }
|
||||
| { ok: false; code: VerifyFailureCode; detail?: string }
|
||||
| { ok: false; code: 'cancelled' };
|
||||
|
||||
/**
|
||||
* The whole check, in order: the chat, the process list, the tool probe. Stops at the first
|
||||
* failure. Never throws.
|
||||
*/
|
||||
export async function verifyModel(
|
||||
target: OllamaTarget,
|
||||
modelId: string,
|
||||
context: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<VerifyResult> {
|
||||
const cancelled: VerifyResult = { ok: false, code: 'cancelled' };
|
||||
if (signal?.aborted) return cancelled;
|
||||
|
||||
// 1. The chat that loads the model. Its text is not read: only that it was answered.
|
||||
const chat = await ask(
|
||||
target,
|
||||
'/v1/chat/completions',
|
||||
{ method: 'POST', json: buildChatProbeRequest(modelId) },
|
||||
CHAT_PROBE_TIMEOUT_MS,
|
||||
signal
|
||||
);
|
||||
if (signal?.aborted) return cancelled;
|
||||
if (!chat) return { ok: false, code: 'load-failed', detail: 'The runtime did not answer the first request' };
|
||||
if (chat.status !== 200) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'load-failed',
|
||||
detail: clip(errorOfBody(chat.body) ?? `The runtime answered ${chat.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. What the runtime says it loaded.
|
||||
const judged = judgeLoaded(await listRunning(target, signal), modelId, context);
|
||||
if (signal?.aborted) return cancelled;
|
||||
if (!judged.ok) return judged;
|
||||
|
||||
// 3. One tool call.
|
||||
const probe = await ask(
|
||||
target,
|
||||
'/v1/chat/completions',
|
||||
{ method: 'POST', json: buildToolProbeRequest(modelId) },
|
||||
TOOL_PROBE_TIMEOUT_MS,
|
||||
signal
|
||||
);
|
||||
if (signal?.aborted) return cancelled;
|
||||
if (!probe)
|
||||
return { ok: false, code: 'probe-unfinished', detail: 'The runtime did not answer the tool request in time' };
|
||||
const tool = judgeToolProbe(probe.status, probe.body);
|
||||
if (!tool.ok) return tool;
|
||||
return { ok: true, loaded: judged.loaded };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @fileoverview Where everything of the local model lives on disk. One pure function, so the
|
||||
* runtime, the install job and uninstall all name the same folders.
|
||||
*
|
||||
* Layout, all under the data dir (`dataPath()`, so a second Codeman instance has its own):
|
||||
*
|
||||
* ```
|
||||
* local-model.json the state file (mode 0600)
|
||||
* local-model/
|
||||
* runtime/ollama-<version>.tgz.partial the runtime archive while it downloads
|
||||
* runtime/ollama-<version>.unpack/ where the archive is unpacked, before the rename
|
||||
* runtime/ollama-<version>/ the unpacked runtime; its binary is `ollama`
|
||||
* models/ the runtime's model store
|
||||
* home/ the runtime's private HOME (its key pair lands here)
|
||||
* ```
|
||||
*
|
||||
* Rules:
|
||||
* - `root` is the ONE folder this feature owns. Nothing is written or deleted outside it,
|
||||
* except the state file beside it and the two writes of the connect step (the endpoint
|
||||
* record and the settings flag), which have their own writers.
|
||||
* - Every path comes from here. None is ever read back from the state file, so a state file
|
||||
* cannot point a delete anywhere else. `isInsideLocalModelRoot()` is the check a delete
|
||||
* makes before it runs.
|
||||
* - Pure apart from `dataPath()`, which resolves (and creates) the data dir.
|
||||
*/
|
||||
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
|
||||
/** A runtime version as it may appear in a folder name: digits and dots only ('0.40.15'). */
|
||||
const VERSION_PATTERN = /^\d+(?:\.\d+){1,3}$/;
|
||||
|
||||
export interface LocalModelPaths {
|
||||
/** `<data dir>/local-model`: the one folder this feature owns. */
|
||||
root: string;
|
||||
/** `<root>/runtime`: the archive and every unpacked version. */
|
||||
runtimeRoot: string;
|
||||
/** `<root>/runtime/ollama-<version>`: the unpacked runtime. */
|
||||
runtimeDir: string;
|
||||
/** The runtime's binary: `<runtimeDir>/ollama`. */
|
||||
binary: string;
|
||||
/** `<root>/runtime/ollama-<version>.tgz.partial`: the archive while it downloads. */
|
||||
archivePartial: string;
|
||||
/** `<root>/runtime/ollama-<version>.unpack`: the temporary folder the archive is unpacked into. */
|
||||
unpackDir: string;
|
||||
/** `<root>/models`: the runtime's model store. */
|
||||
modelsDir: string;
|
||||
/** `<root>/home`: the runtime's private HOME. */
|
||||
homeDir: string;
|
||||
/** `<data dir>/local-model.json`: the state file. */
|
||||
stateFile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths of the local model for one runtime version. Throws on a version that is not
|
||||
* digits and dots, so a version string can never add a path segment.
|
||||
*/
|
||||
export function localModelPaths(version: string): LocalModelPaths {
|
||||
if (!VERSION_PATTERN.test(version)) {
|
||||
throw new Error(`Not a runtime version: ${JSON.stringify(version)}`);
|
||||
}
|
||||
const root = dataPath('local-model');
|
||||
const runtimeRoot = join(root, 'runtime');
|
||||
const runtimeDir = join(runtimeRoot, `ollama-${version}`);
|
||||
return {
|
||||
root,
|
||||
runtimeRoot,
|
||||
runtimeDir,
|
||||
binary: join(runtimeDir, 'ollama'),
|
||||
archivePartial: join(runtimeRoot, `ollama-${version}.tgz.partial`),
|
||||
unpackDir: join(runtimeRoot, `ollama-${version}.unpack`),
|
||||
modelsDir: join(root, 'models'),
|
||||
homeDir: join(root, 'home'),
|
||||
stateFile: dataPath('local-model.json'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The binary of another unpacked runtime version beside the pinned one: `<runtimeRoot>/
|
||||
* ollama-<version>/ollama`. For an installed model whose runtime was unpacked by an earlier
|
||||
* build with another pin: it keeps running the version it was installed with. Throws on a
|
||||
* version that is not digits and dots, like `localModelPaths()`. Pure.
|
||||
*/
|
||||
export function runtimeBinaryForVersion(paths: LocalModelPaths, version: string): string {
|
||||
if (!VERSION_PATTERN.test(version)) {
|
||||
throw new Error(`Not a runtime version: ${JSON.stringify(version)}`);
|
||||
}
|
||||
return join(paths.runtimeRoot, `ollama-${version}`, 'ollama');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `path` is the local model folder itself or lies inside it, judged on the resolved
|
||||
* text (no filesystem access, so a symlink is not followed: a caller that deletes checks the
|
||||
* real path first). `..` segments are resolved before the comparison.
|
||||
*/
|
||||
export function isInsideLocalModelRoot(path: string, root: string = dataPath('local-model')): boolean {
|
||||
const base = resolve(root);
|
||||
const target = resolve(path);
|
||||
return target === base || target.startsWith(base + sep);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* @fileoverview The recommendation itself: which model, at which context, with which harness
|
||||
* fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir.
|
||||
*
|
||||
* The math, in the order it runs:
|
||||
* - `deviceBudget()`. ESTIMATED (all there is before the runtime ran): L is the wired limit
|
||||
* when it is above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB, and
|
||||
* B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to 18 GB, 8 GiB up to
|
||||
* 48 GB and 12 GiB above. MEASURED (`RecommendOptions.measuredTotalBytes`, the device total
|
||||
* the runtime printed when it started): L is that total and B = min(L, memory - reserve),
|
||||
* without the 1 GiB, because the figure is already what the runtime will really use.
|
||||
* - `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; 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
|
||||
* Mac whose best model is tight should be offered that model, not a smaller one.
|
||||
* - Speed: a candidate that matches a `tested` entry with a measured speed reports that
|
||||
* speed. Every other one is estimated: the model's `speedPer100Gbps` x the chip's bandwidth
|
||||
* / 100. Below 10 is slow, decided on the unrounded figure (9.96 is slow although it prints
|
||||
* as 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a
|
||||
* bandwidth is never guessed.
|
||||
* - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not
|
||||
* gated, installed or installable, and accepting the model id.
|
||||
* - Ranking, lexicographic: tested on a Mac like this before not tested (a measurement beats
|
||||
* a formula); ok before unknown before slow; the higher model rank; the higher harness
|
||||
* rank; the larger context. The pick is the first candidate, the alternatives are the next
|
||||
* four MODELS, each at its own best context and harness, so a model is always offered at
|
||||
* the largest context it fits at.
|
||||
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
|
||||
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
|
||||
* with what is missing.
|
||||
*
|
||||
* Verdict `none`, in this order (the first that applies is the reason):
|
||||
* 1. `not-macos`: the server does not run on macOS.
|
||||
* 2. `intel`: a Mac without Apple silicon.
|
||||
* 3. `macos`: macOS is older than the catalogue's minimum (an unknown version does not block).
|
||||
* 4. `memory`: no model fits the budget at any of its contexts, whatever the harness
|
||||
* (`noneMemory`, or `noneMemoryUnknown` when the memory size could not be read).
|
||||
* 5. `no-harness`: no harness row is usable (not gated, known to the caller, installed or
|
||||
* installable).
|
||||
* 6. `memory`: models fit, but none at a context a usable harness needs. Its own sentence
|
||||
* (`noneMemoryContext`) with the budget and the smallest need at such a context, because
|
||||
* "too little memory" would hide that a model does fit at a smaller context and that the
|
||||
* answer rests on estimates.
|
||||
* 7. `no-harness`: models fit at such a context, but no usable harness accepts their id.
|
||||
* 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an
|
||||
* unknown free disk does not block).
|
||||
*
|
||||
* Rules:
|
||||
* - Verdict `limited` when NO model with 12 billion parameters or more fits this Mac's memory
|
||||
* at a context a usable harness works at. It is a statement about the Mac ("This Mac can run
|
||||
* a small model"), so it is judged on everything that fits, BEFORE the disk filter and
|
||||
* whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B is listed under it
|
||||
* is not a small-model Mac, and a 48 GB Mac does not become one because its disk is full. A
|
||||
* small pick under verdict `ok` carries `noteSmallPick` instead.
|
||||
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a
|
||||
* pick whose model has a `tested` entry for exactly this chip, this memory size and this
|
||||
* harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`.
|
||||
* - Every row carries what the chooser needs and nothing the browser must work out: its one
|
||||
* tag (`tagFor()`), every harness it can run with (`Candidate.harnesses`, the flat harness
|
||||
* fields first), and the whole download of an install (`installBytes`: the model plus
|
||||
* `RecommendOptions.runtimeDownloadBytes`).
|
||||
* - Every label in the result comes from `LOCAL_MODEL_LABELS` or `LOCAL_MODEL_TAG_LABELS`,
|
||||
* every computed size text from `formatGb()`. The one size that is not decimal GB is the
|
||||
* installed memory in `noneMemory`: `HardwareFacts.memClassGb`, the size the Mac is sold
|
||||
* as. No number is spliced into a sentence here.
|
||||
* - No CLI id is ever compared with a literal: a harness is a catalogue row plus a
|
||||
* `HarnessFact`.
|
||||
*/
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_LABELS,
|
||||
LOCAL_MODEL_NONE_LABELS,
|
||||
LOCAL_MODEL_TAG_LABELS,
|
||||
type Candidate,
|
||||
type CandidateHarness,
|
||||
type CandidateTag,
|
||||
type CandidateTagKind,
|
||||
type DeviceBudget,
|
||||
type FitGrade,
|
||||
type HardwareFacts,
|
||||
type HarnessFact,
|
||||
type LocalHarnessEntry,
|
||||
type LocalModelCatalog,
|
||||
type LocalModelEntry,
|
||||
type LocalModelLabel,
|
||||
type LocalModelMessage,
|
||||
type NoneReason,
|
||||
type Recommendation,
|
||||
type RecommendOptions,
|
||||
type SpeedGrade,
|
||||
type TestedOn,
|
||||
} from '../types.js';
|
||||
import { chipBandwidthGbps } from '../config/local-model-catalog.js';
|
||||
import { formatGb, GIB, MIB } from './format.js';
|
||||
|
||||
/** Need over budget up to this share is a `good` fit. */
|
||||
export const FIT_GOOD_MAX_SHARE = 0.85;
|
||||
/** Need over budget up to this share is a `tight` fit; above it the model is out. */
|
||||
export const FIT_TIGHT_MAX_SHARE = 0.98;
|
||||
/** A speed below this many tokens per second is `slow`. */
|
||||
export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
|
||||
/** When no model that fits has at least this many billion parameters, the verdict is `limited`. A pick below it is a "small model". */
|
||||
export const LIMITED_BELOW_PARAMS_B = 12;
|
||||
/** What must stay free on the disk after the model download. */
|
||||
export const DISK_HEADROOM_BYTES = 10 * GIB;
|
||||
/** How many other models are listed under the pick. */
|
||||
export const MAX_ALTERNATIVES = 4;
|
||||
|
||||
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
|
||||
const LARGE_LIMIT_FROM_GB = 36;
|
||||
|
||||
/** The memory the system, Codeman, a browser and the agent keep for themselves, by the size the Mac is sold as. */
|
||||
function reserveBytes(memClassGb: number): number {
|
||||
if (memClassGb <= 18) return 6 * GIB;
|
||||
if (memClassGb <= 48) return 8 * GIB;
|
||||
return 12 * GIB;
|
||||
}
|
||||
|
||||
/** A byte count a caller supplied, when it is one: finite and above 0. */
|
||||
function positiveBytes(value: number | undefined): number | undefined {
|
||||
return value !== undefined && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory a model may use on this Mac: the device limit L, the budget B and where L came
|
||||
* from. With `options.measuredTotalBytes` the limit is what the runtime itself reported and
|
||||
* the 1 GiB the estimate keeps back is not subtracted; the reserve bounds both.
|
||||
*/
|
||||
export function deviceBudget(hw: HardwareFacts, options: RecommendOptions = {}): DeviceBudget {
|
||||
const memBytes = Number.isFinite(hw.memBytes) && hw.memBytes > 0 ? hw.memBytes : 0;
|
||||
const afterReserve = memBytes - reserveBytes(hw.memClassGb);
|
||||
const measured = positiveBytes(options.measuredTotalBytes);
|
||||
if (measured !== undefined) {
|
||||
return { limitBytes: measured, budgetBytes: Math.max(0, Math.min(measured, afterReserve)), source: 'measured' };
|
||||
}
|
||||
// 0 means "not set" (both test Macs report 0): only a value above 0 is a limit.
|
||||
const wired = hw.wiredLimitMb !== undefined && Number.isFinite(hw.wiredLimitMb) && hw.wiredLimitMb > 0;
|
||||
const limitBytes = wired
|
||||
? Math.floor((hw.wiredLimitMb as number) * MIB)
|
||||
: Math.floor(memBytes * (hw.memClassGb < LARGE_LIMIT_FROM_GB ? 2 / 3 : 3 / 4));
|
||||
const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, afterReserve));
|
||||
return { limitBytes, budgetBytes, source: wired ? 'wired-limit' : 'estimated' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes `model` needs in memory at a context of `ctx` tokens, at its PEAK: the weights,
|
||||
* what stays held for the context, what the runner holds beside them, and what it takes for a
|
||||
* moment while a long prompt is processed. It does not depend on the Mac.
|
||||
*/
|
||||
export function needBytes(model: LocalModelEntry, ctx: number): number {
|
||||
return model.bytes + model.heldBytesPerToken * ctx + model.overheadBytes + model.peakExtraBytes;
|
||||
}
|
||||
|
||||
function fitGrade(need: number, budgetBytes: number): FitGrade {
|
||||
if (budgetBytes <= 0) return 'out';
|
||||
const share = need / budgetBytes;
|
||||
if (share <= FIT_GOOD_MAX_SHARE) return 'good';
|
||||
if (share <= FIT_TIGHT_MAX_SHARE) return 'tight';
|
||||
return 'out';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the catalogue model `modelId` at `context` tokens fits this Mac's budget (a `good`
|
||||
* or a `tight` fit). The install job asks this right after the runtime started, with the
|
||||
* budget the runtime measured (`options.measuredTotalBytes`), BEFORE it downloads the model: a
|
||||
* `false` ends the job with `does-not-fit`. It is the same judgement `recommend()` makes for a
|
||||
* candidate, without the ranking, so a model that fits is never ruled out for being listed
|
||||
* sixth. False for a model the catalogue does not have.
|
||||
*/
|
||||
export function modelFitsBudget(
|
||||
hw: HardwareFacts,
|
||||
catalog: LocalModelCatalog,
|
||||
modelId: string,
|
||||
context: number,
|
||||
options: RecommendOptions = {}
|
||||
): boolean {
|
||||
const model = catalog.models.find((entry) => entry.id === modelId);
|
||||
if (!model || !Number.isFinite(context) || context <= 0) return false;
|
||||
return fitGrade(needBytes(model, context), deviceBudget(hw, options).budgetBytes) !== 'out';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the MACHINE is Apple silicon. `hw.optional.arm64` decides when it was read. Without
|
||||
* it: a process under Rosetta, an arm64 process or an Apple chip name each prove Apple silicon
|
||||
* (`arch` alone cannot say no, because Node under Rosetta reports x64).
|
||||
*/
|
||||
function isAppleSilicon(hw: HardwareFacts): boolean {
|
||||
if (hw.arm64 !== undefined) return hw.arm64;
|
||||
if (hw.translated === true || hw.arch === 'arm64') return true;
|
||||
return /^Apple\b/i.test(hw.chip ?? '');
|
||||
}
|
||||
|
||||
function round(value: number, decimals: number): number {
|
||||
const factor = 10 ** decimals;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/** A candidate plus its catalogue row and the two ranks the ordering needs, none of which is part of the payload. */
|
||||
interface Ranked {
|
||||
candidate: Candidate;
|
||||
model: LocalModelEntry;
|
||||
modelRank: number;
|
||||
harnessRank: number;
|
||||
}
|
||||
|
||||
/** A catalogue harness row with the caller's fact about it. */
|
||||
interface UsableHarness {
|
||||
row: LocalHarnessEntry;
|
||||
fact: HarnessFact;
|
||||
}
|
||||
|
||||
const SPEED_ORDER: Record<SpeedGrade, number> = { ok: 0, unknown: 1, slow: 2 };
|
||||
|
||||
/** Best first. The fit grade is not a key: every candidate here fits, see the file header. */
|
||||
function compareRanked(a: Ranked, b: Ranked): number {
|
||||
return (
|
||||
Number(b.candidate.tested) - Number(a.candidate.tested) ||
|
||||
SPEED_ORDER[a.candidate.speed] - SPEED_ORDER[b.candidate.speed] ||
|
||||
b.modelRank - a.modelRank ||
|
||||
b.harnessRank - a.harnessRank ||
|
||||
b.candidate.context - a.candidate.context
|
||||
);
|
||||
}
|
||||
|
||||
/** A function supplied by the caller must not be able to break a pure answer. */
|
||||
function accepts(fact: HarnessFact, modelId: string): boolean {
|
||||
try {
|
||||
return fact.acceptsModelId(modelId) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the chooser's "Runs with" select needs to know about one harness. */
|
||||
function candidateHarness(harness: UsableHarness): CandidateHarness {
|
||||
const { row, fact } = harness;
|
||||
return {
|
||||
cli: row.cli,
|
||||
cliLabel: fact.label,
|
||||
installed: fact.installed,
|
||||
// Only a harness that still has to be installed has a command to show.
|
||||
...(!fact.installed && fact.installCommand ? { installCommand: fact.installCommand } : {}),
|
||||
...(row.slowFirstTurn ? { slowFirstTurn: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry that makes this model "tested on a Mac like this": exactly the same chip string,
|
||||
* the same memory size as sold, the same harness.
|
||||
*/
|
||||
function testedEntry(model: LocalModelEntry, hw: HardwareFacts, cli: string): TestedOn | undefined {
|
||||
return model.tested.find((entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === cli);
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
model: LocalModelEntry,
|
||||
ctx: number,
|
||||
harness: UsableHarness,
|
||||
/** Every usable harness that can run this model at this context, `harness` among them. */
|
||||
runsWith: readonly UsableHarness[],
|
||||
need: number,
|
||||
fit: Candidate['fit'],
|
||||
hw: HardwareFacts,
|
||||
catalog: LocalModelCatalog,
|
||||
budgetBytes: number,
|
||||
runtimeDownloadBytes: number
|
||||
): Candidate {
|
||||
const testedOn = testedEntry(model, hw, harness.row.cli);
|
||||
const gbps = chipBandwidthGbps(catalog.chips, hw.chip);
|
||||
// A speed measured on a Mac like this one beats the estimate from the chip's bandwidth.
|
||||
const rawSpeed =
|
||||
testedOn?.tokensPerSecond ??
|
||||
(gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined);
|
||||
// Graded on the unrounded figure: 9.96 rounds to 10.0 for the payload and is still slow.
|
||||
const speed: SpeedGrade =
|
||||
rawSpeed === undefined ? 'unknown' : rawSpeed < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok';
|
||||
const tokensPerSecond = rawSpeed === undefined ? undefined : round(rawSpeed, 1);
|
||||
// The harness of the flat fields first, then the others, best first.
|
||||
const others = runsWith.filter((other) => other !== harness).sort((a, b) => b.row.rank - a.row.rank);
|
||||
const installBytes = model.bytes + runtimeDownloadBytes;
|
||||
return {
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
license: model.license,
|
||||
paramsB: model.paramsB,
|
||||
context: ctx,
|
||||
cli: harness.row.cli,
|
||||
cliLabel: harness.fact.label,
|
||||
cliInstalled: harness.fact.installed,
|
||||
...(harness.row.slowFirstTurn ? { slowFirstTurn: true } : {}),
|
||||
needBytes: need,
|
||||
needGb: formatGb(need),
|
||||
memory: {
|
||||
modelBytes: model.bytes + model.overheadBytes,
|
||||
contextBytes: model.heldBytesPerToken * ctx,
|
||||
peakBytes: model.peakExtraBytes,
|
||||
},
|
||||
downloadBytes: model.bytes,
|
||||
downloadGb: formatGb(model.bytes),
|
||||
budgetShare: round(need / budgetBytes, 3),
|
||||
fit,
|
||||
fitLabel: fit === 'good' ? LOCAL_MODEL_LABELS.fitGood : LOCAL_MODEL_LABELS.fitTight,
|
||||
speed,
|
||||
...(speed === 'slow' ? { speedLabel: LOCAL_MODEL_LABELS.speedSlow } : {}),
|
||||
...(speed === 'unknown' ? { speedLabel: LOCAL_MODEL_LABELS.speedUnknown } : {}),
|
||||
...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}),
|
||||
tested: testedOn !== undefined,
|
||||
needSource: model.figures,
|
||||
harnesses: [harness, ...others].map(candidateHarness),
|
||||
installBytes,
|
||||
installGb: formatGb(installBytes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The one tag of a row: the first kind that applies, in the order tested, the pick (which
|
||||
* says so when it only just fits), tight, slow, small. `undefined` when none does.
|
||||
*/
|
||||
function tagFor(candidate: Candidate, isPick: boolean): CandidateTag | undefined {
|
||||
const kind: CandidateTagKind | undefined = candidate.tested
|
||||
? 'tested'
|
||||
: isPick
|
||||
? candidate.fit === 'tight'
|
||||
? 'best-tight'
|
||||
: 'best'
|
||||
: candidate.fit === 'tight'
|
||||
? 'tight'
|
||||
: candidate.speed === 'slow'
|
||||
? 'slow'
|
||||
: candidate.paramsB < LIMITED_BELOW_PARAMS_B
|
||||
? 'small'
|
||||
: undefined;
|
||||
return kind === undefined ? undefined : { kind, label: LOCAL_MODEL_TAG_LABELS[kind] };
|
||||
}
|
||||
|
||||
/** `candidate` with its tag, when it has one. */
|
||||
function withTag(candidate: Candidate, isPick: boolean): Candidate {
|
||||
const tag = tagFor(candidate, isPick);
|
||||
return tag ? { ...candidate, tag } : candidate;
|
||||
}
|
||||
|
||||
/** A `none` answer. `text` replaces the reason's default sentence (the `memory` reason has three). */
|
||||
function none(
|
||||
reason: NoneReason,
|
||||
budget: DeviceBudget,
|
||||
vars?: Record<string, string>,
|
||||
text: LocalModelLabel = LOCAL_MODEL_NONE_LABELS[reason]
|
||||
): Recommendation {
|
||||
return {
|
||||
verdict: 'none',
|
||||
reason,
|
||||
headline: { text, ...(vars ? { vars } : {}) },
|
||||
notes: [],
|
||||
alternatives: [],
|
||||
budgetBytes: budget.budgetBytes,
|
||||
budgetGb: formatGb(budget.budgetBytes),
|
||||
budgetSource: budget.source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the
|
||||
* caller could resolve; a catalogue harness without a fact is not offered.
|
||||
*
|
||||
* `options` is what the caller knows beyond the probe: the budget the runtime measured (the
|
||||
* answer then says `budgetSource: 'measured'`), and the runtime download an install still
|
||||
* needs (it is added to every row's `installBytes`).
|
||||
*/
|
||||
export function recommend(
|
||||
hw: HardwareFacts,
|
||||
catalog: LocalModelCatalog,
|
||||
harnessFacts: readonly HarnessFact[],
|
||||
options: RecommendOptions = {}
|
||||
): Recommendation {
|
||||
// Off macOS and on an Intel Mac there is no budget to compute: a model has no memory to run in.
|
||||
const noBudget: DeviceBudget = { limitBytes: 0, budgetBytes: 0, source: 'estimated' };
|
||||
if (hw.platform !== 'darwin') return none('not-macos', noBudget);
|
||||
if (!isAppleSilicon(hw)) return none('intel', noBudget);
|
||||
|
||||
const budget = deviceBudget(hw, options);
|
||||
const runtimeDownloadBytes = positiveBytes(options.runtimeDownloadBytes) ?? 0;
|
||||
if (hw.macosMajor !== undefined && hw.macosMajor < catalog.minMacosMajor) {
|
||||
return none('macos', budget, {
|
||||
version: hw.macosVersion ?? String(hw.macosMajor),
|
||||
minimum: String(catalog.minMacosMajor),
|
||||
});
|
||||
}
|
||||
|
||||
// Every (model, context) that fits the budget, before any harness is looked at.
|
||||
const fitting: { model: LocalModelEntry; ctx: number; need: number; fit: Candidate['fit'] }[] = [];
|
||||
for (const model of catalog.models) {
|
||||
for (const ctx of model.contexts) {
|
||||
const need = needBytes(model, ctx);
|
||||
const fit = fitGrade(need, budget.budgetBytes);
|
||||
if (fit !== 'out') fitting.push({ model, ctx, need, fit });
|
||||
}
|
||||
}
|
||||
// Nothing fits at any context. The installed memory is named by the size the Mac is sold
|
||||
// as; a size that could not be read is not stated at all (never "0 GB of memory").
|
||||
const tooLittleMemory = (): Recommendation =>
|
||||
Number.isFinite(hw.memClassGb) && hw.memClassGb > 0
|
||||
? none('memory', budget, { memory: String(hw.memClassGb) })
|
||||
: none('memory', budget, undefined, LOCAL_MODEL_LABELS.noneMemoryUnknown);
|
||||
if (fitting.length === 0) return tooLittleMemory();
|
||||
|
||||
// A gated row is never offered, whatever the caller knows about its CLI.
|
||||
const usable: UsableHarness[] = [];
|
||||
for (const row of catalog.harnesses) {
|
||||
if (row.gated) continue;
|
||||
const fact = harnessFacts.find((candidate) => candidate.cli === row.cli);
|
||||
if (fact && (fact.installed || fact.installable)) usable.push({ row, fact });
|
||||
}
|
||||
if (usable.length === 0) return none('no-harness', budget);
|
||||
|
||||
const atHarnessContext = fitting.flatMap((entry) =>
|
||||
usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness }))
|
||||
);
|
||||
if (atHarnessContext.length === 0) {
|
||||
// Models fit, but only below the context a harness needs. Say both numbers: the reader
|
||||
// sees how far apart they are, and that both are estimates.
|
||||
const smallestContext = Math.min(...usable.map((harness) => harness.row.minContext));
|
||||
let smallestNeed: number | undefined;
|
||||
for (const model of catalog.models) {
|
||||
for (const ctx of model.contexts) {
|
||||
if (ctx < smallestContext) continue;
|
||||
const need = needBytes(model, ctx);
|
||||
if (smallestNeed === undefined || need < smallestNeed) smallestNeed = need;
|
||||
}
|
||||
}
|
||||
if (smallestNeed === undefined) return tooLittleMemory();
|
||||
return none(
|
||||
'memory',
|
||||
budget,
|
||||
{ budget: formatGb(budget.budgetBytes), need: formatGb(smallestNeed) },
|
||||
LOCAL_MODEL_LABELS.noneMemoryContext
|
||||
);
|
||||
}
|
||||
|
||||
const accepted = atHarnessContext.filter((entry) => accepts(entry.harness.fact, entry.model.id));
|
||||
if (accepted.length === 0) return none('no-harness', budget);
|
||||
|
||||
// The download must leave headroom on the disk. An unknown free disk does not block.
|
||||
const free = hw.freeDiskBytes;
|
||||
const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES;
|
||||
if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) {
|
||||
const smallest = Math.min(...accepted.map((entry) => entry.model.bytes));
|
||||
// One number, the one that says what to do: what must be freed for the smallest model.
|
||||
return none('disk', budget, { missing: formatGb(smallest + DISK_HEADROOM_BYTES - free) });
|
||||
}
|
||||
|
||||
// Everything that fits is ranked, the models without room on the disk included: the best of
|
||||
// those is named in a note, so a full disk never removes a model without a word.
|
||||
const ranked: Ranked[] = accepted
|
||||
.map((entry) => ({
|
||||
candidate: buildCandidate(
|
||||
entry.model,
|
||||
entry.ctx,
|
||||
entry.harness,
|
||||
accepted
|
||||
.filter((other) => other.model === entry.model && other.ctx === entry.ctx)
|
||||
.map((other) => other.harness),
|
||||
entry.need,
|
||||
entry.fit,
|
||||
hw,
|
||||
catalog,
|
||||
budget.budgetBytes,
|
||||
runtimeDownloadBytes
|
||||
),
|
||||
model: entry.model,
|
||||
modelRank: entry.model.rank,
|
||||
harnessRank: entry.harness.row.rank,
|
||||
}))
|
||||
.sort(compareRanked);
|
||||
|
||||
// The first candidate of each model is that model at its best context and harness.
|
||||
const bestPerModel: Ranked[] = [];
|
||||
for (const entry of ranked) {
|
||||
if (!bestPerModel.some((kept) => kept.model.id === entry.model.id)) bestPerModel.push(entry);
|
||||
}
|
||||
const offered = bestPerModel.filter((entry) => hasRoom(entry.model)).map((entry) => entry.candidate);
|
||||
const leftOutForDisk = bestPerModel.find((entry) => !hasRoom(entry.model));
|
||||
const pick = withTag(offered[0], true);
|
||||
const alternatives = offered.slice(1, 1 + MAX_ALTERNATIVES).map((candidate) => withTag(candidate, false));
|
||||
|
||||
// About the Mac, not the pick, and judged before the disk filter (see the file header).
|
||||
const limited = !accepted.some((entry) => entry.model.paramsB >= LIMITED_BELOW_PARAMS_B);
|
||||
const headline = limited
|
||||
? LOCAL_MODEL_LABELS.headlineLimited
|
||||
: pick.tested
|
||||
? LOCAL_MODEL_LABELS.headlineTested
|
||||
: LOCAL_MODEL_LABELS.headlineOk;
|
||||
|
||||
const notes: LocalModelMessage[] = [];
|
||||
// The limited headline already says it. Under `ok` a small pick must still be called small.
|
||||
if (!limited && pick.paramsB < LIMITED_BELOW_PARAMS_B) notes.push({ text: LOCAL_MODEL_LABELS.noteSmallPick });
|
||||
if (leftOutForDisk && free !== undefined) {
|
||||
notes.push({
|
||||
text: LOCAL_MODEL_LABELS.noteDiskDropped,
|
||||
vars: {
|
||||
model: leftOutForDisk.model.label,
|
||||
missing: formatGb(leftOutForDisk.model.bytes + DISK_HEADROOM_BYTES - free),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!pick.tested) notes.push({ text: LOCAL_MODEL_LABELS.noteUntested });
|
||||
|
||||
return {
|
||||
verdict: limited ? 'limited' : 'ok',
|
||||
headline: { text: headline },
|
||||
notes,
|
||||
pick,
|
||||
alternatives,
|
||||
budgetBytes: budget.budgetBytes,
|
||||
budgetGb: formatGb(budget.budgetBytes),
|
||||
budgetSource: budget.source,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @fileoverview The ONE reader and writer of `local-model.json`, the state file of the local
|
||||
* model install (`LocalModelStateFile`).
|
||||
*
|
||||
* Why one module: the install job writes the state, the step and the digest, and the caller
|
||||
* of the runtime supervisor writes the pid and the port. Two writers with their own temp file
|
||||
* and rename would lose each other's change, which is the fault `mutateCustomModelHosts()`
|
||||
* exists to end for the endpoint store. So every write goes through `mutateLocalModelState()`.
|
||||
*
|
||||
* The shape of `mutateSettingsFile()` (src/web/settings-writer.ts):
|
||||
* - Serialized: mutations run one at a time on one promise chain.
|
||||
* - Atomic: each write goes to a temp file with a random name beside the target and is
|
||||
* renamed over it. The file is mode 0600.
|
||||
* - Reading is TOTAL, unlike the settings writer: the file holds no setting of the user, only
|
||||
* what this feature recorded, so a missing file, one that does not parse or one with
|
||||
* another `schema` reads as `IDLE_LOCAL_MODEL_STATE` and the next write replaces it.
|
||||
* - The path is `localModelPaths().stateFile`. The file itself holds no path.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import {
|
||||
LOCAL_MODEL_ERROR_LABELS,
|
||||
LOCAL_MODEL_INSTALL_STEPS,
|
||||
type LocalModelErrorCode,
|
||||
type LocalModelInstallState,
|
||||
type LocalModelInstallStep,
|
||||
type LocalModelStateFile,
|
||||
} from '../types.js';
|
||||
import { OLLAMA_RUNTIME_PIN } from './runtime.js';
|
||||
import { localModelPaths } from './paths.js';
|
||||
|
||||
/** The state when nothing was ever installed. What an unreadable file reads as. */
|
||||
export const IDLE_LOCAL_MODEL_STATE: LocalModelStateFile = Object.freeze({ schema: 1, state: 'idle' });
|
||||
|
||||
const STATES: readonly LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed'];
|
||||
|
||||
// ---------- Reading one member at a time ----------
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const text = (value: unknown, max = 4_096): string | undefined =>
|
||||
typeof value === 'string' && value !== '' && value.length <= max ? value : undefined;
|
||||
|
||||
/** A whole number of bytes, tokens, a port or a pid: finite, not negative, and an integer. */
|
||||
const count = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||
|
||||
const step = (value: unknown): LocalModelInstallStep | undefined =>
|
||||
LOCAL_MODEL_INSTALL_STEPS.find((candidate) => candidate === value);
|
||||
|
||||
function parseSteps(value: unknown): LocalModelInstallStep[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const steps: LocalModelInstallStep[] = [];
|
||||
for (const item of value) {
|
||||
const parsed = step(item);
|
||||
// A list with an unknown or a repeated step is not a list this build wrote.
|
||||
if (!parsed || steps.includes(parsed)) return undefined;
|
||||
steps.push(parsed);
|
||||
}
|
||||
return steps.length > 0 ? steps : undefined;
|
||||
}
|
||||
|
||||
function parseVars(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (typeof item === 'string' && item.length <= 4_096) vars[key] = item;
|
||||
}
|
||||
return Object.keys(vars).length > 0 ? vars : undefined;
|
||||
}
|
||||
|
||||
function parseError(value: unknown): LocalModelStateFile['error'] {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const code = text(value.code);
|
||||
if (!code || !Object.prototype.hasOwnProperty.call(LOCAL_MODEL_ERROR_LABELS, code)) return undefined;
|
||||
const vars = parseVars(value.vars);
|
||||
const detail = text(value.detail, 16_384);
|
||||
const nextModelId = text(value.nextModelId, 200);
|
||||
return {
|
||||
code: code as LocalModelErrorCode,
|
||||
...(vars ? { vars } : {}),
|
||||
...(detail ? { detail } : {}),
|
||||
...(nextModelId ? { nextModelId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseModel(value: unknown): LocalModelStateFile['model'] {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const id = text(value.id, 200);
|
||||
const label = text(value.label, 200);
|
||||
const context = count(value.context);
|
||||
if (!id || !label || !context) return undefined;
|
||||
const pullBytes = count(value.pullBytes);
|
||||
const digest = text(value.digest, 200);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
context,
|
||||
...(pullBytes !== undefined ? { pullBytes } : {}),
|
||||
...(digest ? { digest } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCli(value: unknown): LocalModelStateFile['cli'] {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const id = text(value.id, 200);
|
||||
const label = text(value.label, 200);
|
||||
return id && label ? { id, label } : undefined;
|
||||
}
|
||||
|
||||
function parseRuntime(value: unknown): LocalModelStateFile['runtime'] {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const version = text(value.version, 64);
|
||||
const port = count(value.port);
|
||||
if (!version || !port || port > 65_535) return undefined;
|
||||
const pid = count(value.pid);
|
||||
const measuredTotalBytes = count(value.measuredTotalBytes);
|
||||
return {
|
||||
version,
|
||||
port,
|
||||
...(pid ? { pid } : {}),
|
||||
// Anything but an explicit `false` reads as enabled: only Stop switches the runtime off.
|
||||
enabled: value.enabled !== false,
|
||||
...(measuredTotalBytes ? { measuredTotalBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseProgress(value: unknown): LocalModelStateFile['progress'] {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const completedBytes = count(value.completedBytes);
|
||||
const totalBytes = count(value.totalBytes);
|
||||
return completedBytes !== undefined && totalBytes !== undefined ? { completedBytes, totalBytes } : undefined;
|
||||
}
|
||||
|
||||
function parseLoaded(value: unknown): LocalModelStateFile['loaded'] {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const context = count(value.context);
|
||||
const sizeBytes = count(value.sizeBytes);
|
||||
const sizeVramBytes = count(value.sizeVramBytes);
|
||||
return context !== undefined && sizeBytes !== undefined && sizeVramBytes !== undefined
|
||||
? { context, sizeBytes, sizeVramBytes }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseConnection(value: unknown): LocalModelStateFile['connection'] {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const endpointId = text(value.endpointId, 200);
|
||||
return endpointId && typeof value.flagWasEnabled === 'boolean'
|
||||
? { endpointId, flagWasEnabled: value.flagWasEnabled }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a file's text stands for. Pure and total: text that is not a JSON object of
|
||||
* `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are
|
||||
* dropped, and a member of the wrong type is dropped rather than trusted.
|
||||
*
|
||||
* A state that needs a member it does not have is not trusted either: a job (`installing`,
|
||||
* `paused`, `failed`) without its model, its coding agent or its step, a `failed` job without
|
||||
* its error, and an `installed` state without its model or its runtime all read as idle.
|
||||
* Nothing is deleted by that: what lies in the folder stays until a Remove or a new install.
|
||||
*/
|
||||
export function parseLocalModelState(raw: string): LocalModelStateFile {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return IDLE_LOCAL_MODEL_STATE;
|
||||
}
|
||||
if (!isRecord(parsed) || parsed.schema !== 1) return IDLE_LOCAL_MODEL_STATE;
|
||||
const state = STATES.find((candidate) => candidate === parsed.state);
|
||||
if (!state || state === 'idle') return IDLE_LOCAL_MODEL_STATE;
|
||||
|
||||
const model = parseModel(parsed.model);
|
||||
const cli = parseCli(parsed.cli);
|
||||
const runtime = parseRuntime(parsed.runtime);
|
||||
const connection = parseConnection(parsed.connection);
|
||||
const updatedAt = text(parsed.updatedAt, 64);
|
||||
const replacedModelId = text(parsed.replacedModelId, 200);
|
||||
if (!model || !cli) return IDLE_LOCAL_MODEL_STATE;
|
||||
|
||||
if (state === 'installed') {
|
||||
if (!runtime) return IDLE_LOCAL_MODEL_STATE;
|
||||
const loaded = parseLoaded(parsed.loaded);
|
||||
return {
|
||||
schema: 1,
|
||||
state,
|
||||
model,
|
||||
cli,
|
||||
runtime,
|
||||
...(loaded ? { loaded } : {}),
|
||||
...(connection ? { connection } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const steps = parseSteps(parsed.steps);
|
||||
const current = step(parsed.step);
|
||||
if (!steps || !current || !steps.includes(current)) return IDLE_LOCAL_MODEL_STATE;
|
||||
const error = parseError(parsed.error);
|
||||
if (state === 'failed' && !error) return IDLE_LOCAL_MODEL_STATE;
|
||||
const progress = parseProgress(parsed.progress);
|
||||
const loaded = parseLoaded(parsed.loaded);
|
||||
return {
|
||||
schema: 1,
|
||||
state,
|
||||
step: current,
|
||||
steps,
|
||||
...(state === 'failed' && error ? { error } : {}),
|
||||
model,
|
||||
cli,
|
||||
...(runtime ? { runtime } : {}),
|
||||
...(progress ? { progress } : {}),
|
||||
...(loaded ? { loaded } : {}),
|
||||
...(connection ? { connection } : {}),
|
||||
...(replacedModelId ? { replacedModelId } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the state file is. Built from `dataPath()` on every call, so a test's own data dir is honoured. */
|
||||
function stateFilePath(): string {
|
||||
return localModelPaths(OLLAMA_RUNTIME_PIN.version).stateFile;
|
||||
}
|
||||
|
||||
async function readStateFrom(path: string): Promise<LocalModelStateFile> {
|
||||
try {
|
||||
return parseLocalModelState(await fs.readFile(path, 'utf-8'));
|
||||
} catch {
|
||||
return IDLE_LOCAL_MODEL_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored state. Never throws: a file that cannot be read is `IDLE_LOCAL_MODEL_STATE`. */
|
||||
export async function readLocalModelState(): Promise<LocalModelStateFile> {
|
||||
return readStateFrom(stateFilePath());
|
||||
}
|
||||
|
||||
async function writeStateFile(path: string, state: LocalModelStateFile): Promise<void> {
|
||||
await fs.mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, path);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Runs one change to the state file. `fn` receives the current state and returns the state to
|
||||
* write, or `undefined` to write nothing. Resolves with the state before and after the change
|
||||
* (`after` is `undefined` when nothing was written). `updatedAt` is stamped here. A failed
|
||||
* write rejects its own caller only; the chain keeps going.
|
||||
*/
|
||||
export function mutateLocalModelState(
|
||||
fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined
|
||||
): Promise<{ before: LocalModelStateFile; after: LocalModelStateFile | undefined }> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const path = stateFilePath();
|
||||
const before = await readStateFrom(path);
|
||||
const next = fn(before);
|
||||
if (!next) return { before, after: undefined };
|
||||
const after: LocalModelStateFile = { ...next, updatedAt: new Date().toISOString() };
|
||||
await writeStateFile(path, after);
|
||||
return { before, after };
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { PredictionToolCall, WorkspaceSignals } from './readmymind-context.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -150,6 +151,8 @@ const MAX_STATUS_LINES = 30;
|
||||
*/
|
||||
export async function collectWorkspaceSignals(workingDir: string): Promise<WorkspaceSignals | null> {
|
||||
const git = async (args: string[]): Promise<string> => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd: workingDir,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
|
||||
@@ -134,6 +134,8 @@ export interface RebootRestoreRejection {
|
||||
| 'workspace-forbidden'
|
||||
| 'already-live'
|
||||
| 'capacity-reached'
|
||||
/** The session ran on the model Codeman runs on this Mac, and that model cannot be started on right now. */
|
||||
| 'local-model-not-ready'
|
||||
| 'rebuild-failed';
|
||||
}
|
||||
|
||||
|
||||
+58
-5
@@ -510,6 +510,18 @@ export class GuardedInputBusyError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's launch guard refused the start of a new agent process
|
||||
* (`Session.setLaunchGuard()`). Its message is the sentence for the user. A typed error, so a
|
||||
* start that was refused is told apart from a tmux failure and never falls back to a direct PTY.
|
||||
*/
|
||||
export class LaunchRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LaunchRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
|
||||
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
|
||||
@@ -880,6 +892,12 @@ export class Session extends EventEmitter {
|
||||
// config dir) without guessing what it once wrote. Persisted disk-only (`__customModel`).
|
||||
private _customModel: CustomModelBookkeeping | undefined;
|
||||
|
||||
// Asked right before a NEW agent process is started for this session (a fresh pane, the
|
||||
// respawn of a dead one, a restart in place), never for an attach to an agent that runs.
|
||||
// Resolves with the sentence that refuses the start, or undefined. Set by the web layer
|
||||
// (`WebServer.setupSessionListeners()`); this file does not know who asks or why.
|
||||
private _launchGuard: (() => Promise<string | undefined>) | null = null;
|
||||
|
||||
// Env keys a retired custom-model selection injected that the NEXT respawn must
|
||||
// `tmux setenv -u`. Deleting a key from `_envOverrides` alone does nothing to the
|
||||
// tmux session, which keeps every `setenv` and hands it to `respawn-pane`, so the
|
||||
@@ -1639,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;
|
||||
@@ -2063,6 +2084,22 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets what is asked before a new agent process is started for this session (see
|
||||
* `_launchGuard`). The first user is the model Codeman runs on this Mac: an agent is not
|
||||
* started on it while its runtime is down or its model changed. `null` removes it.
|
||||
*/
|
||||
setLaunchGuard(guard: (() => Promise<string | undefined>) | null): void {
|
||||
this._launchGuard = guard;
|
||||
}
|
||||
|
||||
/** Throws the guard's sentence (`LaunchRefusedError`) when a new agent process must not be started now. */
|
||||
private async _assertLaunchAllowed(): Promise<void> {
|
||||
if (!this._launchGuard) return;
|
||||
const refusal = await this._launchGuard();
|
||||
if (refusal) throw new LaunchRefusedError(refusal);
|
||||
}
|
||||
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — public-safe
|
||||
// subset only (never envKeys/configDir/launchModel, the bookkeeping for setCustomModel).
|
||||
get customModel(): CustomModelSelection | undefined {
|
||||
@@ -2418,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,
|
||||
@@ -2635,6 +2674,8 @@ export class Session extends EventEmitter {
|
||||
let respawnedResumeId: string | undefined;
|
||||
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
// A new agent process: asked first (see `_launchGuard`). A refusal leaves the pane as it is.
|
||||
await this._assertLaunchAllowed();
|
||||
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
|
||||
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
||||
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
||||
@@ -2689,6 +2730,8 @@ export class Session extends EventEmitter {
|
||||
this._resumeSessionId = pinned;
|
||||
}
|
||||
}
|
||||
// A new agent process: asked first (see `_launchGuard`).
|
||||
await this._assertLaunchAllowed();
|
||||
this._muxSession = await mux.createSession(options.createSessionOptions);
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
@@ -2808,6 +2851,9 @@ export class Session extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A new agent process: asked first (see `_launchGuard`). A refusal rejects, and the pane
|
||||
// keeps what runs in it.
|
||||
await this._assertLaunchAllowed();
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
|
||||
if (!newPid) {
|
||||
@@ -3461,6 +3507,9 @@ export class Session extends EventEmitter {
|
||||
this._armPaneSettle(true);
|
||||
}
|
||||
} catch (err) {
|
||||
// A refused launch is an answer, not a tmux failure: it must not fall through to
|
||||
// the direct PTY below, which would start the very process that was refused.
|
||||
if (err instanceof LaunchRefusedError) throw err;
|
||||
if (this._adopt) this._failAdoptedStart(err);
|
||||
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
||||
this._useMux = false;
|
||||
@@ -3482,6 +3531,8 @@ export class Session extends EventEmitter {
|
||||
throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`);
|
||||
}
|
||||
if (this._adopt) this._failAdoptedStart(new Error('tmux is not available'));
|
||||
// A new agent process on this path too: asked first (see `_launchGuard`).
|
||||
await this._assertLaunchAllowed();
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab.
|
||||
@@ -4485,6 +4536,8 @@ export class Session extends EventEmitter {
|
||||
}, 100);
|
||||
}
|
||||
} catch (err) {
|
||||
// As in startInteractive(): a refused launch never falls back to a direct PTY.
|
||||
if (err instanceof LaunchRefusedError) throw err;
|
||||
if (this._adopt) this._failAdoptedStart(err);
|
||||
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
||||
this._useMux = false;
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
|
||||
* | local-model | HardwareFacts, LocalModelCatalog, Candidate, Recommendation, RecommendOptions, LOCAL_MODEL_LABELS | In-memory only (nothing persisted) → `GET /api/local-model` |
|
||||
* | local-model-install | LocalModelStatus, LocalModelInstallView, LocalModelStateFile, LocalModelRuntimePin, LOCAL_MODEL_INSTALL_LABELS | `~/.codeman/local-model.json` → `GET /api/local-model` and its install verbs |
|
||||
*
|
||||
* ## Cross-domain relationship map
|
||||
*
|
||||
@@ -77,3 +79,5 @@ export * from './webview.js';
|
||||
export * from './intent.js';
|
||||
export * from './foreign-tmux.js';
|
||||
export * from './mcp-sync.js';
|
||||
export * from './local-model.js';
|
||||
export * from './local-model-install.js';
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* @fileoverview Types of the local model INSTALL: the runtime pin, the job's states, steps and
|
||||
* error codes, the progress, the `install` object of `GET /api/local-model`, the bodies and
|
||||
* answers of the install verbs, and the shape of the state file.
|
||||
*
|
||||
* The read side (hardware, catalogue, recommendation) is in src/types/local-model.ts. This
|
||||
* file holds `LocalModelStatus`, the whole `data` of the GET, because it joins the two.
|
||||
*
|
||||
* Who produces what:
|
||||
* - `LocalModelRuntimePin`: a compiled constant in src/local-model/runtime.ts. No file, no
|
||||
* setting and no request can change which binary is downloaded.
|
||||
* - `LocalModelStateFile`: src/local-model/state-store.ts, the ONE writer of
|
||||
* `local-model.json` (mode 0600, temp file + rename).
|
||||
* - `LocalModelInstallView`: src/local-model/install-job.ts (`buildInstallView()`, pure), from
|
||||
* the state file plus what is live (the runtime child, the developer tools check).
|
||||
* - `LocalModelStatus` and the verb answers: src/web/routes/local-model-routes.ts.
|
||||
*
|
||||
* The HTTP contract. Every route is admin only in multi-user mode (403 `FORBIDDEN`) and
|
||||
* EXPERIMENTAL like the GET (docs/versioning-policy.md). Every verb answers
|
||||
* `{ success: true, data: LocalModelInstallView }` with the view AFTER the verb.
|
||||
*
|
||||
* | Route | Body | Refusals |
|
||||
* |----------------------------------|-----------------------------|-------------------------------------------------------|
|
||||
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
|
||||
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
|
||||
* | | | already has the reserved id; 409 `job-running`, |
|
||||
* | | | `already-installed`, `size-changed` |
|
||||
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
|
||||
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
|
||||
* | | | failed), `sessions-open` (another port while live |
|
||||
* | | | sessions use the model) |
|
||||
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running`, |
|
||||
* | | | `sessions-open` (stop, or another port, while live |
|
||||
* | | | sessions use the model) |
|
||||
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
|
||||
*
|
||||
* A 409 is the error envelope plus `data: LocalModelConflict`:
|
||||
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
|
||||
* WITHOUT `data` is a write of `settings.json` or of the endpoint store that was refused
|
||||
* because the file does not parse; `error` names the fix. A 400 the service names carries its
|
||||
* sentence the same way: `{ success: false, error, errorCode: 'INVALID_INPUT', data: {
|
||||
* message } }` (`LocalModelRefusal`), so the window can print it in its own language.
|
||||
*
|
||||
* The rules of src/types/local-model.ts hold here too:
|
||||
* 1. A size a person reads travels twice: as bytes and as decimal GB text from `formatGb()`
|
||||
* (`completedGb`, `totalGb`, `downloadGb`). The browser prints the text and adds or
|
||||
* divides nothing for display. `mbPerSecond` is the one other unit, decimal MB per second.
|
||||
* 2. Every sentence the server chooses comes from `LOCAL_MODEL_INSTALL_LABELS`, with its
|
||||
* numbers and names in `vars`. `{name}` is taken by the translator, so no slot has that name.
|
||||
* 3. A CLI id is data (`LocalModelInstallView.cli`): the browser passes it on and compares it
|
||||
* with nothing. What a person reads is `cliLabel`.
|
||||
*/
|
||||
|
||||
import type { HardwareFacts, Recommendation } from './local-model.js';
|
||||
|
||||
// ---------- The runtime pin ----------
|
||||
|
||||
/**
|
||||
* The one runtime build Codeman downloads and runs. A compiled constant
|
||||
* (`OLLAMA_RUNTIME_PIN` in src/local-model/runtime.ts): the hash is compared before anything
|
||||
* is unpacked, so the pin and the hash must come from the same reviewed source file.
|
||||
*/
|
||||
export interface LocalModelRuntimePin {
|
||||
/** The runtime's family. One value today. */
|
||||
id: 'ollama';
|
||||
/** The release ('0.40.15'). `GET /api/version` of the running runtime must answer it. */
|
||||
version: string;
|
||||
/** Where the archive is downloaded from. https, a fixed release asset. */
|
||||
url: string;
|
||||
/** Size of the archive in bytes. A body that grows past it is refused before the hash is finished. */
|
||||
bytes: number;
|
||||
/** Lowercase hex sha256 of the archive. */
|
||||
sha256: string;
|
||||
/** What the unpacked folder takes on disk, in bytes (measured: about 579 MB). */
|
||||
unpackedBytes: number;
|
||||
/** The oldest macOS major version the runtime supports. */
|
||||
minMacosMajor: number;
|
||||
}
|
||||
|
||||
// ---------- States, steps, codes ----------
|
||||
|
||||
/**
|
||||
* Where the install stands. The verb of the flow is the same throughout: install, installing,
|
||||
* installed.
|
||||
* - `idle`: nothing is installed and no job exists.
|
||||
* - `installing`: the one job is running; `step` says where.
|
||||
* - `paused`: the job stopped at `step` because of Cancel or a server restart. It never
|
||||
* continues without a click.
|
||||
* - `failed`: the job stopped at `step` with `error`.
|
||||
* - `installed`: the model is connected. Whether the runtime runs is `running`, not a state.
|
||||
* The state table (what is on disk, what a restart and Cancel do, the allowed actions) is
|
||||
* `INSTALL_STATE_TABLE` in src/local-model/install-job.ts.
|
||||
*/
|
||||
export type LocalModelInstallState = 'idle' | 'installing' | 'paused' | 'failed' | 'installed';
|
||||
|
||||
/**
|
||||
* The steps of a job, in the order they run.
|
||||
* - `runtime`: get the pinned runtime (download, compare the hash, unpack), start it, read
|
||||
* the measured memory budget and judge the chosen model against it.
|
||||
* - `model`: download the model (the runtime's pull).
|
||||
* - `verify`: load the model and check it: one tiny chat, the memory and context the runtime
|
||||
* reports, one tool call.
|
||||
* - `agent`: install the coding agent. Left out of a job whose coding agent is installed.
|
||||
* - `connect`: save the connection (the endpoint record and the settings flag).
|
||||
*/
|
||||
export type LocalModelInstallStep = 'runtime' | 'model' | 'verify' | 'agent' | 'connect';
|
||||
|
||||
/** Every step, in order. A job runs these, without `agent` when the coding agent is installed. */
|
||||
export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
|
||||
'runtime',
|
||||
'model',
|
||||
'verify',
|
||||
'agent',
|
||||
'connect',
|
||||
];
|
||||
|
||||
/**
|
||||
* Why a job failed, or (in state `installed`) what blocks a session. Each code has one
|
||||
* sentence (`LOCAL_MODEL_ERROR_LABELS`) and one helpful next step (`LOCAL_MODEL_ERROR_NEXT`).
|
||||
*
|
||||
* | Code | Raised when |
|
||||
* |---------------------------|--------------------------------------------------------------------------|
|
||||
* | `runtime-download-failed` | the archive download still fails after its retries |
|
||||
* | `runtime-damaged` | the archive's sha256 is not the pinned one (the file was deleted) |
|
||||
* | `runtime-unpack-failed` | `/usr/bin/tar` failed |
|
||||
* | `runtime-start-failed` | the runtime was started and never answered `/api/version`. Installed: |
|
||||
* | | the last start failed (the detail says why), and Start tries again |
|
||||
* | `runtime-stopped` | the runtime crashed and was restarted 3 times, then given up: on an |
|
||||
* | | installed model, or during a job's download |
|
||||
* | `runtime-not-running` | installed: the runtime is not running (the person stopped it). Only a |
|
||||
* | | refused session start says it; the view shows no error for a stopped runtime |
|
||||
* | `port-in-use` | another program listens on the stored port, or no port of the span is free |
|
||||
* | `disk-full` | a write failed for lack of space |
|
||||
* | `pull-failed` | the pull still ends on an error line or a cut stream after its retries |
|
||||
* | `model-not-found` | the registry no longer has this model under this name |
|
||||
* | `does-not-fit` | the measured budget rules the model out, or the runtime did not put all of it on the GPU |
|
||||
* | `context-not-applied` | the runtime loaded the model with another context than the chosen one |
|
||||
* | `load-failed` | the tiny chat that loads the model failed |
|
||||
* | `no-tool-call` | the tool probe was answered without a tool call with parseable arguments |
|
||||
* | `probe-unfinished` | the tool probe ended on its token limit, so it proves nothing |
|
||||
* | `harness-failed` | the coding agent's install command failed, or its binary is still missing |
|
||||
* | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write |
|
||||
* | `connect-failed` | the endpoint record or the settings flag could not be written |
|
||||
* | `model-changed` | installed: the model's digest is not the one stored at install time |
|
||||
* | `internal` | anything unexpected (the exception text is in `detail`) |
|
||||
*/
|
||||
export type LocalModelErrorCode =
|
||||
| 'runtime-download-failed'
|
||||
| 'runtime-damaged'
|
||||
| 'runtime-unpack-failed'
|
||||
| 'runtime-start-failed'
|
||||
| 'runtime-stopped'
|
||||
| 'runtime-not-running'
|
||||
| 'port-in-use'
|
||||
| 'disk-full'
|
||||
| 'pull-failed'
|
||||
| 'model-not-found'
|
||||
| 'does-not-fit'
|
||||
| 'context-not-applied'
|
||||
| 'load-failed'
|
||||
| 'no-tool-call'
|
||||
| 'probe-unfinished'
|
||||
| 'harness-failed'
|
||||
| 'endpoint-id-taken'
|
||||
| 'connect-failed'
|
||||
| 'model-changed'
|
||||
| 'internal';
|
||||
|
||||
/**
|
||||
* The ONE action that helps after an error. The window draws one button for it.
|
||||
* - `retry`: "Try again". `POST /api/local-model/resume`.
|
||||
* - `other-port`: "Use another port". `POST /api/local-model/resume` with `newPort: true`
|
||||
* (state `failed`), or `POST /api/local-model/runtime` with `action: 'start'` and
|
||||
* `newPort: true` (state `installed`).
|
||||
* - `next-model`: "Try <model>". Back to the chooser with `error.nextModel` preselected; the
|
||||
* person confirms its sizes and a new `POST /api/local-model/install` replaces the job.
|
||||
* Without a `nextModel` the window offers the chooser and Remove.
|
||||
* - `start`: "Start". `POST /api/local-model/runtime` with `action: 'start'`.
|
||||
* - `remove`: "Remove". `DELETE /api/local-model`.
|
||||
*
|
||||
* `LOCAL_MODEL_ERROR_NEXT` is the default of a code. The view adapts it to the state the
|
||||
* error is shown in, so the button is always one the server accepts there: a code whose
|
||||
* default is `start` says `retry` on a failed job (there is no Start verb for a job), and
|
||||
* `runtime-start-failed` says `start` on an installed model (there is nothing to resume).
|
||||
*/
|
||||
export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start' | 'remove';
|
||||
|
||||
/**
|
||||
* What a client may do in the current state. The view's `actions` lists exactly the ones the
|
||||
* server would accept right now, so the window shows a button only for those.
|
||||
* - `install`: `POST /api/local-model/install`.
|
||||
* - `cancel`: `POST /api/local-model/cancel`.
|
||||
* - `resume`: `POST /api/local-model/resume`.
|
||||
* - `start` and `stop`: `POST /api/local-model/runtime`.
|
||||
* - `remove`: `DELETE /api/local-model`.
|
||||
* - `start-session`: not a route of this feature: the window starts a session on the endpoint
|
||||
* `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing
|
||||
* blocks a session.
|
||||
*/
|
||||
export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session';
|
||||
|
||||
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
|
||||
export type LocalModelDeveloperTools =
|
||||
/** `/usr/bin/xcode-select -p` succeeded. */
|
||||
| 'present'
|
||||
/** It failed: macOS will offer to install the tools the first time an agent needs one. */
|
||||
| 'missing'
|
||||
/** Not macOS, or the check did not answer in time. Nothing is said then. */
|
||||
| 'unknown';
|
||||
|
||||
// ---------- Sentences ----------
|
||||
|
||||
/**
|
||||
* Every sentence the server may put into the `install` object or a 409, in English (the i18n
|
||||
* source language). Like `LOCAL_MODEL_LABELS`: the zh-CN dictionary in src/web/public/i18n.js
|
||||
* has one entry per value, keyed by this exact text, and the i18n test lists them from here.
|
||||
* A constant of its own, so the sentences of the recommendation and of the install can be
|
||||
* listed apart.
|
||||
*
|
||||
* | Key | Slots (all text) |
|
||||
* |------------------------|----------------------------------------------------------------------|
|
||||
* | `errPortInUse` | `port`: the port number |
|
||||
* | `errModelNotFound`, `errDoesNotFit`, `errLoadFailed`, `errNoToolCall`, `errProbeUnfinished` | `model`: the model's label |
|
||||
* | `errContextNotApplied` | `model`; `actual`: the context the runtime reports; `wanted`: the chosen one |
|
||||
* | `errHarnessFailed` | `cli`: the coding agent's label |
|
||||
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
|
||||
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
|
||||
* | `refusedOtherModel` | `model`: the label of the model that is installed |
|
||||
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
|
||||
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
|
||||
*
|
||||
* `developerToolsMissing` is not sent: the view carries `developerTools` and the window prints
|
||||
* this sentence when it is `missing`. It is here so that its wording has one source.
|
||||
*
|
||||
* `endpointManaged` and `endpointIdReserved` are what the endpoint routes
|
||||
* (src/web/routes/custom-model-routes.ts) answer for the record this feature manages and for
|
||||
* its reserved id. They have no slot, so the App Settings toast that prints them translates
|
||||
* them as it translates every other whole sentence.
|
||||
*/
|
||||
export const LOCAL_MODEL_INSTALL_LABELS = {
|
||||
errRuntimeDownloadFailed:
|
||||
'The download of the runtime stopped and could not be continued. Check the network connection, then try again.',
|
||||
errRuntimeDamaged: 'The runtime download was damaged and has been deleted. Try again.',
|
||||
errRuntimeUnpackFailed: 'The runtime could not be unpacked on this Mac.',
|
||||
errRuntimeStartFailed: 'The runtime did not start on this Mac.',
|
||||
errRuntimeStopped: 'The runtime keeps stopping, so Codeman no longer restarts it.',
|
||||
errRuntimeNotRunning: 'The model on this Mac is not running. Start it first.',
|
||||
errPortInUse: 'Port {port} is in use by another program.',
|
||||
errDiskFull: 'The disk is full. Free up space, then try again.',
|
||||
errPullFailed: 'The download keeps stopping. The network may be damaging data. Try again or use another network.',
|
||||
errModelNotFound: '{model} is no longer offered under this name. Update Codeman or pick another model.',
|
||||
errDoesNotFit: '{model} does not fit in the memory of this Mac after all.',
|
||||
errContextNotApplied: '{model} loaded with a context of {actual} tokens instead of {wanted}.',
|
||||
errLoadFailed: '{model} did not load on this Mac.',
|
||||
errNoToolCall: '{model} loaded but did not call a tool, so a coding agent cannot work with it.',
|
||||
errProbeUnfinished: 'The check of {model} did not finish.',
|
||||
errHarnessFailed: '{cli} could not be installed.',
|
||||
errEndpointIdTaken:
|
||||
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.',
|
||||
errConnectFailed: 'Codeman could not save the connection to the model.',
|
||||
errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.',
|
||||
errInternal: 'The install stopped on an unexpected error.',
|
||||
conflictJobRunning: 'An install is already running.',
|
||||
conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.',
|
||||
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
|
||||
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
|
||||
conflictWrongState: 'That is not possible in the current state.',
|
||||
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
|
||||
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
|
||||
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
|
||||
refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.',
|
||||
endpointManaged:
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
|
||||
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
|
||||
developerToolsMissing:
|
||||
'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.',
|
||||
} as const;
|
||||
|
||||
/** One of the texts in `LOCAL_MODEL_INSTALL_LABELS`. */
|
||||
export type LocalModelInstallLabel = (typeof LOCAL_MODEL_INSTALL_LABELS)[keyof typeof LOCAL_MODEL_INSTALL_LABELS];
|
||||
|
||||
/** A sentence of the install: a text from `LOCAL_MODEL_INSTALL_LABELS` plus the values of its `{placeholder}` slots. */
|
||||
export interface LocalModelInstallMessage {
|
||||
text: LocalModelInstallLabel;
|
||||
/** Slot values, already formatted as text. Absent when the text has no slot. */
|
||||
vars?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The sentence of each error code. */
|
||||
export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelInstallLabel> = {
|
||||
'runtime-download-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeDownloadFailed,
|
||||
'runtime-damaged': LOCAL_MODEL_INSTALL_LABELS.errRuntimeDamaged,
|
||||
'runtime-unpack-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeUnpackFailed,
|
||||
'runtime-start-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStartFailed,
|
||||
'runtime-stopped': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStopped,
|
||||
'runtime-not-running': LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning,
|
||||
'port-in-use': LOCAL_MODEL_INSTALL_LABELS.errPortInUse,
|
||||
'disk-full': LOCAL_MODEL_INSTALL_LABELS.errDiskFull,
|
||||
'pull-failed': LOCAL_MODEL_INSTALL_LABELS.errPullFailed,
|
||||
'model-not-found': LOCAL_MODEL_INSTALL_LABELS.errModelNotFound,
|
||||
'does-not-fit': LOCAL_MODEL_INSTALL_LABELS.errDoesNotFit,
|
||||
'context-not-applied': LOCAL_MODEL_INSTALL_LABELS.errContextNotApplied,
|
||||
'load-failed': LOCAL_MODEL_INSTALL_LABELS.errLoadFailed,
|
||||
'no-tool-call': LOCAL_MODEL_INSTALL_LABELS.errNoToolCall,
|
||||
'probe-unfinished': LOCAL_MODEL_INSTALL_LABELS.errProbeUnfinished,
|
||||
'harness-failed': LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed,
|
||||
'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken,
|
||||
'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed,
|
||||
'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged,
|
||||
internal: LOCAL_MODEL_INSTALL_LABELS.errInternal,
|
||||
};
|
||||
|
||||
/** The one helpful next step of each error code. */
|
||||
export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextStep> = {
|
||||
'runtime-download-failed': 'retry',
|
||||
'runtime-damaged': 'retry',
|
||||
'runtime-unpack-failed': 'retry',
|
||||
'runtime-start-failed': 'retry',
|
||||
'runtime-stopped': 'start',
|
||||
'runtime-not-running': 'start',
|
||||
'port-in-use': 'other-port',
|
||||
'disk-full': 'retry',
|
||||
'pull-failed': 'retry',
|
||||
'model-not-found': 'next-model',
|
||||
'does-not-fit': 'next-model',
|
||||
'context-not-applied': 'retry',
|
||||
'load-failed': 'retry',
|
||||
'no-tool-call': 'next-model',
|
||||
'probe-unfinished': 'retry',
|
||||
'harness-failed': 'retry',
|
||||
'endpoint-id-taken': 'retry',
|
||||
'connect-failed': 'retry',
|
||||
'model-changed': 'remove',
|
||||
internal: 'retry',
|
||||
};
|
||||
|
||||
// ---------- The `install` object of the GET ----------
|
||||
|
||||
/**
|
||||
* How far a download is. Present while the `runtime` step downloads the archive and while the
|
||||
* `model` step pulls, once the total is known; kept on a paused or failed job as it stood.
|
||||
* During the `model` step both byte figures come from the pull's own lines, never from the
|
||||
* catalogue.
|
||||
*/
|
||||
export interface LocalModelProgress {
|
||||
completedBytes: number;
|
||||
totalBytes: number;
|
||||
/** `completedBytes` as decimal GB text, no unit ('6.2'). */
|
||||
completedGb: string;
|
||||
/** `totalBytes` as decimal GB text, no unit ('23.1'). */
|
||||
totalGb: string;
|
||||
/** Download speed. Absent until it can be told (the first moments, a paused job). */
|
||||
bytesPerSecond?: number;
|
||||
/** `bytesPerSecond` as whole decimal megabytes per second, no unit ('58'). Present exactly when `bytesPerSecond` is. */
|
||||
mbPerSecond?: string;
|
||||
/** Seconds left at the current speed, rounded up. Absent when the speed is unknown or 0. */
|
||||
secondsLeft?: number;
|
||||
}
|
||||
|
||||
/** An error as the window shows it: the sentence, the one action that helps, and the raw detail. */
|
||||
export interface LocalModelInstallError {
|
||||
code: LocalModelErrorCode;
|
||||
/** `LOCAL_MODEL_ERROR_LABELS[code]` with its slots filled. */
|
||||
message: LocalModelInstallMessage;
|
||||
/** `LOCAL_MODEL_ERROR_NEXT[code]`, adapted to the state the error is shown in (see `LocalModelNextStep`). */
|
||||
next: LocalModelNextStep;
|
||||
/**
|
||||
* For `next-model`: the model to try instead. After `does-not-fit` it is one that needs LESS
|
||||
* memory than the one that failed; after the other codes the best other model that fits.
|
||||
* Absent when there is none.
|
||||
*/
|
||||
nextModel?: { modelId: string; modelLabel: string };
|
||||
/**
|
||||
* Raw text for the person who wants to know more: the last lines of an installer, the
|
||||
* message of a refused write, an exception. Not translated; printed with `textContent`.
|
||||
*/
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** The model of the job or of the installation. */
|
||||
export interface LocalModelInstalledModel {
|
||||
/** `LocalModelEntry.id`. */
|
||||
id: string;
|
||||
/** `LocalModelEntry.label`. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** The runtime, as the consent screen needs it. */
|
||||
export interface LocalModelRuntimeView {
|
||||
/** `LocalModelRuntimePin.version`. */
|
||||
version: string;
|
||||
/** The pinned runtime is unpacked on disk. Then an install downloads only the model. */
|
||||
installed: boolean;
|
||||
/** What an install would download for the runtime: the archive size, 0 when `installed`. */
|
||||
downloadBytes: number;
|
||||
/** `downloadBytes` as decimal GB text, no unit ('0.2'). */
|
||||
downloadGb: string;
|
||||
/** What the unpacked runtime takes on disk, as decimal GB text, no unit ('0.6'). */
|
||||
unpackedGb: string;
|
||||
}
|
||||
|
||||
/** What the runtime reported about the loaded model when the job checked it. The facts of the Done line. */
|
||||
export interface LocalModelLoadedFacts {
|
||||
/** The context length in tokens the runtime loaded the model with. */
|
||||
context: number;
|
||||
/** Memory the loaded model took, in bytes (`size` of the runtime's process list). */
|
||||
memoryBytes: number;
|
||||
/** `memoryBytes` as decimal GB text, no unit. */
|
||||
memoryGb: string;
|
||||
/** All of it is on the GPU (`size_vram` equals `size`). Always true after a job that passed. */
|
||||
onGpu: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `install` object of `GET /api/local-model`: where the install stands and what can be
|
||||
* done about it. The window polls it once a second while it is open; there is no SSE event.
|
||||
*
|
||||
* Which members are present, by state:
|
||||
*
|
||||
* | Member | idle | installing | paused | failed | installed |
|
||||
* |-----------------------------------|------|------------|--------|--------|-----------|
|
||||
* | `step`, `steps` | | yes | yes | yes | |
|
||||
* | `progress` | | during a download | as it stood, when known | as it stood, when known | |
|
||||
* | `error` | | | | yes | only when something blocks a session |
|
||||
* | `model`, `cli`, `cliLabel`, `context` | | yes | yes | yes | yes |
|
||||
* | `port` | | once chosen | once chosen | once chosen | yes |
|
||||
* | `loaded`, `endpointId` | | | | | yes |
|
||||
*
|
||||
* `running`, `developerTools`, `runtime`, `folder` and `actions` are always present.
|
||||
*/
|
||||
export interface LocalModelInstallView {
|
||||
state: LocalModelInstallState;
|
||||
/** The step the job is in, or stopped in. */
|
||||
step?: LocalModelInstallStep;
|
||||
/** The steps of THIS job in order, for the step list (`agent` is absent when the coding agent was already installed). */
|
||||
steps?: LocalModelInstallStep[];
|
||||
progress?: LocalModelProgress;
|
||||
error?: LocalModelInstallError;
|
||||
model?: LocalModelInstalledModel;
|
||||
/** The coding agent: a CLI registry id, as data. */
|
||||
cli?: string;
|
||||
/** The coding agent's display label ('OpenCode'). */
|
||||
cliLabel?: string;
|
||||
/** The context length in tokens the runtime is started with. */
|
||||
context?: number;
|
||||
/** The loopback port of the runtime. */
|
||||
port?: number;
|
||||
/** The runtime child Codeman started is alive right now. */
|
||||
running: boolean;
|
||||
developerTools: LocalModelDeveloperTools;
|
||||
runtime: LocalModelRuntimeView;
|
||||
/** The absolute folder everything of this feature is kept in, for the "where it goes" line. */
|
||||
folder: string;
|
||||
loaded?: LocalModelLoadedFacts;
|
||||
/** The id of the saved endpoint a session is started on. */
|
||||
endpointId?: string;
|
||||
/** What the server accepts right now, see `LocalModelAction`. */
|
||||
actions: LocalModelAction[];
|
||||
}
|
||||
|
||||
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
|
||||
export interface LocalModelStatus {
|
||||
hardware: HardwareFacts;
|
||||
recommendation: Recommendation;
|
||||
/** `LocalModelCatalog.updated`. */
|
||||
catalogUpdated: string;
|
||||
/** Where the install stands. Always present. */
|
||||
install: LocalModelInstallView;
|
||||
}
|
||||
|
||||
// ---------- Request bodies and answers ----------
|
||||
|
||||
/**
|
||||
* Body of `POST /api/local-model/install`. The three choices must name a row the server offers
|
||||
* right now: a model of `recommendation.pick` or `recommendation.alternatives`, at that
|
||||
* candidate's `context`, with one of its `harnesses`. Anything else is a 400 before any
|
||||
* download.
|
||||
*/
|
||||
export interface LocalModelInstallRequest {
|
||||
/** `Candidate.modelId`. */
|
||||
modelId: string;
|
||||
/** `CandidateHarness.cli`. */
|
||||
cli: string;
|
||||
/** `Candidate.context`. */
|
||||
context: number;
|
||||
/**
|
||||
* The total the person saw and confirmed: `Candidate.installBytes`. The server compares it
|
||||
* with its own total and answers 409 `size-changed` when they differ, so a consent is bound
|
||||
* to the size that was shown.
|
||||
*/
|
||||
acknowledgedBytes: number;
|
||||
}
|
||||
|
||||
/** Body of `POST /api/local-model/resume`. May be absent or empty. */
|
||||
export interface LocalModelResumeRequest {
|
||||
/** True: choose another port first (the answer to `port-in-use`). */
|
||||
newPort?: boolean;
|
||||
}
|
||||
|
||||
/** Body of `POST /api/local-model/runtime`. */
|
||||
export interface LocalModelRuntimeRequest {
|
||||
/** `stop` also keeps the runtime from being started at the next boot; `start` undoes that. */
|
||||
action: 'start' | 'stop';
|
||||
/** With `start`: choose another port first (the answer to `port-in-use`), and move the saved endpoint to it. */
|
||||
newPort?: boolean;
|
||||
}
|
||||
|
||||
/** Body of `DELETE /api/local-model`. May be absent or empty. */
|
||||
export interface LocalModelRemoveRequest {
|
||||
/** True: delete the downloaded model too. Default false: the model stays for a later install. */
|
||||
removeModel?: boolean;
|
||||
}
|
||||
|
||||
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
|
||||
export interface LocalModelRefusal {
|
||||
message: LocalModelInstallMessage;
|
||||
}
|
||||
|
||||
/** What every verb answers on success: the view after the verb. */
|
||||
export type LocalModelVerbAnswer = LocalModelInstallView;
|
||||
|
||||
/**
|
||||
* Why a verb was refused with 409.
|
||||
* - `job-running`: a job is running (a second install, a resume, a remove or a runtime action).
|
||||
* - `already-installed`: install while a model is installed. Remove it first.
|
||||
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
|
||||
* `expectedGb` carry that total, so the window can show it and ask again.
|
||||
* - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint;
|
||||
* `sessions` names them. Their coding agents were given the runtime's address when they
|
||||
* started, so they would go on calling a port nothing of ours holds.
|
||||
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
|
||||
*/
|
||||
export type LocalModelConflictReason =
|
||||
| 'job-running'
|
||||
| 'already-installed'
|
||||
| 'size-changed'
|
||||
| 'sessions-open'
|
||||
| 'wrong-state';
|
||||
|
||||
/** The `data` of a 409 of an install verb. */
|
||||
export interface LocalModelConflict {
|
||||
reason: LocalModelConflictReason;
|
||||
/** The sentence for the window (`conflict...` of `LOCAL_MODEL_INSTALL_LABELS`). */
|
||||
message: LocalModelInstallMessage;
|
||||
/** `size-changed`: the server's own total in bytes. */
|
||||
expectedBytes?: number;
|
||||
/** `size-changed`: `expectedBytes` as decimal GB text, no unit. */
|
||||
expectedGb?: string;
|
||||
/** `sessions-open`: the names of the live sessions that use the endpoint. */
|
||||
sessions?: string[];
|
||||
}
|
||||
|
||||
// ---------- The state file ----------
|
||||
|
||||
/**
|
||||
* `local-model.json` in the data dir: everything that must survive a restart. Mode 0600,
|
||||
* written through a temp file and a rename, by src/local-model/state-store.ts alone.
|
||||
*
|
||||
* Rules:
|
||||
* - It holds NO path. Every path of this feature is built from `dataPath()` constants
|
||||
* (`localModelPaths()`), so a state file can never point a delete somewhere else.
|
||||
* - Reading is total: a missing file, a file that does not parse or one with another `schema`
|
||||
* reads as `{ schema: 1, state: 'idle' }`.
|
||||
* - A file read at boot with state `installing` is a job a restart interrupted: it becomes
|
||||
* `paused` before anything else looks at it.
|
||||
*/
|
||||
export interface LocalModelStateFile {
|
||||
schema: 1;
|
||||
state: LocalModelInstallState;
|
||||
/** The step the job is in or stopped in. Absent in `idle` and `installed`. */
|
||||
step?: LocalModelInstallStep;
|
||||
/** The steps of the job, in order. Absent in `idle` and `installed`. */
|
||||
steps?: LocalModelInstallStep[];
|
||||
/** State `failed`: why. State `installed`: absent (a blocking problem is found live, never stored). */
|
||||
error?: {
|
||||
code: LocalModelErrorCode;
|
||||
/** The slot values of the code's sentence. */
|
||||
vars?: Record<string, string>;
|
||||
detail?: string;
|
||||
/** For a `next-model` code: the model to offer, when the job knew one. */
|
||||
nextModelId?: string;
|
||||
};
|
||||
/** The model of the job or of the installation. */
|
||||
model?: {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The context length in tokens the person chose. */
|
||||
context: number;
|
||||
/** The total the pull reported, once known. */
|
||||
pullBytes?: number;
|
||||
/** The digest `/api/tags` reported after the pull. Compared again at boot and before a session starts. */
|
||||
digest?: string;
|
||||
};
|
||||
/**
|
||||
* The model of the job this one replaced, when it was another model: what that job left in
|
||||
* the model store is deleted once the runtime runs, before the new model is downloaded.
|
||||
* Absent once that was done, and in `idle` and `installed`.
|
||||
*/
|
||||
replacedModelId?: string;
|
||||
/** The coding agent of the job or of the installation. */
|
||||
cli?: { id: string; label: string };
|
||||
/** The runtime, once its port was chosen. */
|
||||
runtime?: {
|
||||
/**
|
||||
* The runtime version this installation runs. A later build with another pin keeps
|
||||
* starting this version for as long as its folder is unpacked.
|
||||
*/
|
||||
version: string;
|
||||
/** The loopback port, chosen once by binding. */
|
||||
port: number;
|
||||
/**
|
||||
* The pid of the child Codeman last started, stored the moment it is spawned. Used to
|
||||
* tell our own runtime from another program: at boot, and before the folder is deleted.
|
||||
*/
|
||||
pid?: number;
|
||||
/** False after Stop: the runtime is not started at boot. */
|
||||
enabled: boolean;
|
||||
/** The device total the runtime printed when it started, in bytes. */
|
||||
measuredTotalBytes?: number;
|
||||
};
|
||||
/** How far the last download was when the job paused or failed. A restart may leave it absent. */
|
||||
progress?: { completedBytes: number; totalBytes: number };
|
||||
/** What the runtime reported when the job checked the loaded model. */
|
||||
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
|
||||
/**
|
||||
* Present once the `connect` step CLAIMED the connection, which it does before its two
|
||||
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
|
||||
* right after it) is harmless: a resumed job writes both, and uninstall removes nothing.
|
||||
*/
|
||||
connection?: {
|
||||
/** The id of the endpoint record this feature wrote. */
|
||||
endpointId: string;
|
||||
/** The value `customModelEndpointsEnabled` had before this feature switched it on. */
|
||||
flagWasEnabled: boolean;
|
||||
};
|
||||
/** ISO time of the last write. */
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* @fileoverview Types of the hardware-aware local model recommendation: what the server sees of
|
||||
* this Mac, the catalogue of models and harnesses, and the recommendation part of the answer of
|
||||
* `GET /api/local-model`.
|
||||
*
|
||||
* Everything here is the READ side: Codeman looks at the Mac and recommends. The types of the
|
||||
* install (the job states, the progress, the `install` object of the GET, the request bodies,
|
||||
* the state file, the runtime pin) and `LocalModelStatus`, the whole `data` of the GET, are in
|
||||
* src/types/local-model-install.ts. Both files go out through the barrel (`../types.js`).
|
||||
*
|
||||
* Who produces what:
|
||||
* - `HardwareFacts`: src/local-model/hardware.ts (one bounded `sysctl` spawn, never throws).
|
||||
* - `LocalModelCatalog` and its rows: src/config/local-model-catalog.ts (a compiled constant).
|
||||
* - `HarnessFact`: src/local-model/harness-facts.ts (server only, read from the CLI registry at
|
||||
* call time; it holds a function, so it never goes over HTTP).
|
||||
* - `Recommendation`, `Candidate`, `DeviceBudget`: src/local-model/recommend.ts (pure).
|
||||
*
|
||||
* A field that is optional here is optional by meaning, and its comment says when it is
|
||||
* absent (a tested entry without a measured speed, a harness that needs no install command, a
|
||||
* row without a tag). Everything else is always there, so the chooser window and the install
|
||||
* job may rely on it.
|
||||
*
|
||||
* Three rules every producer and consumer follows:
|
||||
*
|
||||
* 1. ONE unit for a computed size a person reads: decimal GB, computed from bytes by
|
||||
* `formatGb()` in src/local-model/format.ts. Every such size travels twice, as bytes
|
||||
* (`needBytes`) and as the text to show (`needGb`, a number without the unit, such as
|
||||
* '25.5'). The browser prints the text and never divides bytes itself. GiB appears only
|
||||
* inside the budget math. The one exception is the NAME of the installed memory: the card
|
||||
* says `HardwareFacts.memClassGb` (48 for a Mac sold with 48 GB), see that field.
|
||||
* 2. Every sentence or label the SERVER chooses comes from `LOCAL_MODEL_LABELS` below, so the
|
||||
* browser can translate it (`codemanT(text, vars)`) and its i18n test can list every string.
|
||||
* Numbers are never spliced into a sentence here: they travel in `vars` and fill the
|
||||
* `{placeholder}` slots.
|
||||
* 3. A CLI id is DATA (`LocalHarnessEntry.cli`, `Candidate.cli`). No code compares one with a
|
||||
* literal: what differs between harnesses is a catalogue field or a `HarnessFact`.
|
||||
*/
|
||||
|
||||
// ---------- Hardware ----------
|
||||
|
||||
/**
|
||||
* How `HardwareFacts` was obtained.
|
||||
* - `sysctl`: the spawn answered and `hw.memsize` parsed.
|
||||
* - `os-fallback`: on macOS, but the spawn failed or timed out; memory, architecture and the chip
|
||||
* string come from `node:os` and the free disk from `statfs`. What only sysctl knows (`arm64`,
|
||||
* `translated`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb`) is absent.
|
||||
* - `not-probed`: not macOS, so `sysctl` was never spawned.
|
||||
*/
|
||||
export type HardwareProbeSource = 'sysctl' | 'os-fallback' | 'not-probed';
|
||||
|
||||
/**
|
||||
* What the server knows about the machine it runs on. Plain JSON (it is part of the GET payload),
|
||||
* and always an answer: a failed probe yields the `os-fallback` or `not-probed` shape, never an
|
||||
* exception.
|
||||
*/
|
||||
export interface HardwareFacts {
|
||||
/** `process.platform` of the server. Anything but `darwin` ends in verdict `none`, reason `not-macos`. */
|
||||
platform: string;
|
||||
/** `os.arch()` of the server process. `x64` on Apple silicon means Node runs under Rosetta. */
|
||||
arch: string;
|
||||
/** `hw.optional.arm64` is 1: the MACHINE is Apple silicon whatever `arch` says. Absent when the key was not read. */
|
||||
arm64?: boolean;
|
||||
/** `sysctl.proc_translated` is 1: this process runs under Rosetta. Absent when the key was not read (Intel Macs lack it). */
|
||||
translated?: boolean;
|
||||
/** Installed memory in bytes (`hw.memsize`, else `os.totalmem()`). The ONLY memory input of the budget math. */
|
||||
memBytes: number;
|
||||
/**
|
||||
* `memBytes` in the unit of every computed size: decimal GB from `formatGb()`, without the unit
|
||||
* ('17.2' for a Mac sold as 16 GB). NOT what the card calls the installed memory: that is
|
||||
* `memClassGb`, see there.
|
||||
*/
|
||||
memGb: string;
|
||||
/**
|
||||
* The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48), 0 when the size is
|
||||
* unknown. Tier rules and `TestedOn.memGb` use it, and it is the ONE size a person reads that
|
||||
* is not decimal GB: the hardware line and the `memory` slot of `noneMemory` print it, because
|
||||
* it is the number on the box and in About This Mac ("48 GB"), and '51.5 GB memory' reads as a
|
||||
* different machine. Every computed size (need, budget, download, free disk) stays decimal GB.
|
||||
*/
|
||||
memClassGb: number;
|
||||
/** `machdep.cpu.brand_string` ('Apple M4 Pro'), else `os.cpus()[0].model`. */
|
||||
chip?: string;
|
||||
/** `hw.model` ('Mac16,10'). */
|
||||
model?: string;
|
||||
/** `kern.osproductversion` ('27.2'). */
|
||||
macosVersion?: string;
|
||||
/** The leading number of `macosVersion` (27). */
|
||||
macosMajor?: number;
|
||||
/** `iogpu.wired_limit_mb` exactly as reported. 0 means "not set" (both test Macs report 0); only a value above 0 is a limit. */
|
||||
wiredLimitMb?: number;
|
||||
/** Free bytes on the filesystem of Codeman's data dir (`statfs`: `bavail * bsize`). */
|
||||
freeDiskBytes?: number;
|
||||
/** `freeDiskBytes` as a person reads it (decimal GB, no unit). Present exactly when `freeDiskBytes` is. */
|
||||
freeDiskGb?: string;
|
||||
source: HardwareProbeSource;
|
||||
}
|
||||
|
||||
// ---------- Catalogue ----------
|
||||
|
||||
/**
|
||||
* One Mac a model and harness pair was really run on by us. A candidate that matches an entry
|
||||
* (the same chip string, the same memory size as sold, the same harness) is `tested`: it ranks
|
||||
* before every candidate that is not, and as the pick it may be called "Recommended".
|
||||
*/
|
||||
export interface TestedOn {
|
||||
/** The chip, as `machdep.cpu.brand_string` reports it ('Apple M4'). */
|
||||
chip: string;
|
||||
/** The Mac's memory as it is sold (`HardwareFacts.memClassGb`: 16, 32, 48). */
|
||||
memGb: number;
|
||||
/** The harness it was tested with: a CLI registry id, as data. */
|
||||
cli: string;
|
||||
/** ISO date of the test ('2026-11-06'). */
|
||||
date: string;
|
||||
/**
|
||||
* The generation speed measured in that test, in tokens per second (67.7 for the
|
||||
* 2026-11-06 run on an Apple M5 Pro with 48 GB). A candidate that matches this entry reports
|
||||
* this figure as `Candidate.tokensPerSecond` in place of the estimate from the chip's memory
|
||||
* bandwidth: a measurement on the same Mac beats a formula.
|
||||
*/
|
||||
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`). */
|
||||
id: string;
|
||||
/** The name a person reads ('Qwen3.8 27B'). */
|
||||
label: string;
|
||||
/** SPDX id of the license ('Apache-2.0'). */
|
||||
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: 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. 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, 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 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 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. */
|
||||
contexts: number[];
|
||||
/** Preference among models that fit: the HIGHER rank wins. A judgement, not a measurement. */
|
||||
rank: number;
|
||||
/** Measured tokens per second per 100 GB/s of memory bandwidth. Absent: the speed of this model is unknown. */
|
||||
speedPer100Gbps?: number;
|
||||
/** Macs this model was really run on. Empty for every row but one. */
|
||||
tested: TestedOn[];
|
||||
}
|
||||
|
||||
/** One harness (coding agent CLI) that can be connected to a local model. All of it is data. */
|
||||
export interface LocalHarnessEntry {
|
||||
/** A CLI registry id. Data only: never compared with a literal anywhere. */
|
||||
cli: string;
|
||||
/** The smallest context (tokens) at which this harness works. */
|
||||
minContext: number;
|
||||
/** Preference among harnesses: the HIGHER rank wins. */
|
||||
rank: number;
|
||||
/** True: listed in the catalogue but never offered yet (its use with a local model is unverified). */
|
||||
gated?: boolean;
|
||||
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
|
||||
slowFirstTurn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory bandwidth of one chip family. `LocalModelCatalog.chips` is ordered most specific first
|
||||
* ('M4 Max' before 'M4') and the first row that names `HardwareFacts.chip` wins. A row names a
|
||||
* chip when the chip string ENDS with its `match`, starting at the beginning or after a space,
|
||||
* case ignored (`chipNameMatches()` in src/config/local-model-catalog.ts): 'M4' is not the chip
|
||||
* of 'Apple M4 Pro', and a variant the table has never heard of ('Apple M6 Pro') matches no row.
|
||||
* A chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed.
|
||||
*/
|
||||
export interface ChipEntry {
|
||||
/** The chip name the chip string must end with ('M4 Max'). */
|
||||
match: string;
|
||||
/** Memory bandwidth in GB/s. */
|
||||
gbps: number;
|
||||
}
|
||||
|
||||
/** The catalogue: a compiled constant, validated by `LocalModelCatalogSchema`. No override file, no download data. */
|
||||
export interface LocalModelCatalog {
|
||||
schema: 1;
|
||||
/** ISO date the rows were last checked ('2026-11-06'). Reported as `LocalModelStatus.catalogUpdated`. */
|
||||
updated: string;
|
||||
/** Oldest macOS major version a local model runs on. Below it: verdict `none`, reason `macos`. */
|
||||
minMacosMajor: number;
|
||||
models: LocalModelEntry[];
|
||||
harnesses: LocalHarnessEntry[];
|
||||
chips: ChipEntry[];
|
||||
}
|
||||
|
||||
// ---------- Harness facts (server only) ----------
|
||||
|
||||
/**
|
||||
* What the server knows about one catalogue harness on this machine. Built from the CLI registry
|
||||
* at call time, so `recommend()` names no CLI. NOT serializable (it holds a function): it never
|
||||
* leaves the server. `Candidate` carries the parts a browser needs.
|
||||
*/
|
||||
export interface HarnessFact {
|
||||
/** The CLI registry id. */
|
||||
cli: string;
|
||||
/** The registry's display label ('OpenCode'). */
|
||||
label: string;
|
||||
/** The CLI's binary was found on this machine. */
|
||||
installed: boolean;
|
||||
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
|
||||
installable: boolean;
|
||||
/**
|
||||
* The literal install command the install job would run for this CLI: the STOCK
|
||||
* command of the CLI registry for this platform, never one a `clis.json` override supplies.
|
||||
* Present exactly when `installable` is true. The consent screen prints it
|
||||
* (`CandidateHarness.installCommand`).
|
||||
*/
|
||||
installCommand?: string;
|
||||
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
|
||||
acceptsModelId: (modelId: string) => boolean;
|
||||
}
|
||||
|
||||
// ---------- Recommendation ----------
|
||||
|
||||
/**
|
||||
* Need over budget: up to 0.85 is `good`, up to 0.98 is `tight`, above is `out`. The need is
|
||||
* the PEAK, so the grade says how much room is left at the worst moment. It decides whether a
|
||||
* model is offered at all (`out` never is) and what its row says (`tight`). It does not sort
|
||||
* the list.
|
||||
*/
|
||||
export type FitGrade = 'good' | 'tight' | 'out';
|
||||
|
||||
/**
|
||||
* Expected generation speed. `unknown` when the chip is in no `ChipEntry` or the model has no
|
||||
* `speedPer100Gbps`. In the ranking `unknown` sits between `ok` and `slow`.
|
||||
*/
|
||||
export type SpeedGrade = 'ok' | 'slow' | 'unknown';
|
||||
|
||||
/**
|
||||
* Why the verdict is `none`.
|
||||
* - `not-macos`: the server does not run on macOS (the card is hidden).
|
||||
* - `intel`: a Mac without Apple silicon.
|
||||
* - `memory`: no model fits the memory budget at a context a usable harness needs. Three
|
||||
* headlines share this reason: `noneMemory` (nothing fits at any context), `noneMemoryContext`
|
||||
* (a model fits, but only below the context a harness needs: the sentence carries the budget
|
||||
* and the need) and `noneMemoryUnknown` (the memory size could not be read).
|
||||
* - `disk`: models fit the memory, but for every one of them free disk minus the download is below 10 GiB.
|
||||
* - `macos`: macOS is older than `LocalModelCatalog.minMacosMajor`.
|
||||
* - `no-harness`: no catalogue harness is installed or installable and accepts a model.
|
||||
*/
|
||||
export type NoneReason = 'not-macos' | 'intel' | 'memory' | 'disk' | 'macos' | 'no-harness';
|
||||
|
||||
/**
|
||||
* Where the device budget came from.
|
||||
* - `estimated`: a formula over the installed memory. All there is before the runtime ran.
|
||||
* - `wired-limit`: `iogpu.wired_limit_mb` above 0.
|
||||
* - `measured`: the device total the runtime itself printed when it started
|
||||
* (`RecommendOptions.measuredTotalBytes`). The chooser says "estimated" next to the memory
|
||||
* bar for the first value only.
|
||||
*/
|
||||
export type BudgetSource = 'estimated' | 'wired-limit' | 'measured';
|
||||
|
||||
/** The memory a model may use on this Mac. */
|
||||
export interface DeviceBudget {
|
||||
/**
|
||||
* L: the measured device total when there is one, else the wired limit when above 0, else
|
||||
* 2/3 of memory below 36 GB and 3/4 from 36 GB.
|
||||
*/
|
||||
limitBytes: number;
|
||||
/**
|
||||
* What `Candidate.budgetShare` is measured against. Estimated or from the wired limit:
|
||||
* B = min(L - 1 GiB, memory - reserve). Measured: B = min(L, memory - reserve), without the
|
||||
* 1 GiB, because the runtime's figure is already what it will really use (37.4 GiB on the
|
||||
* 48 GB test Mac, which reads '40.2').
|
||||
*/
|
||||
budgetBytes: number;
|
||||
source: BudgetSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the caller of `recommend()` knows beyond the hardware probe. Both members are optional:
|
||||
* an empty object means the budget is the estimate and an install downloads the model alone.
|
||||
*/
|
||||
export interface RecommendOptions {
|
||||
/**
|
||||
* The device total the runtime reported in its log line "inference compute"
|
||||
* (`total="37.4 GiB"`), in bytes: `parseInferenceComputeTotal()` in
|
||||
* src/local-model/runtime.ts. Present: the budget is `measured`. The install job passes it
|
||||
* right after the runtime started, before the model is downloaded, and the GET passes the
|
||||
* stored figure from then on.
|
||||
*/
|
||||
measuredTotalBytes?: number;
|
||||
/**
|
||||
* What an install adds to every candidate's download beside the model: the size of the
|
||||
* pinned runtime archive while the runtime is not installed, 0 once it is. It is the
|
||||
* difference between `Candidate.installBytes` and `Candidate.downloadBytes`. Default 0.
|
||||
*/
|
||||
runtimeDownloadBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sentence and label the server may put into the payload, in English (the i18n source
|
||||
* language). The zh-CN dictionary in src/web/public/i18n.js has one entry per value, keyed by
|
||||
* this exact text, and test/local-model-i18n.test.ts imports this constant to list them. Adding
|
||||
* a key is fine; changing a text means changing its zh-CN key too.
|
||||
*
|
||||
* `{placeholder}` slots are filled by the browser from `LocalModelMessage.vars`. `{name}` is
|
||||
* taken (the translator fills it with the display name), so no slot here is called `name`.
|
||||
*
|
||||
* | Key | Slots (all text; sizes are decimal GB without the unit) |
|
||||
* |---------------------|---------------------------------------------------------------------------|
|
||||
* | `noteDiskDropped` | `model`: label of the best model left out for disk space; `missing`: its |
|
||||
* | | download + the disk reserve - free |
|
||||
* | `noneMemory` | `memory`: `HardwareFacts.memClassGb`, the size the Mac is sold as |
|
||||
* | `noneMemoryContext` | `budget`: `Recommendation.budgetGb`; `need`: the smallest need of any |
|
||||
* | | model at a context a usable harness works at |
|
||||
* | `noneDisk` | `download`: smallest download among the models that fit the memory; |
|
||||
* | | `reserve`: what must stay free after it (10 GiB, as decimal GB); `free`: |
|
||||
* | | `HardwareFacts.freeDiskGb`; `missing`: download + reserve - free |
|
||||
* | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue minimum |
|
||||
*
|
||||
* The word "Recommended" exists only in `headlineTested`, which may be used only when the
|
||||
* pick's `tested` is true: one catalogue row has a tested entry (`qwen3.6:35b-a3b` on an Apple
|
||||
* M5 Pro with 48 GB), so one kind of Mac is told so.
|
||||
*/
|
||||
export const LOCAL_MODEL_LABELS = {
|
||||
/** Verdict `ok`, pick not tested by us on a Mac like this one. */
|
||||
headlineOk: 'Best fit for this Mac',
|
||||
/** Verdict `ok`, pick tested by us on a Mac like this one. */
|
||||
headlineTested: 'Recommended for this Mac',
|
||||
/** Verdict `limited`: no model with 12 billion parameters or more fits this Mac. */
|
||||
headlineLimited:
|
||||
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.',
|
||||
/** A note under an `ok` or `limited` pick whose `tested` is false. */
|
||||
noteUntested: 'Codeman has not run this on a Mac like this yet.',
|
||||
/** A note under an `ok` pick that is itself a small model: larger ones fit this Mac, but rank lower or have no room on the disk. */
|
||||
noteSmallPick:
|
||||
'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.',
|
||||
/** A note when a model that fits the memory was left out because its download would not leave the disk reserve. */
|
||||
noteDiskDropped:
|
||||
'{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.',
|
||||
fitGood: 'Good',
|
||||
fitTight: 'Tight',
|
||||
speedSlow: 'Slow',
|
||||
speedUnknown: 'Speed unknown',
|
||||
noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.',
|
||||
noneIntel: 'Local coding models need Apple silicon, and this Mac has an Intel processor.',
|
||||
/** Reason `memory`: nothing fits at any context. */
|
||||
noneMemory: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.',
|
||||
/** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and ("about") that they are estimates. */
|
||||
noneMemoryContext:
|
||||
'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.',
|
||||
/** Reason `memory`: the memory size could not be read, so no size is stated. */
|
||||
noneMemoryUnknown: 'Codeman could not read how much memory this Mac has, so it recommends no model here.',
|
||||
/** Reason `disk`. One number: what to do. The other sizes travel in `vars` for a client that wants them. */
|
||||
noneDisk: 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.',
|
||||
noneMacos: 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.',
|
||||
noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.',
|
||||
} as const;
|
||||
|
||||
/** One of the texts in `LOCAL_MODEL_LABELS`. The payload's label fields are typed with it, so an off-list string does not compile. */
|
||||
export type LocalModelLabel = (typeof LOCAL_MODEL_LABELS)[keyof typeof LOCAL_MODEL_LABELS];
|
||||
|
||||
/** The headline of each `none` reason. `memory` has two more (`noneMemoryContext`, `noneMemoryUnknown`), see `NoneReason`. */
|
||||
export const LOCAL_MODEL_NONE_LABELS: Record<NoneReason, LocalModelLabel> = {
|
||||
'not-macos': LOCAL_MODEL_LABELS.noneNotMacos,
|
||||
intel: LOCAL_MODEL_LABELS.noneIntel,
|
||||
memory: LOCAL_MODEL_LABELS.noneMemory,
|
||||
disk: LOCAL_MODEL_LABELS.noneDisk,
|
||||
macos: LOCAL_MODEL_LABELS.noneMacos,
|
||||
'no-harness': LOCAL_MODEL_LABELS.noneNoHarness,
|
||||
};
|
||||
|
||||
/** A sentence for the card: a text from `LOCAL_MODEL_LABELS` plus the values of its `{placeholder}` slots. */
|
||||
export interface LocalModelMessage {
|
||||
text: LocalModelLabel;
|
||||
/** Slot values, already formatted as text. Absent when the text has no slot. */
|
||||
vars?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one tag a model row of the chooser carries, by what it says.
|
||||
* - `tested`: `Candidate.tested` is true. Replaces every other tag.
|
||||
* - `best-tight`: the pick, when its `fit` is `tight`. The words must not say less than the
|
||||
* memory bar beside them, which is nearly full for such a row.
|
||||
* - `best`: the pick.
|
||||
* - `tight`: `Candidate.fit` is `tight`.
|
||||
* - `slow`: `Candidate.speed` is `slow`.
|
||||
* - `small`: the model has fewer than 12 billion parameters.
|
||||
* A row gets the FIRST kind of this list that applies and no tag when none does. `tested`,
|
||||
* `best` and `best-tight` are the tags of the row the window opens on, drawn in the accent
|
||||
* colour.
|
||||
*/
|
||||
export type CandidateTagKind = 'tested' | 'best-tight' | 'best' | 'tight' | 'slow' | 'small';
|
||||
|
||||
/**
|
||||
* The text of each tag, in English (the i18n source language), like `LOCAL_MODEL_LABELS`. A
|
||||
* constant of its own so that a consumer can list the tag texts apart from the sentences.
|
||||
*/
|
||||
export const LOCAL_MODEL_TAG_LABELS = {
|
||||
tested: 'Tested on a Mac like this',
|
||||
'best-tight': 'Best fit, tight',
|
||||
best: 'Best fit',
|
||||
tight: 'Tight',
|
||||
slow: 'Slow',
|
||||
small: 'Small model',
|
||||
} as const satisfies Record<CandidateTagKind, string>;
|
||||
|
||||
/** One of the texts in `LOCAL_MODEL_TAG_LABELS`. */
|
||||
export type LocalModelTagLabel = (typeof LOCAL_MODEL_TAG_LABELS)[CandidateTagKind];
|
||||
|
||||
/** The tag of a model row: what kind it is (for the styling) and the text to print. */
|
||||
export interface CandidateTag {
|
||||
kind: CandidateTagKind;
|
||||
/** `LOCAL_MODEL_TAG_LABELS[kind]`. */
|
||||
label: LocalModelTagLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* One coding agent a candidate's model can be run with on this Mac, for the "Runs
|
||||
* with" select of the chooser. Everything a browser needs is here, so it compares no CLI id.
|
||||
*/
|
||||
export interface CandidateHarness {
|
||||
/** The CLI registry id, as data: what `POST /api/local-model/install` is sent as `cli`. */
|
||||
cli: string;
|
||||
/** The registry's display label ('OpenCode'). */
|
||||
cliLabel: string;
|
||||
/** False: the install job installs it first (it is installable, or it would not be listed). */
|
||||
installed: boolean;
|
||||
/**
|
||||
* The literal command the install job would run to install it (`HarnessFact.installCommand`).
|
||||
* Present exactly when `installed` is false. The consent screen prints it as it is.
|
||||
*/
|
||||
installCommand?: string;
|
||||
/** The harness row's `slowFirstTurn`. */
|
||||
slowFirstTurn?: boolean;
|
||||
}
|
||||
|
||||
/** One model at one context with one harness, measured against this Mac. Plain JSON. */
|
||||
export interface Candidate {
|
||||
/** `LocalModelEntry.id`. */
|
||||
modelId: string;
|
||||
/** `LocalModelEntry.label`. */
|
||||
modelLabel: string;
|
||||
license: string;
|
||||
paramsB: number;
|
||||
/** Context length in tokens. */
|
||||
context: number;
|
||||
/** The harness: a CLI registry id, as data. */
|
||||
cli: string;
|
||||
/** The harness's display label, from the CLI registry. */
|
||||
cliLabel: string;
|
||||
/** False: the harness is not on this machine yet (it is installable, or it would not be a candidate). */
|
||||
cliInstalled: boolean;
|
||||
/** The harness row's `slowFirstTurn`. */
|
||||
slowFirstTurn?: boolean;
|
||||
/**
|
||||
* What the model needs in memory at this context: the PEAK, `bytes + heldBytesPerToken *
|
||||
* context + overheadBytes + peakExtraBytes` of the catalogue row. The memory bar of the
|
||||
* chooser is filled to `budgetShare`, which is this over the budget.
|
||||
*/
|
||||
needBytes: number;
|
||||
/** `needBytes` as decimal GB text, no unit. */
|
||||
needGb: string;
|
||||
/**
|
||||
* What `needBytes` is made of, for the chooser's memory map: the model (`bytes +
|
||||
* overheadBytes`), its context in use (`heldBytesPerToken * context`) and the brief peaks
|
||||
* (`peakExtraBytes`). The three add up to `needBytes`.
|
||||
*/
|
||||
memory?: { modelBytes: number; contextBytes: number; peakBytes: number };
|
||||
/** The model download in bytes (`LocalModelEntry.bytes`). */
|
||||
downloadBytes: number;
|
||||
/** `downloadBytes` as decimal GB text, no unit. */
|
||||
downloadGb: string;
|
||||
/** `needBytes / Recommendation.budgetBytes`, rounded to three decimals (0.83). The fit grade is decided on the unrounded ratio. */
|
||||
budgetShare: number;
|
||||
/** A candidate in a `Recommendation` is never `out`. */
|
||||
fit: Exclude<FitGrade, 'out'>;
|
||||
/** `fitGood` or `fitTight`. */
|
||||
fitLabel: LocalModelLabel;
|
||||
speed: SpeedGrade;
|
||||
/** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */
|
||||
speedLabel?: LocalModelLabel;
|
||||
/**
|
||||
* Generation speed in tokens per second, rounded to one decimal. Present exactly when `speed`
|
||||
* is not `unknown`; `speed` is decided on the unrounded figure. An estimate from the chip's
|
||||
* memory bandwidth, except for a candidate that is `tested`, which reports the
|
||||
* speed measured in that test (`TestedOn.tokensPerSecond`).
|
||||
*/
|
||||
tokensPerSecond?: number;
|
||||
/**
|
||||
* A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this
|
||||
* harness. Such a candidate ranks first, and its `tag` says so in place of any other tag.
|
||||
*/
|
||||
tested: boolean;
|
||||
/** `LocalModelEntry.figures` of the model: whether `needBytes` rests on a measurement of this model. */
|
||||
needSource: 'measured' | 'estimated';
|
||||
/** The row's one tag, see `CandidateTagKind`. Absent when no kind applies. */
|
||||
tag?: CandidateTag;
|
||||
/**
|
||||
* Every coding agent this model can be run with at `context` on this Mac, best
|
||||
* first. The first entry is the one the flat fields above name (`cli`, `cliLabel`,
|
||||
* `cliInstalled`, `slowFirstTurn`). Never empty.
|
||||
*/
|
||||
harnesses: CandidateHarness[];
|
||||
/**
|
||||
* The whole download a click on Install starts for this row: `downloadBytes` plus
|
||||
* `RecommendOptions.runtimeDownloadBytes`. It is the server's own total, and exactly the
|
||||
* number `POST /api/local-model/install` must be sent as `acknowledgedBytes`.
|
||||
*/
|
||||
installBytes: number;
|
||||
/** `installBytes` as decimal GB text, no unit. */
|
||||
installGb: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer of `recommend()`.
|
||||
* - `ok`: `pick` is the best candidate, and at least one model with 12 billion parameters or
|
||||
* more fits this Mac's memory at a context a usable harness works at. The pick itself can be
|
||||
* smaller (a larger model may fit only tightly, have no speed figure, or have no room on the
|
||||
* disk): `notes` then carries `noteSmallPick`.
|
||||
* - `limited`: `pick` is the best candidate, and NO model with 12 billion parameters or more
|
||||
* fits. The card says so and never says "Recommended".
|
||||
* - `none`: no pick; `reason` says why.
|
||||
*/
|
||||
export interface Recommendation {
|
||||
verdict: 'ok' | 'limited' | 'none';
|
||||
/** Present exactly when `verdict` is `none`. */
|
||||
reason?: NoneReason;
|
||||
/** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or a `none` sentence. */
|
||||
headline: LocalModelMessage;
|
||||
/** Further sentences under the pick, in this order: `noteSmallPick`, `noteDiskDropped`, `noteUntested`. May be empty. */
|
||||
notes: LocalModelMessage[];
|
||||
/** Present exactly when `verdict` is `ok` or `limited`. */
|
||||
pick?: Candidate;
|
||||
/**
|
||||
* The next models that fit and have room on the disk (at most four), best first: tested
|
||||
* before not tested, then ok before unknown before slow speed, then the higher model rank.
|
||||
* Each is at the largest context it fits at, with the best harness that can run it there.
|
||||
* Empty when `verdict` is `none`.
|
||||
*/
|
||||
alternatives: Candidate[];
|
||||
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
|
||||
budgetBytes: number;
|
||||
/** `budgetBytes` as decimal GB text, no unit. */
|
||||
budgetGb: string;
|
||||
budgetSource: BudgetSource;
|
||||
}
|
||||
|
||||
// `LocalModelStatus`, the `data` of `GET /api/local-model`, is in src/types/local-model-install.ts.
|
||||
+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 */
|
||||
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user