Compare commits

..
Author SHA1 Message Date
Codeman maintainer 91cea50d9c fix(macos): never run Apple's placeholder git, which opens the developer tools window
On a Mac without the developer tools, /usr/bin/git is a placeholder: running
it, even as `git --version`, makes macOS open its "install the command line
developer tools" window. The server asked "is git here?" exactly that way on
the first page load (isGitAvailable() from renderIndexHtml), so a no-admin
install on such a Mac ended with that window one second after the server
started, and it would have come back at every start. Found on a new MacBook
on 2026-11-06: the system log names the Codeman server as the parent of that
git process.

One gate now stands in front of every git the server starts
(src/utils/git-availability.ts). On macOS it resolves git on the PATH itself;
when that is the placeholder, `xcode-select -p` must name a developer folder
that holds a git of its own. xcode-select only prints and never opens a
window. Without the tools, git counts as absent and is never started: every
caller gets the ENOENT it already answers with "git is not installed".
A refusal is asked again after a minute, so installing the tools later needs
no server restart for the git features, and isGitAvailable() no longer
memoizes that one refusal.

Gated: the clone and worktree runners, the Git panel's status and fetch, the
update check and repository status, and Read My Mind's workspace signals.
test/git-spawn-gate.test.ts covers the decision and scans src/ for a git spawn
in a file that does not call the gate, with the reviewed list of sites pinned.

Checked for real on macOS 27. With the developer tools present (Command
Line Tools on one Mac, Xcode on another) the gate resolves /usr/bin/git,
finds the tools and allows git, in 3 to 5 ms. On a MacBook with no
developer tools, a fresh no-admin install with this fix started the server
and loaded the page with git reported as absent, no window opened, and the
system log holds no request to install the tools; the same install without
the fix logged that request one second after the server started.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 17:02:53 +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
23 changed files with 1021 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
+1
View File
@@ -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)
+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.
+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);
}
+20 -1
View File
@@ -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',
+4
View File
@@ -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));
+3
View File
@@ -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
+6
View File
@@ -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, {
+3
View File
@@ -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,
+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',
});
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview The ONE gate in front of every `git` the server starts.
*
* macOS ships `/usr/bin/git` as a placeholder. On a Mac without the developer tools (Xcode or the
* Command Line Tools), running it, even as `git --version`, makes macOS open its "install the command
* line developer tools" window and exit non-zero. The server used to ask "is git here?" that way on the
* first page load, so a user who had just installed Codeman without git and without an administrator
* password got that window one second after the install finished, and again at every login.
* `install.sh` never runs the placeholder for the same reason.
*
* So on macOS, when `git` on the server's PATH resolves to that placeholder, the developer tools are
* proven first: `xcode-select -p` (a real binary that prints and never opens a window) must name a
* folder that holds a git of its own. Otherwise git counts as absent and is never started.
*
* Every site that starts git calls `gitSpawnAllowed()` first and answers a refusal the way it already
* answers a machine with no git (`gitMissingError()` carries `code: 'ENOENT'` for that).
* `test/git-spawn-gate.test.ts` fails on a git spawn in a file that does not go through this gate.
*
* The decision is pure (`gitSpawnWouldPrompt`, `findOnPath`); the IO around it is two bounded steps, a
* PATH scan and one `xcode-select -p` (a few milliseconds, capped at 3 s), and only on macOS. A "yes" is
* kept for the life of the process; a "no" is asked again after `GIT_GATE_NEGATIVE_TTL_MS`, so
* installing the developer tools later needs no server restart for the git features (the page's own
* "git is available" flag stays memoized per process, like every other availability flag there).
*
* @module utils/git-availability
*/
import { execFileSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { delimiter, isAbsolute, join } from 'node:path';
import { isExecutableRegularFile } from './cli-executable-resolver.js';
/** Apple's placeholder. A real git (Homebrew, MacPorts, Nix) sits elsewhere on the PATH. */
export const APPLE_GIT_PLACEHOLDER = '/usr/bin/git';
const XCODE_SELECT = '/usr/bin/xcode-select';
const XCODE_SELECT_TIMEOUT_MS = 3_000;
/** How long a refusal is kept before the machine is looked at again. */
export const GIT_GATE_NEGATIVE_TTL_MS = 60_000;
/**
* The first `name` on a PATH-style list that `isExecutable` accepts, or null. Pure.
* Empty and relative entries are skipped: they mean "the current directory" of whoever runs the
* command, which this lookup cannot know.
*/
export function findOnPath(
name: string,
pathEnv: string | undefined,
isExecutable: (path: string) => boolean
): string | null {
for (const dir of (pathEnv ?? '').split(delimiter)) {
if (!dir || !isAbsolute(dir)) continue;
const candidate = join(dir, name);
if (isExecutable(candidate)) return candidate;
}
return null;
}
export interface GitGateFacts {
platform: NodeJS.Platform;
/** What `git` resolves to on the server's PATH (symlinks followed), or null when there is none. */
gitPath: string | null;
/** Asked only when it decides the answer, so other machines never pay for it. */
developerToolsPresent: () => boolean;
}
/**
* Would starting `git` open macOS's "install the developer tools" window? Pure.
* Only Apple's placeholder does that, and only while no developer tools are installed. No git on the
* PATH at all is not this function's case: the spawn then fails with ENOENT, which every caller handles.
*/
export function gitSpawnWouldPrompt(facts: GitGateFacts): boolean {
if (facts.platform !== 'darwin') return false;
if (facts.gitPath !== APPLE_GIT_PLACEHOLDER) return false;
return !facts.developerToolsPresent();
}
/**
* The developer folder `xcode-select -p` names must hold a git of its own (`usr/bin/git`, true for both
* Xcode and the Command Line Tools). A selected folder that has since been deleted would otherwise read
* as "tools present" and the placeholder would open its window after all.
*/
export function developerDirHoldsGit(developerDir: string, isExecutable: (path: string) => boolean): boolean {
const dir = developerDir.trim();
if (!dir || !isAbsolute(dir)) return false;
return isExecutable(join(dir, 'usr', 'bin', 'git'));
}
function resolveGitOnPath(): string | null {
const found = findOnPath('git', process.env.PATH, isExecutableRegularFile);
if (!found) return null;
try {
return realpathSync(found);
} catch {
return found;
}
}
function developerToolsPresent(): boolean {
try {
const out = execFileSync(XCODE_SELECT, ['-p'], {
encoding: 'utf8',
timeout: XCODE_SELECT_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
return developerDirHoldsGit(out, isExecutableRegularFile);
} catch {
return false; // exit 2: no developer folder. Never the placeholder's window.
}
}
let allowed: boolean | null = null;
let refusedAt = 0;
/**
* May the server start `git` right now? False only on a Mac whose only git is Apple's placeholder with
* no developer tools behind it. Callers answer false as "git is not installed".
*/
export function gitSpawnAllowed(): boolean {
if (allowed === true) return true;
if (allowed === false && Date.now() - refusedAt < GIT_GATE_NEGATIVE_TTL_MS) return false;
const platform = process.platform;
allowed = !gitSpawnWouldPrompt({
platform,
gitPath: platform === 'darwin' ? resolveGitOnPath() : null,
developerToolsPresent,
});
if (!allowed) refusedAt = Date.now();
return allowed;
}
/** The error a refused spawn stands in for: what Node raises when there is no `git` to start. */
export function gitMissingError(): NodeJS.ErrnoException {
const err: NodeJS.ErrnoException = new Error('spawn git ENOENT');
err.code = 'ENOENT';
err.errno = -2;
err.syscall = 'spawn git';
err.path = 'git';
return err;
}
/** Test seam: forget the remembered answer. */
export function resetGitGateForTests(): void {
allowed = null;
refusedAt = 0;
}
+3
View File
@@ -41,6 +41,7 @@ import { createRequire } from 'node:module';
import { promisify } from 'node:util';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js';
import { gitSpawnAllowed } from '../utils/git-availability.js';
import { getInstallInfo } from './self-update.js';
import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js';
@@ -242,6 +243,8 @@ interface GitResult {
* spawn would block the event loop for the whole wait.
*/
async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise<GitResult> {
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
if (!gitSpawnAllowed()) return { ok: false, stdout: '', stderr: 'git is not installed' };
try {
const { stdout } = await execFileAsync('git', args, {
cwd,
+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);
}
+3
View File
@@ -41,6 +41,7 @@ import { createRequire } from 'node:module';
import { dataPath } from '../config/instance.js';
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { gitSpawnAllowed } from '../utils/git-availability.js';
import type {
EnvironmentBlocker,
EnvironmentGate,
@@ -429,6 +430,8 @@ export function reconcileUpdateOnBoot(now = Date.now()): void {
/** Run a command, returning trimmed stdout, or null on any error. */
function tryExec(cmd: string, args: string[], cwd?: string, timeout = EXEC_TIMEOUT_MS): string | null {
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
if (cmd === 'git' && !gitSpawnAllowed()) return null;
try {
return execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
} catch {
+181
View File
@@ -0,0 +1,181 @@
/**
* @fileoverview The gate in front of every `git` the server starts (src/utils/git-availability.ts).
*
* On a Mac without the developer tools, `/usr/bin/git` is Apple's placeholder: running it, even as
* `git --version`, opens the system's "install the command line developer tools" window. The server did
* exactly that on the first page load after a no-admin install (found 2026-11-06 on a new MacBook: the
* system log named the Codeman server as the parent of that `git`).
*
* Two halves: the pure decision, and a scan of `src/` that fails when a file starts git without going
* through the gate, so the next git caller cannot bring the window back.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
APPLE_GIT_PLACEHOLDER,
developerDirHoldsGit,
findOnPath,
gitMissingError,
gitSpawnAllowed,
gitSpawnWouldPrompt,
resetGitGateForTests,
} from '../src/utils/git-availability.js';
const never = (): boolean => {
throw new Error('must not be asked');
};
describe('gitSpawnWouldPrompt', () => {
it('is true only for Apple’s placeholder on a Mac without developer tools', () => {
expect(
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => false })
).toBe(true);
});
it('lets the placeholder run once the developer tools are there', () => {
expect(
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => true })
).toBe(false);
});
it('never asks about developer tools for a git that is not the placeholder', () => {
for (const gitPath of ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/nix/store/x/bin/git']) {
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath, developerToolsPresent: never })).toBe(false);
}
});
it('leaves a Mac with no git on the PATH to the spawn’s own ENOENT', () => {
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath: null, developerToolsPresent: never })).toBe(false);
});
it('is never true off macOS, where /usr/bin/git is a real git', () => {
for (const platform of ['linux', 'win32', 'freebsd'] as const) {
expect(gitSpawnWouldPrompt({ platform, gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: never })).toBe(
false
);
}
});
});
describe('findOnPath', () => {
const has =
(...paths: string[]) =>
(p: string) =>
paths.includes(p);
it('returns the first match in PATH order', () => {
const exists = has('/opt/homebrew/bin/git', '/usr/bin/git');
expect(findOnPath('git', '/opt/homebrew/bin:/usr/bin', exists)).toBe('/opt/homebrew/bin/git');
expect(findOnPath('git', '/usr/bin:/opt/homebrew/bin', exists)).toBe('/usr/bin/git');
});
it('skips empty and relative entries, which mean the current directory', () => {
const exists = has('git', 'bin/git', '/usr/bin/git');
expect(findOnPath('git', ':.:bin:/usr/bin', exists)).toBe('/usr/bin/git');
});
it('is null when nothing matches or there is no PATH', () => {
expect(findOnPath('git', '/a:/b', () => false)).toBeNull();
expect(findOnPath('git', undefined, () => true)).toBeNull();
expect(findOnPath('git', '', () => true)).toBeNull();
});
});
describe('developerDirHoldsGit', () => {
it('needs a git inside the folder xcode-select names', () => {
const exists = (p: string) => p === '/Library/Developer/CommandLineTools/usr/bin/git';
expect(developerDirHoldsGit('/Library/Developer/CommandLineTools\n', exists)).toBe(true);
// A selected Xcode that was deleted since: the placeholder would open its window after all.
expect(developerDirHoldsGit('/Applications/Xcode.app/Contents/Developer\n', exists)).toBe(false);
});
it('refuses empty and relative answers', () => {
expect(developerDirHoldsGit('', () => true)).toBe(false);
expect(developerDirHoldsGit(' \n', () => true)).toBe(false);
expect(developerDirHoldsGit('relative/dir', () => true)).toBe(false);
});
});
describe('gitMissingError', () => {
it('reads as the ENOENT every caller already answers with "git is not installed"', () => {
const err = gitMissingError();
expect(err.code).toBe('ENOENT');
expect(err.message).toBe('spawn git ENOENT');
expect(String(err)).toMatch(/enoent/i); // git-worktrees.ts and git-clone.ts match the string form
});
});
describe('gitSpawnAllowed', () => {
it.skipIf(process.platform === 'darwin')('is always true off macOS and does no IO there', () => {
resetGitGateForTests();
expect(gitSpawnAllowed()).toBe(true);
expect(gitSpawnAllowed()).toBe(true);
});
});
// ─── Scan: no git spawn outside the gate ─────────────────────────────────────
const SRC = join(__dirname, '..', 'src');
/** A call that starts `git` as the program (argv form; the server never builds a git shell string). */
const GIT_SPAWN = /\b(?:spawn|spawnSync|execFile|execFileSync|execFileAsync|exec|execSync|tryExec)\(\s*['"`]git['"`]/g;
/**
* Files that start git, with how many call sites each has. A new site in one of them, or a new file,
* fails here on purpose: add `gitSpawnAllowed()` in front of it (see the module's fileoverview), then
* update this table.
*/
const GATED: Record<string, number> = {
'git-clone.ts': 2,
'git-fetch.ts': 2,
'git-workspace-status.ts': 1,
'git-worktrees.ts': 1,
'readmymind-collectors.ts': 1,
'web/repo-status.ts': 1,
'web/self-update.ts': 8,
};
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'public' || entry.name === 'node_modules') continue;
walk(full, out);
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
out.push(full);
}
}
return out;
}
describe('every git the server starts goes through the gate', () => {
const found: Record<string, number> = {};
const ungated: string[] = [];
for (const file of walk(SRC)) {
const text = readFileSync(file, 'utf8');
const sites = text.match(GIT_SPAWN)?.length ?? 0;
if (!sites) continue;
const rel = relative(SRC, file).split(sep).join('/');
found[rel] = sites;
if (!/\bgitSpawnAllowed\(\)/.test(text)) ungated.push(rel);
}
it('finds the spawn sites at all (the pattern still matches the code)', () => {
expect(Object.keys(found).length).toBeGreaterThan(0);
});
it('has no file that starts git without calling gitSpawnAllowed()', () => {
expect(ungated).toEqual([]);
});
it('matches the reviewed list of sites, so a new one is looked at', () => {
expect(found).toEqual(GATED);
});
it('keeps the gate itself free of git', () => {
const gate = readFileSync(join(SRC, 'utils', 'git-availability.ts'), 'utf8');
expect(gate.match(GIT_SPAWN)).toBeNull();
});
});
+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(