mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235db69c1e | ||
|
|
cda4184e42 | ||
|
|
f1cfe61950 | ||
|
|
edfc2ed667 | ||
|
|
0c0ada7cdb | ||
|
|
55e0f0e7e4 | ||
|
|
14cf95e6ee | ||
|
|
3d61402250 | ||
|
|
cd2265f8d0 | ||
|
|
df98912596 | ||
|
|
ddcdd5d974 | ||
|
|
9c64fc128d | ||
|
|
b3f0ba9e29 | ||
|
|
ba9d311cb7 |
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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,7 +79,7 @@ output. The other CLIs expose no equivalent.
|
||||
| Subagent and team windows | Yes | No |
|
||||
| Model, effort, advisor, and ultracode controls | Yes | No |
|
||||
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly |
|
||||
| The bundled agent skill | Yes | No |
|
||||
| The bundled agent skill | Yes | Every CLI except Pi and Copilot, in `.agents/skills/` |
|
||||
|
||||
Everything that makes a session a session works everywhere. What is Claude-only is mostly
|
||||
the machinery that needs to know *what* the agent is doing rather than *that* it is doing
|
||||
|
||||
@@ -32,7 +32,7 @@ The shape of it:
|
||||
- The loop ends when the agent signals completion, when the iteration cap is reached, or
|
||||
when you stop it.
|
||||
|
||||
Start it from **Session Options → Ralph / Todo**.
|
||||
Start it from **Session Options → Ralph / Todo**, or from the wizard on the welcome screen.
|
||||
|
||||
| Setting | What it does |
|
||||
| ---------------------- | ------------------------------------------------------------------------ |
|
||||
|
||||
@@ -43,7 +43,11 @@ DeepSeek Harness worker on a local qwen model and a Claude Code worker power on
|
||||
|
||||
## Install it
|
||||
|
||||
Pick one route. The skill only acts inside a Codeman session, so a global install is safe.
|
||||
Usually you do not have to: since 1.50.0 Codeman puts the skill into every new session's
|
||||
folder by default (the Web UI row below), where that session's CLI looks for skills. The copy
|
||||
ignores itself in git, so your repository never lists it as a new file.
|
||||
|
||||
Otherwise pick one route. The skill only acts inside a Codeman session, so a global install is safe.
|
||||
|
||||
| How | Command | Scope |
|
||||
| ------------------ | ------------------------------------------------------------------------------- | ----- |
|
||||
@@ -51,10 +55,13 @@ Pick one route. The skill only acts inside a Codeman session, so a global instal
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. |
|
||||
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
|
||||
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Off by default. |
|
||||
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. On by default since 1.50.0. |
|
||||
|
||||
Use the plugin **or** `codeman skill install`, not both: a Claude Code with both lists the
|
||||
skill twice (`codeman` and `codeman:codeman`).
|
||||
skill twice (`codeman` and `codeman:codeman`). The Web UI copy counts too, and it is on by
|
||||
default: with the plugin installed, Claude Code sessions in Codeman list the skill twice.
|
||||
Both work. Turn **Codeman Agent Skill** off if the duplicate bothers you (other CLIs then
|
||||
lose their copy too), or use the Web UI copy instead of the plugin.
|
||||
|
||||
The web UI route skips pi, because a project skill makes pi ask to trust the folder at
|
||||
startup. A pi or plain shell session uses the [`codeman agent`
|
||||
@@ -67,9 +74,16 @@ same verbs as shell commands.
|
||||
Codeman-managed copy in that case, whichever CLI's directory it sits in). It never touches a
|
||||
`skills/codeman` you wrote yourself.
|
||||
|
||||
Turning the web UI setting back off **does not remove copies it already injected**, because
|
||||
To stop the Web UI copy, turn **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent
|
||||
Skill** off. It is on by default since 1.50.0: a settings file without the setting means on,
|
||||
and only switching it off turns it off. Existing installs that stored off are switched on
|
||||
once at the first 1.50.0 start (every App Settings save on an earlier version stored the old
|
||||
default, off, whether or not you touched this switch); turning it off afterwards sticks.
|
||||
|
||||
Turning the web UI setting off **does not remove copies it already injected**, because
|
||||
a sweep at session start would pull the skill out from under other live sessions sharing
|
||||
that directory. Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
that directory. Remove them per case with `codeman skill uninstall --case <name>`, which
|
||||
also removes the copy's `.gitignore`.
|
||||
|
||||
## Then just ask
|
||||
|
||||
|
||||
@@ -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
|
||||
+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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1118,6 +1118,7 @@
|
||||
'When to use this preset': '此预设的适用场景',
|
||||
'Start Loop': '启动循环',
|
||||
'Start Ralph Loop': '启动 Ralph 循环',
|
||||
'Start Ralph Loop →': '启动 Ralph 循环 →',
|
||||
'Enable Tracker': '启用跟踪器',
|
||||
'Ralph / Todo': 'Ralph / 待办',
|
||||
'Ralph / Todo Tracker': 'Ralph / 待办跟踪器',
|
||||
|
||||
@@ -590,6 +590,7 @@
|
||||
<div class="foreign-sessions" id="foreignSessions" hidden></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or click Run to start</p>
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2940,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>
|
||||
|
||||
@@ -1847,6 +1847,11 @@ html.mobile-init .file-browser-panel {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.welcome-ralph-link {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5682,6 +5682,21 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.welcome-ralph-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
margin-top: 0.25rem;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.welcome-ralph-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Short window (a 1470x840 laptop is the common case): the column above now
|
||||
fits by construction, but only by taking the space out of the Resume list,
|
||||
which collapses to two rows. Buying ~80px back from the vertical rhythm keeps
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+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);
|
||||
});
|
||||
@@ -2,8 +2,9 @@
|
||||
* @fileoverview Agent-skill injection on POST /api/sessions (docs/agent-control-plan.md §2).
|
||||
*
|
||||
* The quick-start half of this is covered end-to-end in test/quick-start.test.ts;
|
||||
* the plain create path had NO coverage, because the shared mock context hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it. This exercises the real
|
||||
* the plain create path had NO coverage, because the shared mock context once hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it (it now defaults to true,
|
||||
* the shipped default since 1.50.0, and takes an override). This exercises the real
|
||||
* `applyAgentSkill` against a temp working dir, so it asserts bytes on disk rather
|
||||
* than a spy call.
|
||||
*
|
||||
@@ -81,6 +82,27 @@ describe('POST /api/sessions agent-skill injection', () => {
|
||||
// remove what this create wrote.
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(true);
|
||||
// The copy ignores itself, so the default leaves the user's `git status` clean.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('injects with the shared mock context left at its default (the shipped default is ON)', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
harness = { app, ctx };
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-default', mode: 'claude', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the working dir untouched when the gate is OFF', async () => {
|
||||
|
||||
+12
-6
@@ -1,15 +1,20 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('Interactive Session Lifecycle', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let customDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// The 'custom working directory' test creates a session in /tmp/test; workingDir
|
||||
// validation requires the dir to exist, so ensure it does (idempotent, CI-safe).
|
||||
mkdirSync('/tmp/test', { recursive: true });
|
||||
// The 'custom working directory' test creates a session in a folder of its own
|
||||
// (workingDir validation requires it to exist), never a fixed path in the machine's
|
||||
// real `/tmp`: a create writes the workspace hooks and the agent skill (on by
|
||||
// default) into its working dir, and those would outlive the run there.
|
||||
customDir = mkdtempSync(join(tmpdir(), 'codeman-session-test-'));
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
@@ -17,6 +22,7 @@ describe('Interactive Session Lifecycle', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
rmSync(customDir, { recursive: true, force: true });
|
||||
}, 60000); // Extended timeout for cleanup
|
||||
|
||||
describe('Session Creation', () => {
|
||||
@@ -39,13 +45,13 @@ describe('Interactive Session Lifecycle', () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp/test' }),
|
||||
body: JSON.stringify({ workingDir: customDir }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.session.workingDir).toBe('/tmp/test');
|
||||
expect(data.data.session.workingDir).toBe(customDir);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-4
@@ -2,9 +2,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-events-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -130,7 +137,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
// Wait a bit for the event
|
||||
@@ -168,7 +175,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`);
|
||||
@@ -221,7 +228,7 @@ describe('SSE Event Types', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
|
||||
|
||||
@@ -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