Compare commits

..
Author SHA1 Message Date
Codeman maintainer 569eed2b3e docs(wiki): name the app's Add a Coding Agent menu item as the app shows it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:32:44 +01:00
Codeman maintainer 8f5d2d9bb0 docs: scope the Apple logo blink to the MacBook Pros that have it
"A MacBook Pro whose Apple logo lights up" also describes the 2013 to 2015
Retina MacBook Pros, whose lid logo glows from the display backlight and which
run macOS 11 or 12, both of which the app supports. The View menu's Blink
Apple Logo exists only where CoreBrightness answers for the lid logo
(logo::available() in packages/desktop/src/logo.rs), which is a MacBook Pro
with a PROLED XDR display. The README, its Chinese mirror and the wiki now use
packages/desktop/README.md's wording, so the wiki's "the menu only exists on
such a Mac" holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:03:09 +01:00
Codeman maintainer baf9dfa2eb docs: link the Codeman app for Mac from the README and the wiki
The 1.50.0 release attaches Codeman.dmg to every release, so
https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg
always serves the newest app. This puts that link where a Mac user
looks first:

- README.md: one line under the install one-liner, and a short
  "On a Mac: the Codeman app" section in Quick Start, after Docker
  Compose. What the app does, the download, universal (Apple Silicon
  and Intel), macOS 11.3, the WebKit of Safari 16.4 for Codeman's page,
  macOS 15 (or a tmux already there) for the install from the app, and
  how to update it.
- README.zh-CN.md: the same, translated.
- docs/wiki/Installation.md: a matching section with a First launch
  list (the one "downloaded from the internet" question, the Local
  Network question on macOS 15) and the limits that matter on day one.
  Its anchors, #on-a-mac-the-codeman-app and #first-launch, are what
  the website links for first-launch help. Route A points to it.

The limits are copied from packages/desktop/README.md. The app is
signed with a Developer ID and notarized, so no Gatekeeper workaround
and no per-update Keychain prompt is described.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:44:00 +01:00
Codeman maintainer e9b2ecd552 ci(release): turn the GitHub release body into a readable page
changesets/action copies a CHANGELOG.md section into the release as it stands,
so a whole release rendered as one bullet behind its commit hash, with
`### Thanks` as literal text and "Minor Changes" as the only real heading.

The Release workflow now rebuilds the body from that section: Thanks first,
then What's new with a heading per change, Fixes, Also in this release and a
compare link, with "Behaviour change:" and "Breaking:" in bold. The release is
titled `Codeman <x.y.z>`; the tag stays `codeman@<x.y.z>`, which is what the
updater and the update toast read.

This cannot be written into CHANGELOG.md instead: `### <Type> Changes` is
hardcoded upstream, and a `## ` line would end the section for changesets/action
and versionSection(). So the changelog keeps its format and the toast is
unaffected. npm has already published when the step runs, so a formatter failure
leaves the changesets text with a warning and the retag still happens.

SECTION_LABELS is now shared with whats-new.mjs so the toast and the page agree
on what counts as a headline. docs/releasing.md records the changeset shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:11:22 +01:00
Codeman maintainer f66a9a574d ci: publish each release to the MCP registry
The registry does not follow npm, so every release waited on a by-hand
mcp-publisher publish, behind a browser login that expires between
releases.

Release now calls publish-mcp.yml once npm took a release. It signs in
with the workflow's OIDC token, which the registry accepts for the
io.github.Ark0N/ namespace, so it needs no secret and no browser. It skips
a version the registry already has, waits until npm serves a new one, and
can be run by hand with gh workflow run publish-mcp.yml.

The id-token right sits on its own job, which runs nothing from the
repository: it reads server.json and runs one pinned, checksummed binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:43:32 +01:00
Codeman maintainer a651616907 fix(custom-model): keep the launch model out of the stored config
Quick-start wrote a selection's launchModel into the session's stored
<Mode>Config, which is what a clear falls back to, so a clear relaunched
the CLI with the endpoint's model id on its native backend. Only API
callers can reach that, since no page sends a clear.

The first launch now takes the forced model from the session's selection,
through the same _withCustomModelLaunchModel() the respawn path uses, so
the stored config keeps the model chosen at create. That also carries the
model onto a new tmux session for a selection applied to a running
session, which the create path used to lose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:41:32 +01:00
16 changed files with 753 additions and 39 deletions

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
name: Publish to the MCP registry
# The official MCP registry does not follow npm, so every version is published to it.
# Release calls this after npm took a release. To publish the version on master by hand,
# after a failed run for example: gh workflow run publish-mcp.yml
#
# No secret and no browser login: the registry takes this repository's GitHub Actions OIDC
# token as proof of the io.github.Ark0N/ namespace. That id-token right is why this job
# runs nothing from the repository: it reads server.json and runs one pinned binary.
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
# Bump both together, the digest from the release's asset. Never releases/latest.
MCP_PUBLISHER_VERSION: v1.8.7
MCP_PUBLISHER_SHA256: 00b21004149a4ce66f0782508ae4a2bce67d1bea395a8da0bd8028159fb59579
REGISTRY_VERSIONS_URL: https://registry.modelcontextprotocol.io/v0/servers/io.github.Ark0N%2Fcodeman/versions
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
persist-credentials: false
# Also what ends a run for a release that only moved xterm-zerolag-input.
- name: Skip a version the registry already has
id: check
run: |
VERSION=$(jq -r .version config/mcp-registry/server.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$REGISTRY_VERSIONS_URL/$VERSION")
case "$STATUS" in
200) echo "::notice::$VERSION is already in the MCP registry" ;;
404) echo "needed=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::The MCP registry answered $STATUS for $VERSION"; exit 1 ;;
esac
# The registry refuses a version npm does not serve yet, and reads mcpName from it.
- name: Wait until npm serves this version
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: |
EXPECTED=$(jq -r .name config/mcp-registry/server.json)
for _ in $(seq 1 60); do
FOUND=$(npm view "aicodeman@$VERSION" mcpName 2>/dev/null || true)
[ "$FOUND" = "$EXPECTED" ] && exit 0
sleep 20
done
echo "::error::npm did not serve aicodeman@$VERSION with mcpName $EXPECTED within 20 minutes"
exit 1
- name: Download mcp-publisher
if: steps.check.outputs.needed == 'true'
run: |
ARCHIVE="$RUNNER_TEMP/mcp-publisher.tar.gz"
curl -fsSL -o "$ARCHIVE" "https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION/mcp-publisher_linux_amd64.tar.gz"
echo "$MCP_PUBLISHER_SHA256 $ARCHIVE" | sha256sum -c -
tar xzf "$ARCHIVE" -C "$RUNNER_TEMP" mcp-publisher
- name: Publish
if: steps.check.outputs.needed == 'true'
working-directory: config/mcp-registry
run: |
"$RUNNER_TEMP/mcp-publisher" login github-oidc
"$RUNNER_TEMP/mcp-publisher" publish
- name: Confirm the registry lists it as latest
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: |
curl -fsS --retry 4 --retry-all-errors "$REGISTRY_VERSIONS_URL/$VERSION" \
| jq -e '._meta["io.modelcontextprotocol.registry/official"].isLatest == true'
+29 -2
View File
@@ -14,6 +14,8 @@ jobs:
permissions:
contents: write
pull-requests: write
outputs:
published: ${{ steps.changesets.outputs.published }}
steps:
- name: Checkout repo
uses: actions/checkout@v6
@@ -60,15 +62,40 @@ jobs:
# 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package
# releases already exist by the time this step runs, so setting it here
# is deterministic.
#
# The title and body are the page people land on from the update toast.
# changesets copied the body from CHANGELOG.md as one long bullet, so it is
# reshaped here (scripts/lib/release-notes.mjs). npm has already published by
# now, so a failure in that must not stop the retag below: without the body
# field the release keeps the text changesets wrote.
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
BODY_ARGS=()
if node scripts/generate-release-notes.mjs "$VERSION" > "$NOTES_FILE"; then
BODY_ARGS=(-F "body=@$NOTES_FILE")
else
echo "::warning::Keeping the changesets release notes for $VERSION"
fi
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-f tag_name="$NEW_TAG" \
-f name="$NEW_TAG" \
-f make_latest=true
-f name="Codeman ${VERSION}" \
-f make_latest=true \
"${BODY_ARGS[@]}"
fi
# Retag
git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true
git tag -d "$OLD_TAG" 2>/dev/null || true
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
# Its own job: this one alone holds id-token, and a registry failure shows as itself
# without touching the npm publish or the tag above.
mcp-registry:
name: MCP registry
needs: release
if: needs.release.outputs.published == 'true'
permissions:
contents: read
id-token: write
uses: ./.github/workflows/publish-mcp.yml
+17
View File
@@ -43,6 +43,8 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
@@ -97,6 +99,21 @@ bash docker/Start-Codeman.sh
The first run asks three questions (data folder, port, password; Enter takes the default, including a generated password), writes `docker/.env` for you, builds the image and ends on the URL once Codeman answers. The image already includes Claude Code, Codex, Gemini CLI and OpenCode. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. To update, use **App Settings → Updates**, or `git pull` and run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for Unraid, direct Compose commands, storage and networking options.
### On a Mac: the Codeman app
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
- **Quitting the app doesn't stop Codeman.** An install from the app runs Codeman as a LaunchAgent that starts at login, so your agents keep working with the window closed, and your browser and phone reach it as before.
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later installed. On an older WebKit the app says so and does not connect.
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
- **Updating.** The app doesn't update itself yet: download the new `Codeman.dmg` and drag it over the old copy.
First launch, known limits and how to build it yourself: [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app) in the wiki and [packages/desktop/README.md](packages/desktop/README.md).
<details>
<summary><strong>Keep it running in the background</strong></summary>
+17
View File
@@ -45,6 +45,8 @@ codeman web
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
@@ -99,6 +101,21 @@ bash docker/Start-Codeman.sh
详见下文[多用户模式](#多用户模式可选启用)。
### 在 Mac 上:Codeman 应用
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
- **退出应用不会停止 Codeman。** 通过应用安装的 Codeman 以 LaunchAgent 的形式运行,登录时自动启动,所以关掉窗口后智能体照样工作,浏览器和手机也和以前一样能访问它。
- **Codeman 的页面需要 Safari 16.4 的 WebKit。** 应用本身在 macOS 11.3 上就能启动,但 Codeman 需要 macOS 13.3 或更高版本,或者装有 Safari 16.4 或更高版本的 macOS 11 或 12。WebKit 更旧时,应用会说明原因,并且不会连接。
- **在应用里安装需要 macOS 15,或者机器上已有 tmux。** tmux 来自 tmux 项目自己的构建,而这些构建需要 macOS 15。在没有 tmux 的旧 Mac 上,应用会改为提供终端安装方式,由 Homebrew 在终端里安装 tmux(这需要管理员操作一次)。
- **更新。** 应用暂时不会自我更新:下载新的 `Codeman.dmg`,拖过去覆盖旧的那份即可。
首次启动、已知限制以及如何自行构建:见 wiki 的 [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app)(英文)和 [packages/desktop/README.md](packages/desktop/README.md)(英文)。
<details>
<summary><strong>让它在后台一直运行</strong></summary>
+1 -1
View File
@@ -662,7 +662,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, and only as an absolute path (the registry types that name as any env var, so a `clis.json` override can point it at a credential), which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. The re-apply's real-home lookup has the same rule: the route reads `Session.ownEnvValue()` and recovery that clamped record (probed under the bulk cap, since it walks every saved session), never `envOverrideValue()`, so an entry the own home lacks is skipped rather than filled from the server's home. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ The pin looks in `<isolated>/projects`, so `linkSharedProjectsDir()` links it from the session's own `CLAUDE_CONFIG_DIR` when it has one (`guardSessionConfigDirHome()` covers `configDirVar` too): the server's tree would hide the conversation on apply and leave the endpoint's transcript behind on clear. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model, `homeEnvVar` and the session's own home path; never an injected value, which carries the API key and is re-derived from the endpoint store on recovery, and never any other own value), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, and only as an absolute path (the registry types that name as any env var, so a `clis.json` override can point it at a credential), which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. The re-apply's real-home lookup has the same rule: the route reads `Session.ownEnvValue()` and recovery that clamped record (probed under the bulk cap, since it walks every saved session), never `envOverrideValue()`, so an entry the own home lacks is skipped rather than filled from the server's home. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ The pin looks in `<isolated>/projects`, so `linkSharedProjectsDir()` links it from the session's own `CLAUDE_CONFIG_DIR` when it has one (`guardSessionConfigDirHome()` covers `configDirVar` too): the server's tree would hide the conversation on apply and leave the endpoint's transcript behind on clear. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the create and respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id and never onto the stored `<Mode>Config` (that config is what a clear falls back to; the quick-start route used to write the launch model there, so a clear relaunched the endpoint's id on the native backend), and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model, `homeEnvVar` and the session's own home path; never an injected value, which carries the API key and is re-derived from the endpoint store on recovery, and never any other own value), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
+5
View File
@@ -543,6 +543,11 @@ selection goes, on a clear or a switch to another endpoint. That is what
keeps a session on its own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME`, and on
its own account through `CLAUDE_CONFIG_DIR` (the per-client-account case).
The launch model goes the same way: a clear relaunches on the model the session
was created with, or with none if it was created without one. A session an
older build launched straight onto an endpoint saved that endpoint's model in
its config, so its clear still relaunches on that id.
⚠️ Across a Codeman restart only the home directory comes back, and only as
an absolute path. The selection overwrote the tmux `setenv` that held it, so
its record in `state.json` keeps that one path (`ownEnv`), and a non-granted
+20 -2
View File
@@ -19,14 +19,32 @@ When user says "COM":
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
**Write it in the shape the release page is built from.** `CHANGELOG.md` keeps changesets' format, and the `Release` workflow rebuilds the GitHub release from that section (`scripts/generate-release-notes.mjs`, which also titles it `Codeman <x.y.z>`). The in-app update toast reads the same bold lead-ins:
```markdown
### Thanks
- @someone for what they did (#123).
**What the change does for the user (#123).** Two or three plain sentences.
**Fixes.**
- **What works now (#124).** What was wrong. Behaviour change: what a user has to do differently.
**Fixes applied while landing.** One sentence each.
**Docs and tests.** One sentence.
```
`### Thanks` stays first and opens the page. A bold lead-in that ends in punctuation becomes a heading under What's new, so make it a short headline. `**Fixes.**` and its list become a Fixes section, and the last two labels share an Also in this release section (the labels are `SECTION_LABELS` in `scripts/lib/whats-new.mjs`). `Behaviour change:` and `Breaking:` are put in bold wherever they appear, so use those words for anything a user has to act on. Keep each change short: how it works belongs in the docs, and a long paragraph is a wall of text even under a heading. Text the script does not recognise stays on the page as plain paragraphs. After step 3, `node scripts/generate-release-notes.mjs` prints the page. If the workflow warns that it kept the changesets notes, fix the changelog and run `gh release edit codeman@<x.y.z> --notes-file <file>`.
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast)
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
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).
8. **Publish the new version to the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. Maintainer only (it needs that GitHub account); contributor clones skip it and say so.
8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them.
9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
+69 -1
View File
@@ -25,7 +25,8 @@ curl -fsSL https://getcodeman.com/install | bash
This installs Node.js, tmux and a build toolchain if they are missing (node-pty ships no
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
builds it.
builds it. On a Mac, the [Codeman app](#on-a-mac-the-codeman-app) can run this installer
for you, with no Terminal.
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
an existing install), then asks everything it needs up front, then does the work
@@ -216,6 +217,73 @@ image, the refreshed volumes and the entrypoint arrive together. The full guide,
Unraid, storage and networking options, is
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
## On a Mac: the Codeman app
[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg):
one universal app for Apple Silicon and Intel, macOS 11.3 or later.
A small native app that opens Codeman in its own window. Its first screen has two tabs:
- **This Mac** looks for Codeman here and offers what fits: *Open Codeman* when it runs,
*Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is
not there. It also lists the Codeman servers of yours that already run on this Mac (one
started by hand in a terminal, say) and offers to connect to those; it never starts a
second one next to them. Install runs [the installer](#route-a-the-installer-recommended)
for you, with no Terminal and no administrator password: it asks which coding agent to
install (only when the Mac has none, and *Skip for now* is there) and who may reach
Codeman (this Mac only, or the local network with a password), then shows the install as
a checklist. *Add a coding agent* on this tab (*Add a Coding Agent to This Mac…* in the app menu) installs one later.
- **Another machine** connects to Codeman by address, a tailnet name included, and keeps
the password in the Keychain. *Codeman is not installed there yet?* opens Terminal and
runs the installer on that machine over SSH.
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
your own for working, for waiting for you and for needing permission. All three are off
until you choose, and the menu only exists on such a Mac.
### First launch
1. Open `Codeman.dmg` and drag Codeman into Applications.
2. Open Codeman from Applications. macOS asks once whether to open an app downloaded from
the internet: click **Open**.
3. On macOS 15 and later, the first connection to an address on your local network (a LAN
IP, a `.local` name) makes macOS ask whether Codeman may use the local network, and that
first attempt can fail while the question is open: connect again after answering. If
you denied it, turn Codeman on in **System Settings > Privacy & Security > Local
Network**.
What to know about it:
- **Quitting the app does not stop Codeman.** An install from the app runs Codeman as a
LaunchAgent that starts at login, so agents keep working with the window closed, and a
browser or phone reaches it as before.
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but
Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later
installed. On an older WebKit the app says so and does not connect.
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes
from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the
app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
- **The install from the app reaches as far as the local network.** Tailscale needs its own
login and setup, which the installer does at a terminal: choose *Install in Terminal
instead*, or run `install.sh tailscale` afterwards.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a
self-signed certificate, which macOS's WebKit rejects. Use `http://127.0.0.1:3000` for a
server on this Mac. For another machine, serve Codeman through `tailscale serve`
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse proxy with a
real certificate.
- **The Codeman it installs is an ordinary installer install,** made on the
[route without administrator rights](#without-administrator-rights), so
[Updating](#updating) and [Uninstalling](#uninstalling) below apply to it.
- **The app does not update itself yet.** Download the new `Codeman.dmg` and drag it over
the old copy.
- **Deleting the app leaves Codeman running.** The app's own settings are in
`~/Library/Application Support/Codeman/`, and its saved passwords in the Keychain under
*Codeman Desktop*.
Everything else, including how to build it yourself:
[`packages/desktop/README.md`](https://github.com/Ark0N/Codeman/blob/master/packages/desktop/README.md).
## Installing an agent CLI
Codeman drives CLIs, it does not bundle them. Install at least one:
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// Prints the GitHub release notes for one version, built from its section of CHANGELOG.md.
// The Release workflow sends this to the release it just published. Run it by hand to preview:
//
// node scripts/generate-release-notes.mjs [version] default: the version in package.json
//
// Exits 1 with nothing on stdout when the changelog has no section for that version, so the
// workflow keeps the text changesets wrote rather than blanking a published release.
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildReleaseNotes } from './lib/release-notes.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const version = process.argv[2] ?? JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version;
const notes = buildReleaseNotes(readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8'), version);
if (!notes) {
console.error(`release-notes: CHANGELOG.md has no notes for ${version}`);
process.exit(1);
}
process.stdout.write(notes);
+144
View File
@@ -0,0 +1,144 @@
/**
* Turns one version's section of CHANGELOG.md into the body of its GitHub release.
*
* changesets writes a changeset as one list item, `- <hash>: <first line>`, with the rest
* indented under it, and changesets/action copies that section into the release as it stands.
* So a whole release rendered as a single bullet, and the `### Thanks` that opens a changeset
* sat behind the hash as literal text. The shape below cannot be written into CHANGELOG.md
* instead: `### <Type> Changes` is hardcoded upstream, and a `## ` line would end the section
* for both changesets/action and versionSection(). So the changelog keeps changesets' format,
* and the Release workflow applies this to what comes out of it.
*
* Only the commit prefix and the `### <Type> Changes` lines are removed. A block this does not
* recognise stays as text; test/release-notes.test.ts checks that over every past release.
*/
import { SECTION_LABELS, cleanLead, versionSection } from './whats-new.mjs';
const REPO_URL = 'https://github.com/Ark0N/Codeman';
const TYPE_HEADING = /^### (?:Major|Minor|Patch) Changes\s*$/;
const ITEM_START = /^- (?:[0-9a-f]{7,40}: )?(.*)$/;
const HEADING = /^#{1,6}\s+(.+?)\s*$/;
const BOLD_START = /^\*\*(.+?)\*\*(?:\s+(.*))?$/;
const LIST_ITEM = /^\s*[-*] /;
const VERSION_HEADING = /^## (\d+\.\d+\.\d+\S*)\s*$/;
const BEHAVIOUR_CHANGE = /(?<![*\w])(Behaviou?r changes?|Breaking):/g;
/** The section's changesets, each as its own lines with the prefix and indent removed. */
function changesets(section) {
const items = [];
for (const line of section.split('\n')) {
if (TYPE_HEADING.test(line)) continue;
const start = ITEM_START.exec(line);
if (start) items.push([start[1]]);
else if (items.length > 0) items[items.length - 1].push(line.replace(/^ {2}/, ''));
else if (line.trim()) items.push([line]);
}
return items;
}
/** Splits lines on blank ones, keeping a fenced code block whole. */
function splitBlocks(lines) {
const blocks = [];
let current = [];
let inFence = false;
for (const line of lines) {
if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence;
if (inFence || line.trim()) {
current.push(line);
} else if (current.length > 0) {
blocks.push(current);
current = [];
}
}
if (current.length > 0) blocks.push(current);
return blocks;
}
/**
* The title a block opens with, or null. Bold counts only as a lead-in when it ends in
* punctuation or stands alone: "**Allow network access** is a new setting" is a sentence.
*/
function blockTitle(firstLine) {
const heading = HEADING.exec(firstLine);
if (heading) return { title: heading[1], text: '', isHeading: true };
const bold = BOLD_START.exec(firstLine);
if (bold && (!bold[2] || /[.:?!]$/.test(bold[1]))) return { title: bold[1], text: bold[2] ?? '', isHeading: false };
return null;
}
/** Which part of the page a group label heads, or null for a change's own headline. */
function groupFor(label) {
if (!SECTION_LABELS.test(label)) return null;
if (/^thanks\b/i.test(label)) return 'thanks';
if (/^fixes$/i.test(label)) return 'fixes';
return /^new features$/i.test(label) ? null : 'also';
}
/** Sorts every block of the section into the part of the page it belongs to. */
function sortBlocks(section) {
const parts = { thanks: [], intro: [], news: [], fixes: [], also: [] };
const items = changesets(section);
for (const item of items) {
const blocks = splitBlocks(item);
// Text with no headline leads What's new, or it would read as part of the last feature.
let target = 'intro';
let thanksIsEmpty = false;
for (const block of blocks) {
const [first, ...rest] = block;
const opened = blockTitle(first);
if (opened) {
const label = cleanLead(opened.title);
const group = groupFor(label);
const body = [opened.text, ...rest].filter(Boolean).join('\n');
target = group ?? 'news';
thanksIsEmpty = group === 'thanks' && !body;
if (group === 'also') {
// These two are a sentence or two each, so they keep their bold lead-in: a heading
// over every one of them weighs more than the text under it.
parts.also.push(opened.isHeading ? [`**${label}.**`, ...rest].join('\n') : block.join('\n'));
} else if (group) {
if (body) parts[group].push(body);
} else {
parts.news.push(`### ${label}`);
if (body) parts.news.push(body);
}
continue;
}
// Only credit stays under Thanks: what opens it, its list, and a line ending in a colon
// that leads into one. A paragraph after that is the release talking again.
if (target === 'thanks' && !thanksIsEmpty && !LIST_ITEM.test(first) && !first.endsWith(':')) target = 'intro';
thanksIsEmpty = false;
const text = block.join('\n');
// A one-line changeset was a bullet in the changelog; beside others it stays one.
parts[target].push(blocks.length === 1 && items.length > 1 ? `- ${text}` : text);
}
}
return parts;
}
/** The release before `version`, or null for the first one or a version not in the changelog. */
export function previousVersion(changelog, version) {
const versions = changelog.split(/\r?\n/).flatMap((line) => VERSION_HEADING.exec(line)?.[1] ?? []);
const index = versions.indexOf(version);
return index === -1 ? null : (versions[index + 1] ?? null);
}
/** The markdown for the release page, or '' when the changelog has nothing for `version`. */
export function buildReleaseNotes(changelog, version) {
const { thanks, intro, news, fixes, also } = sortBlocks(versionSection(changelog, version));
const page = [];
// Thanks stays first on purpose: contributors are the first thing a visitor to Latest sees.
if (thanks.length > 0) page.push('## Thanks', ...thanks);
if (intro.length + news.length > 0) page.push("## What's new", ...intro, ...news);
if (fixes.length > 0) page.push('## Fixes', ...fixes);
if (also.length > 0) page.push('## Also in this release', ...also);
if (page.length === 0) return '';
const blocks = page.map((block) => (block.startsWith('#') ? block : block.replace(BEHAVIOUR_CHANGE, '**$1:**')));
const previous = previousVersion(changelog, version);
if (previous) {
blocks.push('---', `**Full Changelog**: ${REPO_URL}/compare/codeman@${previous}...codeman@${version}`);
}
return `${blocks.join('\n\n')}\n`;
}
+6 -2
View File
@@ -19,9 +19,13 @@ export function versionSection(changelog, version) {
// Bold phrases the release notes use as group labels rather than as the headline of a change:
// "**Fixes.**" heads the list of fixes, "**Fixes applied while landing.**" the review fixes.
const SECTION_LABELS =
// Shared with release-notes.mjs, so the toast and the release page agree on what is a headline.
export const SECTION_LABELS =
/^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i;
/** A bold lead-in without its closing punctuation. */
export const cleanLead = (text) => text.replace(/[\s.:]+$/, '').trim();
/** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */
export function extractHighlights(section) {
const out = [];
@@ -30,7 +34,7 @@ export function extractHighlights(section) {
// ("- d18deed: **Lead.** text"), which is the shape every changesets-bot release has.
const m = /^\s*(?:[-*]\s+)?(?:[0-9a-f]{7,40}:\s+)?\*\*([^*]{3,120})\*\*/.exec(line);
if (!m) continue;
const text = m[1].replace(/[\s.:]+$/, '').trim();
const text = cleanLead(m[1]);
if (!text || SECTION_LABELS.test(text)) continue;
if (!out.includes(text)) out.push(text);
}
+8 -8
View File
@@ -2990,13 +2990,13 @@ export class Session extends EventEmitter {
* launch param. Where that param
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
* ...) or the top-level `model` for an entry that declares none — so this stays a
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
* `<Mode>Config` keeps whatever model the user chose at create, which is exactly what a
* later clear must fall back to.
* generic reader rather than a branch per CLI. Applied on the OPTIONS only, on both the
* respawn and the create path: the stored `<Mode>Config` keeps whatever model the user
* chose at create, which is exactly what a later clear must fall back to.
*/
private _withCustomModelLaunchModel(
options: import('./mux-interface.js').RespawnPaneOptions
): import('./mux-interface.js').RespawnPaneOptions {
private _withCustomModelLaunchModel<
T extends import('./mux-interface.js').RespawnPaneOptions | import('./mux-interface.js').CreateSessionOptions,
>(options: T): T {
const launchModel = this._customModel?.launchModel;
if (!launchModel) return options;
const entry = getCli(this.mode);
@@ -3348,7 +3348,7 @@ export class Session extends EventEmitter {
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
// Single source of truth shared with reattachRemote() (COD-108).
respawnPaneOptions: this._buildRespawnPaneOptions(),
createSessionOptions: {
createSessionOptions: this._withCustomModelLaunchModel({
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -3378,7 +3378,7 @@ export class Session extends EventEmitter {
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
}),
spawnErrLabel: 'mux attachment',
});
+6 -23
View File
@@ -4750,7 +4750,6 @@ export function registerSessionRoutes(
// would hide them, and giving setCustomModel() that set would put
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
let qsCustomModelLaunchModel: string | undefined;
let qsCustomModelSessionId: string | undefined;
let qsCustomModelSwapInProgress = false;
let qsCustomModelBookkeeping:
@@ -4866,7 +4865,6 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
}
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
qsCustomModelLaunchModel = cmApplied.launchModel;
qsCustomModelBookkeeping = {
endpointId: cmEndpoint.id,
modelId: customModel.modelId,
@@ -4916,30 +4914,15 @@ export function registerSessionRoutes(
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
parentSessionId: qsParentSessionId,
};
// Force the custom-model selection's launchModel (pi/omp `custom/<id>`, grok's
// `[model.<name>]` block name) onto whichever config field the registry says the
// CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel,
// which the restart-in-place path already uses, rather than a hardcoded per-CLI
// branch here that a CLI landing its injection recipe later would silently miss.
if (qsCustomModelLaunchModel !== undefined) {
const qsCustomModelField = getCli(mode)?.launch.legacyConfigField;
if (qsCustomModelField) {
const qsSessionOptionsBag = qsSessionOptions as unknown as Record<string, unknown>;
qsSessionOptionsBag[qsCustomModelField] = {
...((qsSessionOptionsBag[qsCustomModelField] as Record<string, unknown>) ?? {}),
model: qsCustomModelLaunchModel,
};
} else {
qsSessionOptions.model = qsCustomModelLaunchModel;
}
}
const session = new Session(qsSessionOptions);
// Records the selection for session.customModel/getCustomModelForPersist() and future
// clear/switch calls, and merges the injected env, which the launch below reads along
// with the launch-model config set above (piConfig/grokConfig/ompConfig.model). Nothing
// has started yet, so this is never a restart: setCustomModel() is synchronous state,
// no tmux IO of its own (see its own doc comment in session.ts).
// clear/switch calls, and merges the injected env. The launch below reads both: the
// selection's launchModel is forced onto the launch options, never onto the stored
// config above, because that config is what a later clear falls back to
// (Session._withCustomModelLaunchModel). Nothing has started yet, so this is never a
// restart: setCustomModel() is synchronous state, no tmux IO of its own (see its own
// doc comment in session.ts).
if (qsCustomModelBookkeeping) {
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
}
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
// @ts-expect-error plain .mjs helper shared with scripts/generate-release-notes.mjs
import { buildReleaseNotes, previousVersion } from '../scripts/lib/release-notes.mjs';
// @ts-expect-error plain .mjs helper shared with scripts/generate-whats-new.mjs
import { versionSection, extractHighlights } from '../scripts/lib/whats-new.mjs';
const root = resolve(import.meta.dirname, '..');
const realChangelog = readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8');
/** A release the way changesets writes it: one hashed bullet, the rest nested under it. */
const CHANGELOG = `# aicodeman
## 2.1.0
### Minor Changes
- 12024cc: ### Thanks
- @someone for the tile grid (#10).
- @other for reporting the wheel bug (#11).
**The tile grid (#10).** Six sessions side by side. Behaviour change: \`Ctrl+G\` opens it now.
It remembers your layout.
**Install without admin rights.** Nothing lands outside your home folder.
**Fixes.**
- **The wheel works (#11).** It survives a reload.
- **A second fix.** Text.
**Fixes applied while landing.** One review fix (#10).
**Docs and tests.** A new guide.
## 2.0.0
### Patch Changes
- abcdef1: One line.
- A bullet with no hash.
## 1.9.0
- **The oldest entry.** Text.
`;
const notes = (version: string, changelog = CHANGELOG): string => buildReleaseNotes(changelog, version);
describe('release notes', () => {
it('drops what only changesets needs: the commit prefix, the type heading and the nesting', () => {
const body = notes('2.1.0');
expect(body).not.toMatch(/12024cc/);
expect(body).not.toMatch(/Minor Changes/);
expect(body.split('\n').filter((line) => line.startsWith(' '))).toEqual([]);
});
it('lays the page out in order, with Thanks first', () => {
const headings = notes('2.1.0')
.split('\n')
.filter((line) => line.startsWith('#'));
expect(headings).toEqual([
'## Thanks',
"## What's new",
'### The tile grid (#10)',
'### Install without admin rights',
'## Fixes',
'## Also in this release',
]);
});
it('keeps a paragraph with the feature it follows', () => {
const body = notes('2.1.0');
expect(body.indexOf('It remembers your layout.')).toBeLessThan(body.indexOf('### Install without admin rights'));
expect(body.indexOf('It remembers your layout.')).toBeGreaterThan(body.indexOf('### The tile grid'));
});
it('keeps the fixes as a list and the two short sections as run-ins', () => {
const body = notes('2.1.0');
expect(body).toContain('## Fixes\n\n- **The wheel works (#11).** It survives a reload.\n- **A second fix.** Text.');
expect(body).toContain('**Fixes applied while landing.** One review fix (#10).');
expect(body).toContain('**Docs and tests.** A new guide.');
});
it('makes a behaviour change stand out, without doubling bold that is already there', () => {
expect(notes('2.1.0')).toContain('**Behaviour change:** `Ctrl+G` opens it now.');
const bold = '## 1.0.0\n\n- **Fixes.**\n - **Clone.** Text. Behaviour changes: a. **Breaking:** b.\n';
const body = notes('1.0.0', bold);
expect(body).toContain('**Behaviour changes:** a. **Breaking:** b.');
expect(body).not.toContain('****');
});
it('links the comparison with the previous release, and only when there is one', () => {
expect(previousVersion(CHANGELOG, '2.1.0')).toBe('2.0.0');
expect(notes('2.1.0')).toContain('compare/codeman@2.0.0...codeman@2.1.0');
expect(previousVersion(CHANGELOG, '1.9.0')).toBeNull();
expect(notes('1.9.0')).not.toContain('Full Changelog');
});
it('keeps one-line changesets as a list, hashed or not', () => {
expect(notes('2.0.0')).toContain("## What's new\n\n- One line.\n\n- A bullet with no hash.");
});
it('merges several changesets into one page instead of repeating sections', () => {
const changelog = [
'## 3.0.0',
'### Minor Changes',
'- aaaaaaa: ### Thanks\n - @a for one.\n\n **Feature A.** Text.\n\n **Fixes.**\n - **Fix A.** Text.',
'### Patch Changes',
'- bbbbbbb: ### Thanks\n - @b for two.\n\n **Feature B.** Text.\n\n **Fixes.**\n - **Fix B.** Text.',
].join('\n\n');
const body = notes('3.0.0', changelog);
expect(body.match(/^## Thanks$/gm)).toHaveLength(1);
expect(body.match(/^## Fixes$/gm)).toHaveLength(1);
expect(body).toContain('### Feature A');
expect(body).toContain('### Feature B');
expect(body).toContain('- @b for two.');
expect(body).toContain('- **Fix B.** Text.');
});
it('does not take a sentence that opens in bold for a headline', () => {
const changelog = '## 1.0.0\n\n- **Allow network access** is a new setting, off by default.\n';
const body = notes('1.0.0', changelog);
expect(body).toContain('**Allow network access** is a new setting, off by default.');
expect(body).not.toContain('###');
});
it('keeps text after the Thanks list out of Thanks, and a lead-in line in it', () => {
const changelog = [
'## 1.0.0',
'- ### Thanks',
' 1.0.0 follows 0.9.0, so the thanks belong here too:',
'',
' - @a for a fix.',
'',
' ![a demo](https://example.com/demo.png)',
'',
' **A feature.** Text.',
].join('\n');
const body = notes('1.0.0', changelog);
expect(body).toMatch(/## Thanks\n\n1\.0\.0 follows 0\.9\.0.*:\n\n- @a for a fix\.\n\n## What's new\n\n!\[a demo\]/);
});
it('keeps credit written as a paragraph under Thanks, where it opened (1.21.0 does this)', () => {
const changelog =
'## 1.0.0\n\n- ### Thanks\n\n Seven rounds of beta testing by **@someone**.\n\n **A feature.** Text.\n';
expect(notes('1.0.0', changelog)).toMatch(
/^## Thanks\n\nSeven rounds of beta testing by \*\*@someone\*\*\.\n\n## What's new/
);
});
it('leaves a fenced code block alone', () => {
const changelog = '## 1.0.0\n\n- **A feature.** Text.\n\n ```md\n ### Not a heading\n\n **Fixes.**\n ```\n';
const body = notes('1.0.0', changelog);
expect(body).toContain('```md\n### Not a heading\n\n**Fixes.**\n```');
expect(body).not.toContain('## Fixes');
});
it('returns nothing for a version the changelog does not have', () => {
expect(notes('9.9.9')).toBe('');
expect(notes('1.0.0', '## 1.0.0\n\n### Patch Changes\n')).toBe('');
});
});
describe('release notes over every release in CHANGELOG.md', () => {
const versions: string[] = [...realChangelog.matchAll(/^## (\d+\.\d+\.\d+\S*)\s*$/gm)].map((m) => m[1]);
/** The words of a text, ignoring only what the page legitimately changes around them. */
const words = (text: string): string[] =>
text
.replace(/^### (?:Major|Minor|Patch) Changes\s*$/gm, '')
.replace(/^- [0-9a-f]{7,40}: /gm, '')
.replace(/[*#]/g, '')
.split(/\s+/)
.map((word) => word.replace(/[.:]+$/, ''))
.filter((word) => word && word !== '-');
it('reads the whole changelog', () => {
expect(versions.length).toBeGreaterThan(200);
});
it('never loses a word', () => {
const lost: string[] = [];
for (const version of versions) {
const available = new Map<string, number>();
for (const word of words(buildReleaseNotes(realChangelog, version))) {
available.set(word, (available.get(word) ?? 0) + 1);
}
for (const word of words(versionSection(realChangelog, version))) {
const left = available.get(word) ?? 0;
if (left === 0) lost.push(`${version}: ${word}`);
available.set(word, left - 1);
}
}
expect(lost).toEqual([]);
});
it('leaves no changesets scaffolding on any page, and opens with Thanks wherever there is one', () => {
for (const version of versions) {
const body: string = buildReleaseNotes(realChangelog, version);
expect(body, version).not.toMatch(/^- [0-9a-f]{7,40}: /m);
expect(body, version).not.toMatch(/^#+ (?:Major|Minor|Patch) Changes/m);
if (/^ *### Thanks|: ### Thanks/m.test(versionSection(realChangelog, version))) {
expect(body.startsWith('## Thanks'), version).toBe(true);
}
}
});
it('gives a page every headline the in-app toast announces for the same release', () => {
const body: string = buildReleaseNotes(realChangelog, '1.49.0');
const highlights: string[] = extractHighlights(versionSection(realChangelog, '1.49.0'));
expect(highlights.length).toBeGreaterThan(0);
for (const highlight of highlights) expect(body).toContain(highlight);
});
});
describe('generate-release-notes.mjs', () => {
const script = resolve(root, 'scripts/generate-release-notes.mjs');
it('prints the page for a version', () => {
const out = execFileSync(process.execPath, [script, '1.49.0'], { encoding: 'utf8' });
expect(out).toBe(buildReleaseNotes(realChangelog, '1.49.0'));
});
it('prints nothing and fails for an unknown version, so the workflow keeps the changesets text', () => {
const run = spawnSync(process.execPath, [script, '0.0.1-nope'], { encoding: 'utf8' });
expect(run.status).toBe(1);
expect(run.stdout).toBe('');
});
});
describe('Release workflow', () => {
const workflow = readFileSync(resolve(root, '.github/workflows/release.yml'), 'utf8');
it('titles the release after the product and keeps the tag the updater parses', () => {
expect(workflow).toContain('-f name="Codeman ${VERSION}"');
expect(workflow).toContain('-f tag_name="$NEW_TAG"');
});
it('cannot lose the retag to a failed format: the script is guarded and the PATCH is not', () => {
const guard = workflow.indexOf('if node scripts/generate-release-notes.mjs "$VERSION"');
const patch = workflow.indexOf('gh api -X PATCH');
expect(guard).toBeGreaterThan(-1);
expect(guard).toBeLessThan(patch);
expect(workflow.slice(guard, patch)).toContain('fi\n');
expect(workflow.slice(patch)).toContain('"${BODY_ARGS[@]}"');
});
});
@@ -204,6 +204,34 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(restartSpy).not.toHaveBeenCalled();
});
it("leaves the caller's own model in the stored config, which is what a later clear falls back to", async () => {
const res = await quickStart({
caseName: 'cm-pi-own-model',
mode: 'pi',
piConfig: { model: 'anthropic/claude-x' },
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
expect(session.getCustomModelForPersist()?.launchModel).toBe('custom/qwen3');
expect(session.toState().piConfig?.model).toBe('anthropic/claude-x');
});
it('stores no model at all for a caller that chose none', async () => {
const res = await quickStart({
caseName: 'cm-codex-no-model',
mode: 'codex',
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(res.json().sessionId) as unknown as Session;
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
expect(session.toState().codexConfig?.model).toBeUndefined();
});
it('404s for an unknown endpoint id', async () => {
const res = await quickStart({
caseName: 'cm-ghost',
+47
View File
@@ -195,6 +195,53 @@ describe('restartCli() must not kill a working pane', () => {
});
describe('launchModel reaches the CLI through its own launch param', () => {
/** A session the route just built: no pane yet, so its launch is `createSession`. */
function freshSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
mkdirSync(workingDir, { recursive: true });
const mux = new TmuxManager();
const create = vi.spyOn(mux, 'createSession');
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
const session = new Session({ workingDir, mode, mux, useMux: true, ...extra });
sessions.push(session);
return { session, create, respawn };
}
it('the first launch carries it too, and a clear relaunches on the model the session was created with', async () => {
const { session, create, respawn } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x' } });
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
{ HOME: '/tmp/cfg' }
);
await session.startInteractive();
expect(create.mock.calls[0][0].piConfig?.model).toBe('custom/qwen3');
expect(session.toState().piConfig?.model).toBe('anthropic/claude-x');
session.setCustomModel(undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn.mock.calls[0][0].piConfig?.model).toBe('anthropic/claude-x');
});
it('a clear on a session created with no model relaunches with none', async () => {
const { session, create, respawn } = freshSession('codex');
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], configDir: '/tmp/cfg', launchModel: 'qwen3' },
{ CODEX_HOME: '/tmp/cfg' }
);
await session.startInteractive();
expect(create.mock.calls[0][0].codexConfig?.model).toBe('qwen3');
session.setCustomModel(undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn.mock.calls[0][0].codexConfig?.model).toBeUndefined();
});
it('leaves the launch untouched without a selection', async () => {
const { session, create } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
await session.startInteractive();
expect(create.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
});
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
session.setCustomModel(