mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-09 00:39:33 +01:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235db69c1e | ||
|
|
cda4184e42 | ||
|
|
f1cfe61950 | ||
|
|
edfc2ed667 | ||
|
|
0c0ada7cdb | ||
|
|
55e0f0e7e4 | ||
|
|
14cf95e6ee | ||
|
|
3d61402250 | ||
|
|
cd2265f8d0 | ||
|
|
df98912596 | ||
|
|
ddcdd5d974 | ||
|
|
9c64fc128d | ||
|
|
b3f0ba9e29 | ||
|
|
ba9d311cb7 | ||
|
|
e9b2ecd552 | ||
|
|
f66a9a574d | ||
|
|
a651616907 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
**The Codeman agent skill is on by default.** New sessions get the skill in their folder, where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; still none for Pi, GitHub Copilot or a plain shell), so an agent can start and drive workers without an install step. The copy now ignores itself in git (a `.gitignore` of `*` inside the copy, written once and never overwritten), so it never shows up as a new file in your repository, and `codeman skill uninstall` removes it with the copy. A `settings.json` without the `agentSkillEnabled` key now means on. **Behaviour change:** every Save of App Settings on 1.49.0 or earlier wrote the old default (`false`) whether or not you touched this switch, so on its first start 1.50.0 switches a stored off on, once per data directory (it cannot tell an off you chose from the old default; the startup log says when it did). Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**: an off set after that first start stays off. A browser tab left open from before the upgrade still holds the old value until you reload it, so reload before saving App Settings there. Turning it off still removes no copy already written: `codeman skill uninstall --case <name>` does that per case.
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -127,7 +127,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case <name>` removes copies). ⚠️ Existing installs that stored off are switched on once at the first 1.50.0 start (every Save up to 1.49.0 wrote `false`); turning it off afterwards sticks. That is `migrateAgentSkillDefault()` (`web/agent-skill-default-migration.ts`, from `WebServer.start()` before the first settings read and before listen, skipped in test mode, through `mutateSettingsFile()` only): it stores `true`, never deletes the key (the page's merge keeps a device's own `false` for a key the server lacks, and the next Save would write it back; the page also drops its copy when the server has no key), and its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir, never a settings key (so no Save sends or drops it, the `.strict()` schema never sees it, and a config-backup restore leaves it alone). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
@@ -323,7 +323,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -775,13 +775,15 @@ Everything in this section also ships as an **agent skill**, [`skills/codeman`](
|
||||
|
||||
#### Step 1: install it
|
||||
|
||||
Usually there is nothing to install: since 1.50.0 every new session gets the skill in its folder, where its CLI looks for skills (the web UI row below, on by default). The copy ignores itself in git, so your repository never lists it as a new file. The other routes are for pi, or for a copy that does not depend on that setting.
|
||||
|
||||
| How | Command | Scope |
|
||||
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
|
||||
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, default off) |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, **on by default** since 1.50.0; a Claude Code that also has the plugin lists the skill twice, both work, so drop one if the duplicate bothers you) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every Codeman-managed copy in that case, whichever CLI's directory it sits in), and never touches a `skills/codeman` you wrote yourself.
|
||||
|
||||
@@ -848,7 +850,7 @@ Every recipe in there was verified against a live server, and the comments recor
|
||||
- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
|
||||
|
||||
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
⚠️ The web UI injection is **on by default** since 1.50.0. A `settings.json` without the `agentSkillEnabled` key means on. Existing installs that stored off are switched on once at the first 1.50.0 start (every App Settings save on 1.49.0 or earlier wrote the old default, `false`, whether or not you touched this switch); turning it off afterwards sticks. Turning it off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-2
@@ -775,13 +775,15 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
|
||||
#### 第 1 步:安装
|
||||
|
||||
通常什么都不用装:从 1.50.0 起,每个新会话都会在自己的文件夹里、在它的 CLI 查找技能的位置拿到这个技能(即下表的 Web UI 一行,默认开启)。这份副本会让 git 忽略它自己,所以你的仓库永远不会把它列为新文件。其他方式适用于 pi,或者你想要一份不依赖这个设置的副本的情况。
|
||||
|
||||
| 方式 | 命令 | 范围 |
|
||||
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用([skills.sh](https://skills.sh));给 pi 装技能就用这个方式 |
|
||||
| Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) |
|
||||
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
|
||||
| 内置 CLI | `codeman skill install --case <name> [--mode <mode>]` | 仅一个 case;`--mode codex`(或任何其他运行模式)会把它写到那个 CLI 查找技能的位置 |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,默认关闭) |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,从 1.50.0 起**默认开启**;同时装了插件的 Claude Code 会把技能列出两次,两个都能用,嫌重复就去掉其中一个) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装(带 `--case` 时,删除该 case 里所有由 Codeman 管理的副本,不论它在哪个 CLI 的目录里),并且绝不会碰你自己写的 `skills/codeman`。
|
||||
|
||||
@@ -848,7 +850,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||
- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
|
||||
|
||||
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
⚠️ 从 1.50.0 起,Web UI 注入**默认开启**。`settings.json` 里没有 `agentSkillEnabled` 这个键就表示开启。已经存了关闭的旧安装,会在第一次启动 1.50.0 时被打开一次(1.49.0 及更早版本每次保存 App Settings 都会写入旧默认值 `false`,不管你有没有动过这个开关);之后你再把它关掉,就会一直保持关闭。把它关掉**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,6 +8,17 @@ see [§7 Build log](#7-build-log-what-actually-happened) for what shipped, what
|
||||
verification round found, and the two items that genuinely remain open (§2.4's footgun
|
||||
guard and the Part 3 deferrals).
|
||||
|
||||
**Update 2026-11-08 (1.50.0)**: `agentSkillEnabled` is now **on by default** (owner decision:
|
||||
"enable the codeman skill by default"), the flip §2.2 planned for after dogfooding. An absent
|
||||
key means on in both readers (server `getAgentSkillEnabled()` and the App Settings switch, both
|
||||
`!== false`); an explicit false is off. Existing installs that stored off are switched on once at
|
||||
the first 1.50.0 start (every App Settings Save up to 1.49.0 wrote `false`); turning it off
|
||||
afterwards sticks (`src/web/agent-skill-default-migration.ts`, a "Behaviour change" in the
|
||||
release notes). To keep users' repositories clean, every managed copy
|
||||
now carries a `.gitignore` of `*` (written once with `wx`, removed by uninstall only when it is
|
||||
the last file left). Turning the setting off still sweeps nothing. The sections below record the
|
||||
original default-off decision as it was made.
|
||||
|
||||
**Date**: 2026-08-08
|
||||
**Scope**: Part 1 (agent skill) and Part 2 (wait primitives) were specified and built.
|
||||
Parts 3 to 5 are captured so they are not lost, but remain deliberately deferred.
|
||||
@@ -119,7 +130,7 @@ Install paths, in order of how a user gets it:
|
||||
|
||||
Setting name: `agentSkillEnabled`. Synced (not per-device), since it changes on-disk case
|
||||
content rather than display. Default: **ON after the dogfooding phase, OFF in the first
|
||||
release**. Rationale for starting OFF: Claude Code loads every skill's name and description
|
||||
release** (flipped to ON in 1.50.0, see the Status update at the top). Rationale for starting OFF: Claude Code loads every skill's name and description
|
||||
into context on every turn, so an always-on skill has a small permanent token cost, and we
|
||||
should measure that we are buying something with it first.
|
||||
|
||||
@@ -543,6 +554,7 @@ without the wait endpoints, so the wait work goes first.
|
||||
1. ✅ `skills/` at the repo root: accepted (built that way; the install one-liner depends on it).
|
||||
2. ✅ `agentSkillEnabled` default: **OFF** for the first release, per §2.2's rationale (skills
|
||||
cost context on every turn; measure before defaulting on). Flip later if dogfooding earns it.
|
||||
**Flipped to ON in 1.50.0** (2026-11-08), with the copy made self-ignoring in git.
|
||||
3. ✅ Both: global install via `npx skills add` / `codeman skill install`, AND per-case
|
||||
auto-injection behind the (default-off) setting. Injection is add-only at session create and
|
||||
marker-guarded, so a user-authored copy is never touched.
|
||||
@@ -681,9 +693,9 @@ Built to the §2.6 file list, mirroring the statusLine mechanism throughout:
|
||||
| ----- | ----- |
|
||||
| `applyAgentSkill(casePath, enabled)` + `installAgentSkillInto` / `removeAgentSkillFrom` | `src/hooks-config.ts` |
|
||||
| `codeman skill install` / `skill uninstall` (`--global` default, `--case <name>`) | `src/cli.ts` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF; ON since 1.50.0) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js`; the one-time 1.50.0 boot migration of a stored false in `src/web/agent-skill-default-migration.ts` |
|
||||
| Injection call sites (Claude mode only) | `POST /api/sessions` next to `refreshStaleCodemanHooks`; `POST /api/quick-start` after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) |
|
||||
| Tests | `test/agent-skill.test.ts` (10 unit), `test/quick-start.test.ts` (real server: default-off, PUT accepts key, injection on create, shell-mode skipped) |
|
||||
| Tests | `test/agent-skill.test.ts` (unit, incl. the self-ignoring `.gitignore` against real git since 1.50.0), `test/quick-start.test.ts` (real server: default-off at first, default-on since 1.50.0 incl. a partial PUT and an explicit false, injection on create, shell-mode skipped), `test/agent-skill-setting-ui.test.ts` (the App Settings switch reads an absent key as on, and a device blob holding the old false follows the server), `test/agent-skill-default-migration.test.ts` (the boot migration: once per data dir, marker outside settings.json) |
|
||||
|
||||
Decisions worth keeping:
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
|
||||
|
||||
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex).
|
||||
|
||||
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
|
||||
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again (and switches on an off saved between the fix and that start, since the migration has not run yet). It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so once the migration has run, a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `<copy>/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `referenLine truncated
|
||||
|
||||
Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused).
|
||||
|
||||
@@ -229,7 +229,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
|
||||
|
||||
**Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in.
|
||||
|
||||
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
|
||||
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
|
||||
|
||||
**`nameSource` also decides what claude is told to call the conversation.** `--name` is the prompt-box label, the `/resume` picker entry and the terminal title at once, and a pinned title stops Claude generating its own, so by default only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry, because `_nextCaseSessionStartNumber()` numbers from the highest OPEN tab and restarts once a case's tabs are closed. The opt-in synced `pinCliSessionName` (default OFF) pins every non-blank tab name for users who want the CLI labels to match their tabs; it is read fresh at every claude create and respawn (`readPinCliSessionNameEnabled()`, hooks-config.ts, like `readPlanUsageTelemetryEnabled()`), so a toggle applies from the next spawn with no restart. ⚠️ Its cost: Claude stops generating its own titles for those sessions, and a rename holds only until the next turn, because a pane spawned with `--name` re-appends its in-memory title after every turn (`claude-session-title.ts`); a server restart re-attaches the live pane without respawning it. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,7 +165,7 @@ agent skill instead of teaching it these calls by hand: `skills/codeman` in the
|
||||
(`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install
|
||||
[--case <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
|
||||
automatic per-case injection on session create, into the directory each CLI scans for
|
||||
skills). The skill carries the guard, the safety rules, and verified
|
||||
skills, on by default since 1.50.0). The skill carries the guard, the safety rules, and verified
|
||||
wait/orchestration recipes.
|
||||
|
||||
If the caller speaks the Model Context Protocol (Claude Desktop, Cursor, Codex, any MCP
|
||||
|
||||
+20
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ output. The other CLIs expose no equivalent.
|
||||
| Subagent and team windows | Yes | No |
|
||||
| Model, effort, advisor, and ultracode controls | Yes | No |
|
||||
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly |
|
||||
| The bundled agent skill | Yes | No |
|
||||
| The bundled agent skill | Yes | Every CLI except Pi and Copilot, in `.agents/skills/` |
|
||||
|
||||
Everything that makes a session a session works everywhere. What is Claude-only is mostly
|
||||
the machinery that needs to know *what* the agent is doing rather than *that* it is doing
|
||||
|
||||
@@ -43,7 +43,11 @@ DeepSeek Harness worker on a local qwen model and a Claude Code worker power on
|
||||
|
||||
## Install it
|
||||
|
||||
Pick one route. The skill only acts inside a Codeman session, so a global install is safe.
|
||||
Usually you do not have to: since 1.50.0 Codeman puts the skill into every new session's
|
||||
folder by default (the Web UI row below), where that session's CLI looks for skills. The copy
|
||||
ignores itself in git, so your repository never lists it as a new file.
|
||||
|
||||
Otherwise pick one route. The skill only acts inside a Codeman session, so a global install is safe.
|
||||
|
||||
| How | Command | Scope |
|
||||
| ------------------ | ------------------------------------------------------------------------------- | ----- |
|
||||
@@ -51,10 +55,13 @@ Pick one route. The skill only acts inside a Codeman session, so a global instal
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. |
|
||||
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
|
||||
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Off by default. |
|
||||
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. On by default since 1.50.0. |
|
||||
|
||||
Use the plugin **or** `codeman skill install`, not both: a Claude Code with both lists the
|
||||
skill twice (`codeman` and `codeman:codeman`).
|
||||
skill twice (`codeman` and `codeman:codeman`). The Web UI copy counts too, and it is on by
|
||||
default: with the plugin installed, Claude Code sessions in Codeman list the skill twice.
|
||||
Both work. Turn **Codeman Agent Skill** off if the duplicate bothers you (other CLIs then
|
||||
lose their copy too), or use the Web UI copy instead of the plugin.
|
||||
|
||||
The web UI route skips pi, because a project skill makes pi ask to trust the folder at
|
||||
startup. A pi or plain shell session uses the [`codeman agent`
|
||||
@@ -67,9 +74,16 @@ same verbs as shell commands.
|
||||
Codeman-managed copy in that case, whichever CLI's directory it sits in). It never touches a
|
||||
`skills/codeman` you wrote yourself.
|
||||
|
||||
Turning the web UI setting back off **does not remove copies it already injected**, because
|
||||
To stop the Web UI copy, turn **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent
|
||||
Skill** off. It is on by default since 1.50.0: a settings file without the setting means on,
|
||||
and only switching it off turns it off. Existing installs that stored off are switched on
|
||||
once at the first 1.50.0 start (every App Settings save on an earlier version stored the old
|
||||
default, off, whether or not you touched this switch); turning it off afterwards sticks.
|
||||
|
||||
Turning the web UI setting off **does not remove copies it already injected**, because
|
||||
a sweep at session start would pull the skill out from under other live sessions sharing
|
||||
that directory. Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
that directory. Remove them per case with `codeman skill uninstall --case <name>`, which
|
||||
also removes the copy's `.gitignore`.
|
||||
|
||||
## Then just ask
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode,
|
||||
and most other agent CLIs read. It teaches the agent the whole API, so you ask in plain
|
||||
English instead of pasting endpoint documentation into prompts:
|
||||
|
||||
Since 1.50.0 Codeman puts it into every new session's folder by default (App Settings →
|
||||
Agents & CLIs → **Codeman Agent Skill**), so an agent inside Codeman usually has it already.
|
||||
For pi, or a copy that does not depend on that setting:
|
||||
|
||||
```bash
|
||||
codeman skill install # or: npx skills add Ark0N/Codeman --skill codeman -g
|
||||
```
|
||||
|
||||
+3
-1
@@ -215,7 +215,9 @@ Yes. Codeman ships an agent skill that lets an agent inside a session drive the
|
||||
list sessions, spawn workers in any installed CLI, send prompts, and block until a worker's
|
||||
turn finishes. Install it once with `codeman skill install` or the Claude Code plugin, then
|
||||
ask in plain English, for example *"use codeman to start 5 claude workers, one per TODO
|
||||
item"*. The web UI setting that injects it into every new session's case is off by default.
|
||||
item"*. Since 1.50.0 Codeman also injects it into every new session's case by default, so an
|
||||
agent inside Codeman usually has it already (App Settings → Agents & CLIs → **Codeman Agent
|
||||
Skill** turns that off).
|
||||
|
||||
See [The Codeman Skill](Codeman-Skill), with recordings of real runs, and
|
||||
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the other routes.
|
||||
|
||||
@@ -17,7 +17,7 @@ lists mark a session that runs in one with a `⑂ <name>` pill.
|
||||
|
||||
The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/`
|
||||
(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the
|
||||
`codeman` folder it writes when **Codeman Agent Skill** is on) to the repository's local
|
||||
`codeman` folder it writes while **Codeman Agent Skill** is on, the default) to the repository's local
|
||||
`.git/info/exclude` when git does not ignore them already. Without that, those files would make
|
||||
every worktree look modified and git would refuse to remove it. Your tracked `.gitignore` is
|
||||
never touched.
|
||||
|
||||
@@ -202,7 +202,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
|
||||
| Agent Teams | Experimental teams. Also needs the CLI's own environment flag. |
|
||||
| Pin tab names on the CLI | Launches every Claude session with `--name` set to its tab name, so Claude Code's prompt label and `/resume` list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Synced, off by default; applies from the next session start or respawn. |
|
||||
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). Off by default. See [The Codeman Skill](Codeman-Skill). |
|
||||
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi, GitHub Copilot and a plain shell). On by default since 1.50.0; existing installs that stored off are switched on once at the first 1.50.0 start, and turning it off afterwards sticks. The copy ignores itself in git, so your repository never lists it. Off stops new copies but removes none (`codeman skill uninstall --case <name>` does). See [The Codeman Skill](Codeman-Skill). |
|
||||
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
|
||||
|
||||
@@ -9,6 +9,6 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c
|
||||
|
||||
The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions.
|
||||
|
||||
Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise.
|
||||
Pick one install route. Codeman injects the skill into each new session's folder itself, on by default since 1.50.0 (App Settings, Agents & CLIs, Codeman Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise: turn that setting off, or skip the plugin, if it bothers you.
|
||||
|
||||
This directory is a mirror of [`skills/codeman`](../../skills/codeman) in the main repository, kept byte-identical by `scripts/sync-plugin.mjs` and pinned by a test. Edit the source there, never here. `npm run check:plugin` (repo root, needs the `claude` CLI) checks the mirror and validates both manifests. Source, issues and the rest of Codeman: https://github.com/Ark0N/Codeman
|
||||
@@ -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);
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+6
-2
@@ -250,7 +250,7 @@ function describeSkillResult(result: AgentSkillApplyResult, target: string): { o
|
||||
},
|
||||
symlink: {
|
||||
ok: false,
|
||||
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
|
||||
text: `${target}, its parent or its reference folder is a symlink, refusing to write through it.`,
|
||||
},
|
||||
};
|
||||
return messages[result];
|
||||
@@ -320,7 +320,11 @@ skillCmd
|
||||
if (result === 'symlink') {
|
||||
// A shared-skills layout (`.agents/skills -> ../.claude/skills`) is deliberate:
|
||||
// skip it rather than fail the whole sweep.
|
||||
console.log(palette.warn(`- Skipped ${target}: it (or its parent) is a symlink, so it was not touched`));
|
||||
console.log(
|
||||
palette.warn(
|
||||
`- Skipped ${target}: it, its parent or its reference folder is a symlink, so it was not touched`
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
removedAny = true;
|
||||
|
||||
+103
-5
@@ -1358,6 +1358,87 @@ async function isSymlink(path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a folder INSIDE the copy that holds skill files (today only `reference/`) is a
|
||||
* symlink. The checks on the copy and its parent stop at the copy itself, and a write or
|
||||
* unlink of `reference/<name>.md` follows a linked `reference/` wherever it points:
|
||||
* measured, `reference -> <elsewhere>` made install overwrite a `history.md` there and
|
||||
* uninstall delete it. A cloned repository can commit that link, and a Docker case's
|
||||
* container can plant it in the bind-mounted workspace that the host injects into, so
|
||||
* both are refused like a linked copy. Checked once before the first write or unlink; a
|
||||
* link swapped in after the check (a race against the container) is not covered.
|
||||
*/
|
||||
async function agentSkillSubdirIsSymlink(skillDir: string, files: AgentSkillFile[]): Promise<boolean> {
|
||||
const subdirs = new Set<string>(['reference']);
|
||||
for (const file of files) {
|
||||
// Every folder on the way to the file, outermost first: `a/b/x.md` checks `a`, then `a/b`.
|
||||
const parts = dirname(file.relPath)
|
||||
.split(/[\\/]/)
|
||||
.filter((part) => part !== '' && part !== '.');
|
||||
for (let i = 1; i <= parts.length; i++) subdirs.add(join(...parts.slice(0, i)));
|
||||
}
|
||||
for (const subdir of subdirs) {
|
||||
if (await isSymlink(join(skillDir, subdir))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ignore file every Codeman-managed skill copy carries, and what it holds: `*`
|
||||
* matches everything in the copy's own folder (the ignore file included), so a user's
|
||||
* repository never lists `.claude/skills/codeman/` (or `.agents/skills/codeman/`) as
|
||||
* untracked, without Codeman touching the repository's own `.gitignore` or
|
||||
* `info/exclude`. Same idea as the prompt-uploads folder (`ensureUploadDir`).
|
||||
*
|
||||
* Deliberately NOT part of `readAgentSkillSource()`: it is written once with O_EXCL and
|
||||
* never compared or rewritten, so a file the user put there (or edited) is theirs.
|
||||
*
|
||||
* Measured against Claude Code 2.1.319, whose skill discovery runs `git check-ignore`
|
||||
* only on directories at or above `.claude/skills` (the nested-skills walk), never on a
|
||||
* skill folder or its SKILL.md: a `*` inside `codeman/` ignores the folder's CONTENTS,
|
||||
* not the folder, so the skill still loads. Codex 0.162 does no gitignore check on skills.
|
||||
*/
|
||||
const AGENT_SKILL_GITIGNORE = '.gitignore';
|
||||
const AGENT_SKILL_GITIGNORE_CONTENT = '*\n';
|
||||
|
||||
/**
|
||||
* Write the copy's `.gitignore` if none is there. Returns whether it wrote one.
|
||||
* 'wx' (O_CREAT|O_EXCL) never replaces an existing file and refuses a symlink at the
|
||||
* name. A create that succeeded before the write failed (ENOSPC) would leave an empty
|
||||
* file that reads as the user's next time, so it is taken back; that cleanup's own
|
||||
* failure must not replace the cause.
|
||||
*/
|
||||
async function ensureAgentSkillGitignore(skillDir: string): Promise<boolean> {
|
||||
const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE);
|
||||
try {
|
||||
await writeFile(ignoreFile, AGENT_SKILL_GITIGNORE_CONTENT, { flag: 'wx' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
||||
await unlink(ignoreFile).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the copy's `.gitignore` only when it is the LAST thing in the folder and holds
|
||||
* exactly what Codeman writes. Anything else in the folder (a user's own notes) keeps
|
||||
* the ignore file, so those files stay out of `git status` as they were, and a
|
||||
* `.gitignore` with other content is the user's.
|
||||
*/
|
||||
async function removeAgentSkillGitignoreIfLast(skillDir: string): Promise<void> {
|
||||
const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE);
|
||||
try {
|
||||
const entries = await readdir(skillDir);
|
||||
if (entries.length !== 1 || entries[0] !== AGENT_SKILL_GITIGNORE) return;
|
||||
if (!(await lstat(ignoreFile)).isFile()) return;
|
||||
if ((await readFile(ignoreFile, 'utf-8')) !== AGENT_SKILL_GITIGNORE_CONTENT) return;
|
||||
await unlink(ignoreFile);
|
||||
} catch {
|
||||
// best-effort: a folder that stays behind holds only an ignore file
|
||||
}
|
||||
}
|
||||
|
||||
/** What an install/remove actually did, so callers (CLI, logs) can say so. */
|
||||
export type AgentSkillApplyResult =
|
||||
| 'installed' // fresh copy written
|
||||
@@ -1366,23 +1447,31 @@ export type AgentSkillApplyResult =
|
||||
| 'removed' // our copy deleted
|
||||
| 'absent' // nothing there to remove
|
||||
| 'foreign' // a copy exists but is not ours; left untouched
|
||||
| 'symlink'; // the skill dir (or its parent) is a symlink; left untouched
|
||||
| 'symlink'; // the skill dir, its parent or a folder inside it is a symlink; left untouched
|
||||
|
||||
/**
|
||||
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
|
||||
* directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
|
||||
* `<case>/.agents/skills/codeman`).
|
||||
*
|
||||
* Refuses two shapes rather than writing through them:
|
||||
* Refuses these shapes rather than writing through them:
|
||||
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
|
||||
* layout (`.claude/skills/codeman -> ../../skills/codeman`) would otherwise have
|
||||
* the injector overwrite the repo source through the link;
|
||||
* - a SYMLINK at a folder inside the copy (`reference/`), which would carry the skill's
|
||||
* files to wherever it points (`agentSkillSubdirIsSymlink`);
|
||||
* - a FOREIGN copy (SKILL.md present without our marker): that is the user's own
|
||||
* skill, and per the statusLine rule we never clobber what we did not write.
|
||||
*
|
||||
* Idempotent and cheap: unchanged files are not rewritten, so calling on every
|
||||
* session create causes no mtime churn.
|
||||
*
|
||||
* The copy ignores itself in git: a `.gitignore` of `*` is written FIRST, before any
|
||||
* skill file, so a `git status` that runs mid-install never lists the folder either
|
||||
* (`ensureAgentSkillGitignore`). It is written once and never rewritten; a copy made
|
||||
* before this existed gains it on its next install or refresh, which then reports
|
||||
* 'refreshed'.
|
||||
*
|
||||
* Serialized on the skill dir through the same lock the settings writers use: two
|
||||
* sessions created at once in one repo both inject this skill, and interleaving their
|
||||
* ownership read with the other's write reports a bogus result (an 'unchanged' for a
|
||||
@@ -1402,7 +1491,9 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
||||
if (existing !== null && !existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
|
||||
|
||||
const files = await readAgentSkillSource();
|
||||
let changed = false;
|
||||
if (await agentSkillSubdirIsSymlink(skillDir, files)) return 'symlink';
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
let changed = await ensureAgentSkillGitignore(skillDir);
|
||||
for (const file of files) {
|
||||
const target = join(skillDir, file.relPath);
|
||||
let current: string | null = null;
|
||||
@@ -1544,7 +1635,8 @@ export async function refreshUserAgentSkill(): Promise<AgentSkillApplyResult | '
|
||||
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
|
||||
* refusals as the install path. Deletes only files the packaged source would have
|
||||
* written (never `rm -rf`, so a user's extra files in the directory survive), then
|
||||
* prunes the directories bottom-up if they emptied.
|
||||
* the copy's own `.gitignore` when nothing else is left beside it, then prunes the
|
||||
* directories bottom-up if they emptied.
|
||||
*
|
||||
* Shares the install path's per-dir lock so an uninstall can't run between an install's
|
||||
* ownership read and its writes, which would leave half the skill back on disk.
|
||||
@@ -1564,10 +1656,13 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
|
||||
// Manifest-based, with SKILL.md as the fallback when the packaged source is
|
||||
// unreadable: removal must still work on an install whose skills/ dir went missing.
|
||||
const files = await readAgentSkillSource().catch((): AgentSkillFile[] => [{ relPath: 'SKILL.md', content: '' }]);
|
||||
// An unlink through a linked `reference/` deletes files wherever it points.
|
||||
if (await agentSkillSubdirIsSymlink(skillDir, files)) return 'symlink';
|
||||
for (const file of files) {
|
||||
await unlink(join(skillDir, file.relPath)).catch(() => {});
|
||||
}
|
||||
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
|
||||
await removeAgentSkillGitignoreIfLast(skillDir);
|
||||
await rmdir(skillDir).catch(() => {});
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
|
||||
return 'removed';
|
||||
@@ -1601,7 +1696,10 @@ export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string {
|
||||
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
|
||||
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
|
||||
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
|
||||
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
|
||||
* shape. Gated by the synced `agentSkillEnabled` app setting: default ON since 1.50.0
|
||||
* (an absent key is on, only an explicit false is off; `getAgentSkillEnabled()` in
|
||||
* server.ts), which is safe for a user's repository because the copy ignores itself
|
||||
* in git (`ensureAgentSkillGitignore`).
|
||||
*
|
||||
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
|
||||
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
|
||||
|
||||
+8
-8
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @fileoverview One-time boot migration that lets the 1.50.0 agent skill default reach installs
|
||||
* that saved App Settings before it.
|
||||
*
|
||||
* `agentSkillEnabled` is ON by default since 1.50.0 (an absent key is on, only an explicit false
|
||||
* is off). Up to 1.49.0 the App Settings switch loaded an absent key as off (`?? false`) and Save
|
||||
* sends the whole form, so every install whose App Settings were ever saved holds an explicit
|
||||
* `false` the user may never have chosen, and the new default would never reach it. On the first
|
||||
* start of a server that has this module, a stored `false` is switched on, exactly once per data
|
||||
* dir. It cannot tell a deliberate off from the old default; that is accepted and stated as a
|
||||
* behaviour change in the release notes. An off set after this ran is never touched again.
|
||||
*
|
||||
* Why a stored `false` becomes `true` rather than being deleted: deleting it would not reach the
|
||||
* page. Every browser that saved App Settings keeps the form in its localStorage blob, and
|
||||
* `loadAppSettingsFromServer()` only overwrites a synced key the server HAS: a key the server
|
||||
* lacks keeps the device's `false`, the switch loads off, and the next Save of any setting writes
|
||||
* `false` back (measured with the real page code in the JSDOM harness of
|
||||
* test/agent-skill-setting-ui.test.ts, which pins the shipped shape). A stored `true` wins on
|
||||
* every device, including a browser still running a cached settings-ui.js from before the upgrade.
|
||||
* Every reader treats `true` and an absent key alike (`!== false`).
|
||||
*
|
||||
* Why the "done" marker is its own file (`agent-skill-default-migrated.json` in the data dir)
|
||||
* and not a key in settings.json:
|
||||
* - it is not a setting, so `GET /api/settings` never returns it, the page never holds it, and
|
||||
* the page's Save (rebuilt from the form) can neither send it back nor drop it; the `.strict()`
|
||||
* `SettingsUpdateSchema` never sees it;
|
||||
* - a PUT or any other settings.json writer cannot erase it, which would re-arm the migration and
|
||||
* switch a deliberate later off back on;
|
||||
* - it is not in `CONFIG_BACKUP_FILES` (like `config-backup-id`), so a restore leaves it alone:
|
||||
* once the migration has run, a restored settings.json, even one from before 1.50.0 holding
|
||||
* `false`, is what the user asked for and stays as restored.
|
||||
* A data dir is one instance, and settings.json is one file for the whole server, so in
|
||||
* multi-user mode this runs once for the server, never per user.
|
||||
*
|
||||
* Order: the marker is checked first (present: nothing is read), the settings change goes through
|
||||
* `mutateSettingsFile()` (serialized, atomic, refuses a file that does not parse), and the marker
|
||||
* is written only after that resolves. A refused or failed settings write writes no marker, so the
|
||||
* migration runs again at the next start (after the user fixed the file). Until then it has not
|
||||
* run, so an off saved after the fix and before that start is switched on with the rest: the same
|
||||
* "cannot tell" trade as above, in a window that needs an unparseable settings.json at the first
|
||||
* 1.50.0 start. A crash between the two writes is harmless: the next start finds `true` (nothing
|
||||
* to change) and writes the marker.
|
||||
*
|
||||
* Called once from `WebServer.start()`, before the first settings read and before the server
|
||||
* listens, so no request path can see the old value.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { getErrorMessage } from '../types.js';
|
||||
import { mutateSettingsFile, SettingsWriteRefusedError } from './settings-writer.js';
|
||||
|
||||
/** The marker's file name in the data dir. Never in CONFIG_BACKUP_FILES (see the file comment). */
|
||||
export const AGENT_SKILL_DEFAULT_MARKER = 'agent-skill-default-migrated.json';
|
||||
|
||||
/**
|
||||
* - `switched-on`: settings.json held `agentSkillEnabled: false`; it now holds `true`.
|
||||
* - `nothing-to-change`: the key was absent or not `false` (or there is no settings.json); nothing was written to it.
|
||||
* - `already-done`: the marker exists; settings.json was not read.
|
||||
* - `refused`: settings.json could not be read, parsed or written, or the marker could not be checked.
|
||||
* Nothing changed and no marker was written, so the next start tries again.
|
||||
*/
|
||||
export type AgentSkillDefaultMigration = 'switched-on' | 'nothing-to-change' | 'already-done' | 'refused';
|
||||
|
||||
/** The log line of a migration that switched the skill on. */
|
||||
export const AGENT_SKILL_DEFAULT_MIGRATED_LOG =
|
||||
'[agent-skill] switched on the off stored before 1.50.0, so the new default applies (turn it off again under App Settings, Agents & CLIs, Codeman Agent Skill; that choice is kept)';
|
||||
|
||||
export async function migrateAgentSkillDefault(
|
||||
markerPath: string = dataPath(AGENT_SKILL_DEFAULT_MARKER)
|
||||
): Promise<AgentSkillDefaultMigration> {
|
||||
try {
|
||||
await fs.lstat(markerPath);
|
||||
return 'already-done';
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// Not knowing whether it ran must not switch a deliberate off on a second time.
|
||||
console.warn(`[agent-skill] default migration skipped: cannot check ${markerPath} (${getErrorMessage(err)})`);
|
||||
return 'refused';
|
||||
}
|
||||
}
|
||||
|
||||
let switched = false;
|
||||
try {
|
||||
await mutateSettingsFile((current) => {
|
||||
if (current.agentSkillEnabled !== false) return undefined;
|
||||
switched = true;
|
||||
return { ...current, agentSkillEnabled: true };
|
||||
});
|
||||
} catch (err) {
|
||||
// A refusal is already logged by the writer (`[settings] write refused: ...`), with the fix.
|
||||
const why = err instanceof SettingsWriteRefusedError ? 'settings.json was left as it is' : getErrorMessage(err);
|
||||
console.warn(`[agent-skill] default migration skipped (${why}); it runs again at the next start`);
|
||||
return 'refused';
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(dirname(markerPath), { recursive: true });
|
||||
const marker = { migratedAt: new Date().toISOString(), switchedOn: switched };
|
||||
await fs.writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
||||
} catch (err) {
|
||||
// settings.json is already right; without the marker the next start runs again, which
|
||||
// changes nothing unless the user has switched it off by then.
|
||||
console.warn(`[agent-skill] could not record the default migration at ${markerPath}: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
if (switched) console.log(AGENT_SKILL_DEFAULT_MIGRATED_LOG);
|
||||
return switched ? 'switched-on' : 'nothing-to-change';
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export interface ConfigPort {
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
||||
/** Synced `agentSkillEnabled` app setting (default ON, only an explicit false is off); gates per-case agent-skill injection. */
|
||||
getAgentSkillEnabled(): Promise<boolean>;
|
||||
/** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */
|
||||
getWorkspaceHooksEnabled(): Promise<boolean>;
|
||||
|
||||
@@ -2941,7 +2941,7 @@
|
||||
<div class="set-row" data-search="agent skill codeman api workers codex gemini opencode pi grok deepseek omp antigravity">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell.</span>
|
||||
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi, GitHub Copilot and a plain shell. On by default. The copy ignores itself in git, so your repository never lists it as a new file. Off stops new copies but removes none: <code>codeman skill uninstall --case <name></code> does that.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
|
||||
@@ -707,7 +707,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyCodexSettingsVisibility();
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false;
|
||||
// Default ON (since 1.50.0), like the workspace hooks below: the server reads an
|
||||
// absent key as on, so the switch must too, or the next Save would write false.
|
||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled !== false;
|
||||
// Default ON: an absent key is a user who has never seen this setting, and OFF
|
||||
// for them means no tab alerts in any workspace Codeman did not scaffold.
|
||||
document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false;
|
||||
@@ -5266,6 +5268,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
// The agent skill is a synced key that is ON when absent (since 1.50.0). A server
|
||||
// without the key means that default, so this device's own copy goes rather than
|
||||
// standing in for it: up to 1.49.0 the switch loaded an absent key as off, so a
|
||||
// device whose save never reached the server still holds `false`, and its next
|
||||
// Save would write it back. The server's one-time boot migration stores `true` for
|
||||
// installs that had saved `false` (agent-skill-default-migration.ts).
|
||||
if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled;
|
||||
this.saveAppSettingsToStorage(merged);
|
||||
|
||||
// Apply notification prefs from server if present (only if localStorage has none)
|
||||
|
||||
@@ -978,8 +978,8 @@ export function _paneDeathWatcherCount(): number {
|
||||
*
|
||||
* `applyAgentSkill` declines two shapes rather than writing through them ('foreign':
|
||||
* an unmarked skills/codeman the user authored; 'symlink': the skill dir or its
|
||||
* parent is a link). Both were silent: the user flips `agentSkillEnabled` on, nothing
|
||||
* appears in the case, and there is nowhere to look for why. The ordinary outcomes
|
||||
* parent is a link). Both were silent: `agentSkillEnabled` is on (the default since
|
||||
* 1.50.0), nothing appears in the case, and there is nowhere to look for why. The ordinary outcomes
|
||||
* ('installed'/'refreshed'/'unchanged') stay unlogged since they would print on every
|
||||
* single session create.
|
||||
*
|
||||
@@ -1003,7 +1003,7 @@ async function injectAgentSkill(casePath: string, dir: AgentSkillDir): Promise<v
|
||||
);
|
||||
} else if (result === 'symlink') {
|
||||
console.warn(
|
||||
`[agent-skill] not injected: ${skillDir} (or its parent) is a symlink, refusing to write through it. Replace it with a real directory to let Codeman install the skill.`
|
||||
`[agent-skill] not injected: ${skillDir}, its parent or its reference folder is a symlink, refusing to write through it. Replace it with a real directory to let Codeman install the skill.`
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+7
-5
@@ -1471,11 +1471,13 @@ export const SettingsUpdateSchema = z
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Inject the Codeman agent skill (`skills/codeman`) into `<case>/.claude/skills/`
|
||||
* on Claude session create, so an agent inside the session can drive the API
|
||||
* (see docs/agent-control-plan.md §2). SYNCED, default OFF: every skill's
|
||||
* name+description costs context on every turn, so it is opt-in. Injection is
|
||||
* add-only at create; a marker keeps user-authored copies untouched.
|
||||
* Inject the Codeman agent skill (`skills/codeman`) into each new session's case, in
|
||||
* the directory its CLI scans for skills (`capabilities.agentSkillDir`), so an agent
|
||||
* inside the session can drive the API (see docs/agent-control-plan.md §2). SYNCED,
|
||||
* default ON since 1.50.0: an absent key is on, only an explicit false is off, and a
|
||||
* false stored by 1.49.0 or earlier is switched on once at the first 1.50.0 start
|
||||
* (agent-skill-default-migration.ts). Injection is add-only at create; a marker keeps
|
||||
* user-authored copies untouched.
|
||||
*/
|
||||
agentSkillEnabled: z.boolean().optional(),
|
||||
/**
|
||||
|
||||
+16
-3
@@ -195,6 +195,7 @@ import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
|
||||
import { mutateSettingsFile } from './settings-writer.js';
|
||||
import { migrateAgentSkillDefault } from './agent-skill-default-migration.js';
|
||||
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
|
||||
import { telemetrySignature } from '../usage-telemetry.js';
|
||||
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
|
||||
@@ -2450,11 +2451,15 @@ export class WebServer extends EventEmitter {
|
||||
return resolveTerminalHistoryConfig(settings);
|
||||
}
|
||||
|
||||
// Whether the Codeman agent skill is injected into cases on Claude session create
|
||||
// (synced `agentSkillEnabled` setting, default OFF; docs/agent-control-plan.md §2).
|
||||
// Whether the Codeman agent skill is injected into a session's workspace on create
|
||||
// (synced `agentSkillEnabled` setting; docs/agent-control-plan.md §2). Default ON
|
||||
// since 1.50.0, like `workspaceHooksEnabled` below: an absent key means a user who
|
||||
// has never touched the switch, and only an explicit false turns it off. The copy
|
||||
// ignores itself in git (`.gitignore` of `*`, see installAgentSkillInto), so the
|
||||
// default leaves a user's `git status` clean.
|
||||
private async getAgentSkillEnabled(): Promise<boolean> {
|
||||
const settings = await this.readSettings();
|
||||
return settings.agentSkillEnabled === true;
|
||||
return settings.agentSkillEnabled !== false;
|
||||
}
|
||||
|
||||
// Whether pending agent-inbox mail is snapshotted to disk (synced
|
||||
@@ -3397,6 +3402,14 @@ export class WebServer extends EventEmitter {
|
||||
reconcileUpdateOnBoot();
|
||||
}
|
||||
|
||||
// One-time, once per data dir: a stored `agentSkillEnabled: false` written by the switch's
|
||||
// old default (up to 1.49.0) becomes true, so the 1.50.0 default reaches installs that ever
|
||||
// saved App Settings (agent-skill-default-migration.ts). Before the restore below reads
|
||||
// settings (2 s cache) and before listen, so no session create can see the old value.
|
||||
if (!this.testMode && (await migrateAgentSkillDefault()) === 'switched-on') {
|
||||
this._settingsCache = null;
|
||||
}
|
||||
|
||||
// Restore mux sessions BEFORE accepting connections
|
||||
// This prevents race conditions where clients connect before state is ready
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @fileoverview The one-time boot migration that lets the 1.50.0 agent skill default reach
|
||||
* installs that saved App Settings before it (src/web/agent-skill-default-migration.ts).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - An absent key, a stored `true` or no settings.json at all: settings.json is not written
|
||||
* (byte-identical, odd formatting kept) and the marker is set.
|
||||
* - A stored `false` (what every 1.49 save wrote, chosen or not) becomes `true`, every other key
|
||||
* kept, exactly once: the log line prints once, and a second start reports `already-done`
|
||||
* without reading the file.
|
||||
* - An off set AFTER the migration stays off: through the real `PUT /api/settings` (what the
|
||||
* page's Save sends), then a second start. The PUT neither writes nor drops the marker, and
|
||||
* `GET /api/settings` never returns it, so the page cannot hold it to send it back.
|
||||
* - A settings.json that does not parse is left byte-identical, no marker is written, nothing
|
||||
* throws, and the next start (after the fix) does the migration.
|
||||
* - A marker that cannot be checked switches nothing on (a deliberate off must not be cleared
|
||||
* twice).
|
||||
* - The marker is not a config-backup file, and `WebServer.start()` runs the migration before
|
||||
* its first settings read and before it listens (source wiring guard: the boot call is skipped
|
||||
* in test mode, like the other boot-time writes).
|
||||
*
|
||||
* The page's side (a device blob holding the old `false`) is pinned in
|
||||
* test/agent-skill-setting-ui.test.ts.
|
||||
*
|
||||
* Port: 0 (one WebServer for the PUT/GET cases).
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
import { dataPath } from '../src/config/instance.js';
|
||||
import { CONFIG_BACKUP_FILES } from '../src/config-backup.js';
|
||||
import {
|
||||
AGENT_SKILL_DEFAULT_MARKER,
|
||||
AGENT_SKILL_DEFAULT_MIGRATED_LOG,
|
||||
migrateAgentSkillDefault,
|
||||
} from '../src/web/agent-skill-default-migration.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const MARKER = dataPath(AGENT_SKILL_DEFAULT_MARKER);
|
||||
|
||||
/** Written with a formatting no writer of ours produces, so any rewrite shows in the bytes. */
|
||||
const seedRaw = (text: string) => writeFileSync(SETTINGS_PATH, text);
|
||||
const seed = (value: unknown) => seedRaw(JSON.stringify(value));
|
||||
const raw = () => readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
const stored = () => JSON.parse(raw()) as Record<string, unknown>;
|
||||
const marker = () => JSON.parse(readFileSync(MARKER, 'utf-8')) as { migratedAt: string; switchedOn: boolean };
|
||||
|
||||
/** The bytes of a torn settings.json (test/settings-json-write-serialization.test.ts). */
|
||||
const TORN = '{\n "agentSkillEnabled": false\n}' + 'rt-1761000000000"\n}';
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
const migratedLogs = () => logSpy.mock.calls.filter(([line]) => line === AGENT_SKILL_DEFAULT_MIGRATED_LOG).length;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
rmSync(MARKER, { force: true });
|
||||
logSpy = vi.spyOn(console, 'log');
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('nothing to switch on', () => {
|
||||
it('an absent key: settings.json is not written, the marker is set', async () => {
|
||||
seed({ lastUsedCase: 'demo' });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
expect(migratedLogs()).toBe(0);
|
||||
});
|
||||
|
||||
it('no settings.json at all: none is created, the marker is set', async () => {
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
|
||||
it('a stored true is left untouched', async () => {
|
||||
seed({ agentSkillEnabled: true, caseOrder: ['a'] });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stored false from before 1.50.0', () => {
|
||||
it('is switched on once, every other key kept, with one log line', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(marker()).toMatchObject({ switchedOn: true });
|
||||
expect(Number.isNaN(Date.parse(marker().migratedAt))).toBe(false);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
|
||||
// A second start finds the marker and reads nothing.
|
||||
const after = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(raw()).toBe(after);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
});
|
||||
|
||||
it('a false written by hand after the migration is never switched on again', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a settings.json that does not parse', () => {
|
||||
it('is left alone, writes no marker, does not throw, and the next start after the fix migrates', async () => {
|
||||
seedRaw(TORN);
|
||||
expect(await migrateAgentSkillDefault()).toBe('refused');
|
||||
expect(raw()).toBe(TORN);
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
expect(
|
||||
warnSpy.mock.calls.some(([line]) => String(line).startsWith('[agent-skill] default migration skipped'))
|
||||
).toBe(true);
|
||||
|
||||
// The user repairs the file (or restores a backup): the migration has not run yet, so it runs now.
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a marker that cannot be checked', () => {
|
||||
it('switches nothing on and writes no marker', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
// A path below a regular file: lstat answers ENOTDIR, not ENOENT.
|
||||
expect(await migrateAgentSkillDefault(join(SETTINGS_PATH, 'marker.json'))).toBe('refused');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the page and the server (port 0)', () => {
|
||||
let server: WebServer;
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
}, 30000);
|
||||
|
||||
it('an off saved through PUT /api/settings after the migration stays off at the next start, and the PUT leaves the marker alone', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo' });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
const markerBytes = readFileSync(MARKER, 'utf-8');
|
||||
|
||||
// What the page sends when the user turns the switch off (plus another synced key).
|
||||
const res = await fetch(url('/api/settings'), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: false, autoNameSessions: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false, lastUsedCase: 'demo', autoNameSessions: true });
|
||||
expect(readFileSync(MARKER, 'utf-8')).toBe(markerBytes);
|
||||
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/settings never carries the marker, so the page cannot hold or send it', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
await migrateAgentSkillDefault();
|
||||
const res = await fetch(url('/api/settings'));
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const data = (body.success === true ? body.data : body) as Record<string, unknown>;
|
||||
expect(data).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('placement', () => {
|
||||
it('the marker is not a config-backup file, so a restore never re-arms or drops it', () => {
|
||||
expect(CONFIG_BACKUP_FILES as readonly string[]).not.toContain(AGENT_SKILL_DEFAULT_MARKER);
|
||||
});
|
||||
|
||||
it('WebServer.start() runs it before its first settings read, the restore and listen, and drops the settings cache', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
|
||||
const start = source.slice(source.indexOf(' async start(): Promise<void> {'));
|
||||
const call = start.indexOf('await migrateAgentSkillDefault()');
|
||||
expect(call).toBeGreaterThan(0);
|
||||
for (const later of [
|
||||
'await this.getAgentInboxPersistEnabled()',
|
||||
'await this.restoreMuxSessions()',
|
||||
'await this.app.listen(',
|
||||
]) {
|
||||
expect(start.indexOf(later), later).toBeGreaterThan(call);
|
||||
}
|
||||
expect(start.slice(call, call + 200)).toContain('this._settingsCache = null;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview The Codeman Agent Skill switch (`agentSkillEnabled`) in App Settings,
|
||||
* on by default since 1.50.0.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The REAL openAppSettings() reads an ABSENT key as on, like the server's
|
||||
* getAgentSkillEnabled() (`!== false`). The page and the server must agree: a
|
||||
* page that showed an absent key as off would write `false` on the next Save of
|
||||
* any other setting, and the default would be gone without the user touching it.
|
||||
* - Only an explicit false shows the switch off, wherever it came from (this
|
||||
* device's blob or the server's settings.json, merged as a synced key).
|
||||
* - saveAppSettings() sends the switch in the settings PUT, and the body passes the
|
||||
* .strict() SettingsUpdateSchema (an undeclared key 400s every save).
|
||||
* - A device that saved on 1.49.0 or earlier keeps `false` in its own blob. After the
|
||||
* server's one-time boot migration (stored true) its switch shows on and a Save keeps it
|
||||
* on; on a server without the key the device's copy is dropped for the default; a later
|
||||
* explicit off on the server stays off. The Save body never carries the migration marker.
|
||||
* - The row says it is on by default, that the copy stays out of git, and how to
|
||||
* remove copies already written (turning it off sweeps nothing).
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), with the
|
||||
* harness of test/tab-cli-logos-setting.test.ts.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
|
||||
type Settings = Record<string, unknown>;
|
||||
interface SettingsApp {
|
||||
openAppSettings(): void;
|
||||
saveAppSettings(): Promise<void>;
|
||||
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
||||
_apiPut: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/** Methods that run for real; every other method of the app is a no-op here. */
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
// Real so the save builds the body a browser would send (the schema check).
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
'_displaySettingKeySet',
|
||||
]);
|
||||
|
||||
/** Methods the load/save paths call that other modules add to the prototype. */
|
||||
const ELSEWHERE = [
|
||||
'showToast',
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
||||
async function boot(stored?: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
||||
windows.push(win);
|
||||
// After load, so app.js's DOMContentLoaded boot never runs; only the prototype is under test.
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
if (stored) win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => 'desktop',
|
||||
isHandheldDevice: () => false,
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as SettingsApp;
|
||||
const checkbox = () => win.document.getElementById('appSettingsAgentSkill') as HTMLInputElement;
|
||||
const putBody = () => {
|
||||
const calls = app._apiPut.mock.calls as [string, Settings][];
|
||||
const call = calls.find(([path]) => path === '/api/settings');
|
||||
expect(call, 'a settings PUT').toBeDefined();
|
||||
return call![1];
|
||||
};
|
||||
return { app, checkbox, putBody };
|
||||
}
|
||||
|
||||
describe('the switch loads on by default', () => {
|
||||
it('an absent key shows on, an explicit false off, an explicit true on', async () => {
|
||||
for (const [stored, expected] of [
|
||||
[{}, true],
|
||||
[{ agentSkillEnabled: false }, false],
|
||||
[{ agentSkillEnabled: true }, true],
|
||||
] as const) {
|
||||
const { app, checkbox } = await boot(stored);
|
||||
checkbox().checked = !expected;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('a fresh device on a server with no stored value shows on', async () => {
|
||||
const { app, checkbox } = await boot();
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
||||
checkbox().checked = false;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("the server's explicit false wins over this device's blob (a synced key)", async () => {
|
||||
const { app, checkbox } = await boot({ agentSkillEnabled: true });
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save', () => {
|
||||
it('saving other settings on a never-touched install keeps it on, and the body passes the schema', async () => {
|
||||
const { app, putBody } = await boot({});
|
||||
app.openAppSettings();
|
||||
await app.saveAppSettings();
|
||||
const body = putBody();
|
||||
expect(body.agentSkillEnabled).toBe(true);
|
||||
// The whole body, not just this key: an undeclared key 400s every save.
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('switching it off sends an explicit false', async () => {
|
||||
const { app, checkbox, putBody } = await boot({});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Every device that saved App Settings on 1.49.0 or earlier holds `agentSkillEnabled: false` in
|
||||
// its own blob (the switch loaded an absent key as off). The server's boot migration
|
||||
// (src/web/agent-skill-default-migration.ts) switches a stored false on by storing TRUE, because
|
||||
// the merge only overwrites a synced key the server has: a key it deleted would leave the
|
||||
// device's false in place, and the next Save would write it back.
|
||||
describe('a device that saved before 1.50.0', () => {
|
||||
const OLD_BLOB = { agentSkillEnabled: false, autoNameSessions: false };
|
||||
|
||||
it("after the server's migration (stored true) the switch shows on, and a Save keeps it on and passes the schema", async () => {
|
||||
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: true, lastUsedCase: 'demo' }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
await app.saveAppSettings();
|
||||
const body = putBody();
|
||||
expect(body.agentSkillEnabled).toBe(true);
|
||||
// The migration's marker is a file of its own, never a setting: the Save can neither send it
|
||||
// back nor be refused for it by the .strict() schema.
|
||||
expect(Object.keys(body).filter((key) => /migrat/i.test(key))).toEqual([]);
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('on a server without the key (a save that never reached it) the switch shows the default, on', async () => {
|
||||
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('an off the user saved after the migration stays off on every device', async () => {
|
||||
const { app, checkbox, putBody } = await boot({ agentSkillEnabled: true });
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('without a server answer the device keeps what it has', async () => {
|
||||
const { app, checkbox } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve(null as unknown as Settings));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the App Settings row', () => {
|
||||
const doc = new JSDOM(INDEX).window.document;
|
||||
const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement;
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
|
||||
it('is a synced switch', () => {
|
||||
expect((doc.getElementById('appSettingsAgentSkill') as HTMLInputElement).type).toBe('checkbox');
|
||||
expect(row.closest('.set-group')!.querySelector('.set-scope')!.textContent).toBe('synced');
|
||||
});
|
||||
|
||||
it('says it is on by default, that git does not list the copy, and how to remove copies', () => {
|
||||
expect(desc).toContain('On by default');
|
||||
expect(desc).toContain('ignores itself in git');
|
||||
expect(desc).toContain('codeman skill uninstall --case <name>');
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,8 @@ describe('applyAgentSkill with a directory', () => {
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
// It ignores itself in git there too, like the Claude Code copy.
|
||||
expect(await readFile(join(skillDir, '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
||||
});
|
||||
|
||||
+168
-1
@@ -7,12 +7,14 @@
|
||||
* publish without them would be caught here before the `files` entry silently
|
||||
* ignores the missing directory.
|
||||
*
|
||||
* Pure filesystem tests in a per-test temp dir. Port: N/A.
|
||||
* Filesystem tests in a per-test temp dir; the self-ignoring block also runs real
|
||||
* `git` there (no global or system config). Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import {
|
||||
@@ -92,6 +94,8 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
|
||||
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
|
||||
// Not even the ignore file: a foreign copy is the user's to track or not.
|
||||
expect(existsSync(join(skillDir(), '.gitignore'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
|
||||
@@ -107,9 +111,61 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
|
||||
});
|
||||
|
||||
// A cloned repository can commit `reference` as a link, and a Docker case's container
|
||||
// can plant one in the bind-mounted workspace the host injects into (on by default).
|
||||
it('refuses a symlinked reference/ folder inside the copy, so nothing is written where it points', async () => {
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'history.md'), 'my notes\n');
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['history.md']);
|
||||
expect(await readFile(join(outside, 'history.md'), 'utf-8')).toBe('my notes\n');
|
||||
// Refused before the first write: no SKILL.md, no ignore file.
|
||||
expect(await readdir(skillDir())).toEqual(['reference']);
|
||||
});
|
||||
|
||||
it('refuses a symlinked reference/ folder planted beside an existing Codeman copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'recipes.md'), 'my recipes\n');
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['recipes.md']);
|
||||
expect(await readFile(join(outside, 'recipes.md'), 'utf-8')).toBe('my recipes\n');
|
||||
// The stale copy is left as found, not refreshed through the link.
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).not.toBe(skillMd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
it('refuses to delete through a symlinked reference/ folder inside the copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
// Files named like the packaged reference files: an unlink through the link would take them.
|
||||
for (const name of await readdir(join(skillDir(), 'reference'))) {
|
||||
await writeFile(join(outside, name), 'not yours\n');
|
||||
}
|
||||
const names = (await readdir(outside)).sort();
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('symlink');
|
||||
expect((await readdir(outside)).sort()).toEqual(names);
|
||||
for (const name of names) expect(await readFile(join(outside, name), 'utf-8')).toBe('not yours\n');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes our copy and prunes the emptied directories', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
@@ -139,6 +195,117 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
|
||||
// The user's file and the directories holding it survive.
|
||||
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
||||
// And so does the ignore file, so their notes stay out of `git status` as before.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the injected copy ignores itself in git', () => {
|
||||
const ENV = {
|
||||
...process.env,
|
||||
LC_ALL: 'C',
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, { cwd: casePath, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
/** Exit status of `git check-ignore -q -- <path>`: 0 ignored, 1 not ignored. */
|
||||
const checkIgnore = (path: string): number => {
|
||||
try {
|
||||
git('check-ignore', '-q', '--', path);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return (err as { status: number }).status;
|
||||
}
|
||||
};
|
||||
/** A repository with one commit and a tracked `.gitignore` of its own. */
|
||||
const initRepo = async () => {
|
||||
git('init', '-q', '-b', 'main');
|
||||
await writeFile(join(casePath, 'README.md'), 'hello\n');
|
||||
await writeFile(join(casePath, '.gitignore'), 'node_modules/\n');
|
||||
git('add', 'README.md', '.gitignore');
|
||||
git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init');
|
||||
};
|
||||
|
||||
it('writes a .gitignore of `*` into the copy on install', async () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
// Never part of the skill content: a second run has nothing to do.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('keeps `git status` of the user repository clean, and leaves its own ignore file alone', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
await applyAgentSkill(casePath, true, '.agents/skills');
|
||||
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
expect(await readFile(join(casePath, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
|
||||
// Nor the repository's local exclude file (git's template may or may not create one).
|
||||
const exclude = join(casePath, '.git', 'info', 'exclude');
|
||||
if (existsSync(exclude)) expect(await readFile(exclude, 'utf-8')).not.toContain('codeman');
|
||||
});
|
||||
|
||||
it('ignores the copy contents, never the folder above it (Claude Code checks only those)', async () => {
|
||||
// Claude Code 2.1.319 skips a nested skills folder when `git check-ignore` says its
|
||||
// project directory is ignored; it never checks a skill folder or its SKILL.md.
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(checkIgnore('.claude/skills/codeman/SKILL.md')).toBe(0);
|
||||
expect(checkIgnore('.claude/skills/codeman')).toBe(1);
|
||||
expect(checkIgnore('.claude/skills')).toBe(1);
|
||||
expect(checkIgnore('.claude')).toBe(1);
|
||||
});
|
||||
|
||||
it('never overwrites a .gitignore that is already in the folder', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), '# mine\nSKILL.md\n');
|
||||
// A stale copy: the refresh rewrites the skill but not the ignore file.
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('# mine\nSKILL.md\n');
|
||||
});
|
||||
|
||||
it('a copy injected before the ignore file existed gains it on the next install', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await rm(join(skillDir(), '.gitignore'));
|
||||
// Every skill file is current, so only the ignore file is new: still a refresh.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('does not write through a symlink planted at the ignore file name', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
const outside = join(casePath, 'outside.txt');
|
||||
await writeFile(outside, 'untouched\n');
|
||||
await symlink(outside, join(skillDir(), '.gitignore'));
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(outside, 'utf-8')).toBe('untouched\n');
|
||||
});
|
||||
|
||||
it('uninstall removes the ignore file with the copy, so no folder is left behind', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
});
|
||||
|
||||
it("uninstall keeps an ignore file that is not Codeman's", async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), 'SKILL.md\n');
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('SKILL.md\n');
|
||||
});
|
||||
|
||||
it('the packaged source carries no ignore file (the plugin mirror pins its file set)', () => {
|
||||
expect(existsSync(join(process.cwd(), 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), 'plugins', 'codeman', 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-edge-cases-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
let server: WebServer;
|
||||
@@ -279,7 +285,7 @@ describe('Concurrent Session Handling', () => {
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
}).then((r) => r.json())
|
||||
);
|
||||
|
||||
@@ -312,7 +318,7 @@ describe('Concurrent Session Handling', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
|
||||
@@ -128,10 +128,10 @@ export function createMockRouteContext(options?: {
|
||||
getModelConfig: vi.fn(async () => null),
|
||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
||||
// Default OFF mirrors the shipped setting, so existing tests never touch a
|
||||
// case's .claude/skills. Overridable per test because the create-time
|
||||
// injection call sites are otherwise unreachable from a route test.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
||||
// Default ON mirrors the shipped setting (since 1.50.0), so a route test sees
|
||||
// what a user sees. Writes land in the test's temp working dir, never in a real
|
||||
// repo. Overridable per test, so the OFF branch stays reachable.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? true),
|
||||
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
||||
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
|
||||
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
|
||||
|
||||
@@ -13,9 +13,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-lightspeed-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events from raw text
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -86,7 +93,7 @@ async function createSession(baseUrl: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.data?.session?.id) {
|
||||
@@ -1226,7 +1233,7 @@ describe('Operation Lightspeed', () => {
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
}).then((r) => r.json())
|
||||
)
|
||||
);
|
||||
|
||||
+60
-32
@@ -174,10 +174,13 @@ describe('Session Management', () => {
|
||||
|
||||
describe('POST /api/sessions', () => {
|
||||
it('should create a new session', async () => {
|
||||
// Under the test HOME, never the machine's real `/tmp`: a create writes the workspace
|
||||
// hooks and the agent skill (on by default) into its working dir.
|
||||
const workingDir = mkdtempSync(join(TEST_HOME, 'work-'));
|
||||
const response = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -185,7 +188,7 @@ describe('Session Management', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.session).toBeDefined();
|
||||
expect(data.data.session.id).toBeDefined();
|
||||
expect(data.data.session.workingDir).toBe('/tmp');
|
||||
expect(data.data.session.workingDir).toBe(workingDir);
|
||||
expect(data.data.session.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -353,49 +356,63 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not inject by default, accepts the setting via PUT, then injects on quick-start', async () => {
|
||||
// 1. Default OFF: a claude quick-start creates the case without the skill.
|
||||
const offCase = 'test-skill-off-' + Date.now();
|
||||
createdCases.push(offCase);
|
||||
const offResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
// The tests below run in order against one server and one settings.json: absent key
|
||||
// (on), a partial PUT that omits it (still on), then an explicit false (off).
|
||||
const skillDirOf = (caseName: string) => join(CASES_DIR, caseName, '.claude', 'skills', 'codeman');
|
||||
|
||||
async function quickStart(prefix: string, mode?: string): Promise<string> {
|
||||
const caseName = `${prefix}-${Date.now()}`;
|
||||
createdCases.push(caseName);
|
||||
const response = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: offCase }),
|
||||
body: JSON.stringify(mode ? { caseName, mode } : { caseName }),
|
||||
});
|
||||
const offData = await offResponse.json();
|
||||
expect(offData.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, offCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
expect((await response.json()).success).toBe(true);
|
||||
return caseName;
|
||||
}
|
||||
|
||||
// 2. The `.strict()` settings schema accepts the new synced key.
|
||||
const putResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
async function putSettings(body: Record<string, unknown>): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: true }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const putData = await putResponse.json();
|
||||
expect(putData.success).toBe(true);
|
||||
|
||||
// 3. The server's settings read is cached ~2s; outwait it so the create sees the toggle.
|
||||
expect((await response.json()).success).toBe(true);
|
||||
// The server's settings read is cached ~2s; outwait it so the next create sees the change.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
}
|
||||
|
||||
// 4. Quick-start now injects the marker-carrying skill into the new case.
|
||||
const onCase = 'test-skill-on-' + Date.now();
|
||||
createdCases.push(onCase);
|
||||
const onResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: onCase }),
|
||||
});
|
||||
const onData = await onResponse.json();
|
||||
expect(onData.success).toBe(true);
|
||||
/** What settings.json holds, read back through GET /api/settings (enveloped or not). */
|
||||
async function storedSettings(): Promise<Record<string, unknown>> {
|
||||
const body = (await (await fetch(`${baseUrl}/api/settings`)).json()) as Record<string, unknown>;
|
||||
const settings = (body.success === true ? body.data : body) as Record<string, unknown>;
|
||||
// Not vacuous: every quick-start above records the case it used.
|
||||
expect(settings).toHaveProperty('lastUsedCase');
|
||||
return settings;
|
||||
}
|
||||
|
||||
it('injects by default: a settings.json without the key means on', async () => {
|
||||
const caseName = await quickStart('test-skill-default');
|
||||
// A quick-start writes lastUsedCase, never this key.
|
||||
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
|
||||
|
||||
const skillDir = join(CASES_DIR, onCase, '.claude', 'skills', 'codeman');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const skillMd = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
const skillMd = readFileSync(join(skillDirOf(caseName), 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain('<!-- codeman-managed-agent-skill');
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDir, 'reference', 'recipes.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDirOf(caseName), 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDirOf(caseName), 'reference', 'recipes.md'))).toBe(true);
|
||||
// The copy ignores itself in git, so the default leaves a repository's status clean.
|
||||
expect(readFileSync(join(skillDirOf(caseName), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
}, 30000);
|
||||
|
||||
it('a partial settings PUT that omits the key keeps it on', async () => {
|
||||
await putSettings({ autoNameSessions: false });
|
||||
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
|
||||
|
||||
const caseName = await quickStart('test-skill-partial');
|
||||
expect(existsSync(join(skillDirOf(caseName), 'SKILL.md'))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('does not inject for shell-mode quick-start even when enabled', async () => {
|
||||
@@ -410,4 +427,15 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, shellCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
});
|
||||
|
||||
it('an explicit false turns it off, and a later partial PUT leaves it off', async () => {
|
||||
// The `.strict()` settings schema accepts the synced key.
|
||||
await putSettings({ agentSkillEnabled: false });
|
||||
await putSettings({ autoNameSessions: true });
|
||||
expect((await storedSettings()).agentSkillEnabled).toBe(false);
|
||||
|
||||
const caseName = await quickStart('test-skill-off');
|
||||
expect(existsSync(join(CASES_DIR, caseName))).toBe(true);
|
||||
expect(existsSync(skillDirOf(caseName))).toBe(false);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -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 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',
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* @fileoverview Agent-skill injection on POST /api/sessions (docs/agent-control-plan.md §2).
|
||||
*
|
||||
* The quick-start half of this is covered end-to-end in test/quick-start.test.ts;
|
||||
* the plain create path had NO coverage, because the shared mock context hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it. This exercises the real
|
||||
* the plain create path had NO coverage, because the shared mock context once hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it (it now defaults to true,
|
||||
* the shipped default since 1.50.0, and takes an override). This exercises the real
|
||||
* `applyAgentSkill` against a temp working dir, so it asserts bytes on disk rather
|
||||
* than a spy call.
|
||||
*
|
||||
@@ -81,6 +82,27 @@ describe('POST /api/sessions agent-skill injection', () => {
|
||||
// remove what this create wrote.
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(true);
|
||||
// The copy ignores itself, so the default leaves the user's `git status` clean.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('injects with the shared mock context left at its default (the shipped default is ON)', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
harness = { app, ctx };
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-default', mode: 'claude', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the working dir untouched when the gate is OFF', async () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
+12
-6
@@ -1,15 +1,20 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('Interactive Session Lifecycle', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let customDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// The 'custom working directory' test creates a session in /tmp/test; workingDir
|
||||
// validation requires the dir to exist, so ensure it does (idempotent, CI-safe).
|
||||
mkdirSync('/tmp/test', { recursive: true });
|
||||
// The 'custom working directory' test creates a session in a folder of its own
|
||||
// (workingDir validation requires it to exist), never a fixed path in the machine's
|
||||
// real `/tmp`: a create writes the workspace hooks and the agent skill (on by
|
||||
// default) into its working dir, and those would outlive the run there.
|
||||
customDir = mkdtempSync(join(tmpdir(), 'codeman-session-test-'));
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
@@ -17,6 +22,7 @@ describe('Interactive Session Lifecycle', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
rmSync(customDir, { recursive: true, force: true });
|
||||
}, 60000); // Extended timeout for cleanup
|
||||
|
||||
describe('Session Creation', () => {
|
||||
@@ -39,13 +45,13 @@ describe('Interactive Session Lifecycle', () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp/test' }),
|
||||
body: JSON.stringify({ workingDir: customDir }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.session.workingDir).toBe('/tmp/test');
|
||||
expect(data.data.session.workingDir).toBe(customDir);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-4
@@ -2,9 +2,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-events-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -130,7 +137,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
// Wait a bit for the event
|
||||
@@ -168,7 +175,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`);
|
||||
@@ -221,7 +228,7 @@ describe('SSE Event Types', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-filter-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events from raw text
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -157,7 +164,7 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
const sessionId = createData.data.session.id;
|
||||
@@ -208,7 +215,7 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
const sessionId = createData.data.session.id;
|
||||
@@ -263,14 +270,14 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session1 = (await createRes1.json()).data.session;
|
||||
|
||||
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session2 = (await createRes2.json()).data.session;
|
||||
|
||||
@@ -330,14 +337,14 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session1 = (await createRes1.json()).data.session;
|
||||
|
||||
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session2 = (await createRes2.json()).data.session;
|
||||
|
||||
|
||||
Reference in new issue
Block a user