From ef071ed485f21d8dcceb95713d3715a6129c5ad1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 11 Oct 2026 07:10:41 +0200 Subject: [PATCH] feat(agent-skill): give the codeman skill to every agent CLI, not just Claude The agent skill only reached Claude Code: injection wrote .claude/skills/codeman, which no other CLI reads, and the per-session preamble was seeded for claude alone. A Codex, Gemini, OpenCode, Antigravity, Grok, DeepSeek or OMP session inside Codeman never learned it could start and drive workers. - Registry field capabilities.agentSkillDir (a closed enum, since clis.json can set it and it decides where the server writes) names the skills directory each CLI scans. Claude keeps .claude/skills; those seven CLIs get the same skill in /.agents/skills/codeman (each measured to scan it; Claude Code does not, so it never sees two copies). pi gets none: a project skill makes pi ask "Trust project folder?" at startup. - The preamble is seeded for every local session whose CLI takes the skill; nothing in it is claude-specific. - The skill text is CLI-neutral and names the two CLI-side failures to report instead of retrying: a sandbox that blocks loopback (Codex's default), and commands that do not carry this session's environment (Codex 0.162 runs them in a shared app-server daemon). - POST /api/sessions injects after the launch pre-flight, so a CLI that cannot start leaves nothing in the workspace. - codeman skill install gains --mode; uninstall --case removes the copy from every directory the registry can write. - The settings toggle moves to Agents & CLIs > All agent CLIs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 +- README.md | 14 +- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/cli-registry.md | 12 ++ docs/extending-codeman.md | 7 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 38 ++-- docs/wiki/Settings-Reference.md | 2 +- plugins/codeman/skills/codeman/SKILL.md | 41 ++-- skills/codeman/SKILL.md | 41 ++-- src/cli.ts | 110 ++++++++-- src/config/cli-registry/schema.ts | 8 +- src/config/cli-registry/stock.ts | 28 +++ src/config/cli-registry/types.ts | 21 ++ src/hooks-config.ts | 74 +++++-- src/web/public/index.html | 20 +- src/web/routes/reboot-restore-routes.ts | 4 +- src/web/routes/session-routes.ts | 53 +++-- test/agent-skill-targets.test.ts | 196 ++++++++++++++++++ test/cli-skill-target.test.ts | 12 +- .../routes/session-routes-agent-skill.test.ts | 65 +++++- 21 files changed, 629 insertions(+), 125 deletions(-) create mode 100644 test/agent-skill-targets.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 15d32bfc..03780455 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,9 +198,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. 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`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): 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): 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`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): 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 CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents in modes that never get the claude-only preamble (`seedAgentSessionPreamble` is local-claude only; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, 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), 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` +**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 a CLIENT: no new route, 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), 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` **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) diff --git a/README.md b/README.md index 4fa42a9e..90d45d2b 100644 --- a/README.md +++ b/README.md @@ -733,7 +733,7 @@ For AI agents and automation that control Codeman without a browser: an agent th ### The agent skill (start here) -Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself. +Everything in this section also ships as an **agent skill**, [`skills/codeman`](skills/codeman/SKILL.md), in the `SKILL.md` format Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.

A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both @@ -743,13 +743,13 @@ Everything in this section also ships as a **Claude Code skill** in [`skills/cod | How | Command | Scope | | -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, works for any skills-aware agent | +| 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 ` | One case only | -| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | Auto-injects into each case on Claude session create (`agentSkillEnabled`, SYNCED, default off) | +| Bundled CLI | `codeman skill install --case [--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) | -`codeman skill uninstall [--case ]` reverses the CLI installs, and never touches a `skills/codeman` you wrote yourself. +`codeman skill uninstall [--case ]` 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. #### Step 2: ask for things @@ -802,7 +802,7 @@ Every recipe in there was verified against a live server, and the comments recor - **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess 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 `.claude/` dir). Remove them per case with `codeman skill uninstall --case `. +⚠️ 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 `. --- @@ -946,7 +946,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a53134e..9ce144ef 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -555,7 +555,7 @@ geometry was read. The capture runs synchronous tmux calls on the server; the ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. ## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ce51923b..556e9542 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -143,7 +143,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. -**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 ]` / `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's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ 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 ]` / `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. 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). diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 247e6a32..ebc4d443 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -128,6 +128,18 @@ sure its row is one the agent cannot write. `test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session. +### Where the agent skill lands + +`agentSkillInjection` says a CLI's sessions get the agent skill (`skills/codeman`) and its per-session preamble when the synced `agentSkillEnabled` setting is on; `agentSkillDir` says which workspace directory the CLI scans for skills, read through `agentSkillDir()` (hooks-config.ts), so no call site branches on a CLI id. Absent with the boolean on means `.claude/skills`, which is what the boolean meant before the field existed. + +| CLI | Skill directory | +| --- | --- | +| claude | `.claude/skills/codeman` | +| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` | +| pi, shell | none | + +Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it. + ## The newline chord `capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think. diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index 9d19ff0d..e65695be 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -163,9 +163,10 @@ respawn, Ralph, the orchestrator, search, and admin. Each route module carries a If the caller is an agent running _inside_ a Codeman session, install the packaged agent skill instead of teaching it these calls by hand: `skills/codeman` in the repo (`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install -[--case ]`, or the synced `agentSkillEnabled` App Setting for automatic -per-case injection on Claude session create). The skill carries the guard, the -safety rules, and verified wait/orchestration recipes. +[--case ] [--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 +wait/orchestration recipes. The common ones: diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 1a5b16a2..3c3a3a0d 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -4,26 +4,34 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and supervising other sessions.** -Three routes. In a Claude session, start with the skill. In any other CLI mode, use the -`codeman agent` commands. Raw HTTP is there for everything else. +Three routes. Start with the skill, whichever CLI the agent is. Where the skill does not +reach (a pi or shell session), use the `codeman agent` commands. Raw HTTP is there for +everything else. ## The agent skill -A Claude Code skill that teaches the agent the whole API, so you ask in plain English -instead of pasting endpoint documentation into prompts. +A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP +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. ### Install it | How | Command | Scope | | ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- | -| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. | +| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. 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 `codeman skill install`, not both, or the skill is listed twice (`codeman` and `codeman:codeman`). | | Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. | -| Bundled CLI | `codeman skill install --case ` | One case. | -| Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. | +| Bundled CLI | `codeman skill install --case [--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 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. Not pi: a project skill makes pi ask to trust the folder at startup. Off by default. | -`codeman skill uninstall [--case ]` reverses the CLI installs, and never touches a -`skills/codeman` you wrote yourself. +`codeman skill uninstall [--case ]` reverses the CLI installs (with `--case`, every +Codeman-managed copy in that case), and never touches a `skills/codeman` you wrote yourself. + +Two things to know when the agent is **Codex**: its default sandbox blocks network access +even to this machine, so the skill needs **Bypass approvals and sandbox** (App Settings, +Codex); and Codex 0.162 runs its commands in a shared background process, whose Codeman +variables can belong to the session that started it. The skill checks for both and says so +instead of acting. ### Then just ask @@ -62,12 +70,12 @@ completion signals. ## The `codeman agent` commands -The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An -`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing -that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is -a thin client over the endpoints in [the manual path](#the-manual-path), so auth and -ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a -case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill drives the API with `curl` and `jq`. Where it does not reach (a pi or shell +session, a machine without `jq`), the agent runs in the same environment, so `codeman agent` +packages the same verbs as shell commands. It is a thin client over the endpoints in +[the manual path](#the-manual-path), so auth and ownership apply unchanged, and it refuses to +act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"other sessions: +`codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index b3738246..db60320e 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -145,7 +145,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E | Allowed Tools | The list used by the explicit mode. | | Ralph / Todo Tracker | Enables the Ralph loop surfaces. | | Agent Teams | Experimental teams. Also needs the CLI's own environment flag. | -| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). | +| 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 [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). | | 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. | diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index c965c998..74762615 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -1,11 +1,13 @@ --- name: codeman description: >- - Drive Codeman, the session manager this agent is running inside, over its HTTP API: - list sessions, start worker sessions, send them prompts, block until they finish - (wait / wait-output / send-and-wait), read their output, and clean up; where - available, message claude workers directly (Claude Code cross-session messaging). - Use when asked to orchestrate or parallelize work across Codeman sessions, watch + Drive Codeman, the session manager this agent is running inside, over its HTTP API, + from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok + Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed + CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait), + read their output, and clean up; where available, message claude workers directly + (Claude Code cross-session messaging). Use when asked to orchestrate or parallelize + work across Codeman sessions, get a second opinion from another CLI or model, watch another session, or start and manage workers. Only usable inside a Codeman-managed session (CODEMAN_MUX=1); refuse to act otherwise. --- @@ -29,17 +31,31 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). -Workers in **every other mode** never receive this preamble, but they have the same -environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI -(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the -same endpoints, with the guards below (no control bytes, no self-delete, no guessed -URL) enforced in code. +This skill works the same whichever CLI you are: Codeman puts it where each CLI looks +for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, +OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets +it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same +verbs over the same endpoints, with the guards below (no control bytes, no self-delete, +no guessed URL) enforced in code, and it is also your own way through when this +machine has no `jq` for the preamble below. ## 0. Guard and bootstrap If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server you are not part of is not yours to drive. +Two failures come from your CLI, not from Codeman: name them to the user instead of +retrying. + +- **Every call fails at once with "couldn't connect" or "connection refused".** Your + CLI's sandbox blocks network access, even to this machine. Codex's default sandbox + does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex). +- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints + `404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background + process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and + the delete guard) is that session's. Delete nothing; tell the user. + ⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by the next call, and `$$` is a different pid. **The filesystem does survive**, so write @@ -47,8 +63,9 @@ the preamble to a file once and source it afterwards, rather than re-pasting a hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the single most likely way to break a run). -**Codeman seeds the preamble file for you** when it spawns a claude session (server -1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every +**Codeman seeds the preamble file for you** when it spawns your session (claude since +server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is +usually nothing at all: these are the two lines every later call opens with, and your first REAL call performs them anyway: ```bash diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index c965c998..74762615 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -1,11 +1,13 @@ --- name: codeman description: >- - Drive Codeman, the session manager this agent is running inside, over its HTTP API: - list sessions, start worker sessions, send them prompts, block until they finish - (wait / wait-output / send-and-wait), read their output, and clean up; where - available, message claude workers directly (Claude Code cross-session messaging). - Use when asked to orchestrate or parallelize work across Codeman sessions, watch + Drive Codeman, the session manager this agent is running inside, over its HTTP API, + from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok + Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed + CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait), + read their output, and clean up; where available, message claude workers directly + (Claude Code cross-session messaging). Use when asked to orchestrate or parallelize + work across Codeman sessions, get a second opinion from another CLI or model, watch another session, or start and manage workers. Only usable inside a Codeman-managed session (CODEMAN_MUX=1); refuse to act otherwise. --- @@ -29,17 +31,31 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). -Workers in **every other mode** never receive this preamble, but they have the same -environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI -(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the -same endpoints, with the guards below (no control bytes, no self-delete, no guessed -URL) enforced in code. +This skill works the same whichever CLI you are: Codeman puts it where each CLI looks +for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, +OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets +it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same +verbs over the same endpoints, with the guards below (no control bytes, no self-delete, +no guessed URL) enforced in code, and it is also your own way through when this +machine has no `jq` for the preamble below. ## 0. Guard and bootstrap If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server you are not part of is not yours to drive. +Two failures come from your CLI, not from Codeman: name them to the user instead of +retrying. + +- **Every call fails at once with "couldn't connect" or "connection refused".** Your + CLI's sandbox blocks network access, even to this machine. Codex's default sandbox + does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex). +- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints + `404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background + process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and + the delete guard) is that session's. Delete nothing; tell the user. + ⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by the next call, and `$$` is a different pid. **The filesystem does survive**, so write @@ -47,8 +63,9 @@ the preamble to a file once and source it afterwards, rather than re-pasting a hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the single most likely way to break a run). -**Codeman seeds the preamble file for you** when it spawns a claude session (server -1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every +**Codeman seeds the preamble file for you** when it spawns your session (claude since +server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is +usually nothing at all: these are the two lines every later call opens with, and your first REAL call performs them anyway: ```bash diff --git a/src/cli.ts b/src/cli.ts index 03692780..4bf96f4e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,16 @@ import { dataPath } from './config/instance.js'; import { readCodemanCredentials } from './codeman-credentials.js'; import { casePath } from './config/cases-dir.js'; import { assertValidBasePath } from './config/base-path.js'; -import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js'; +import { + agentSkillDir, + agentSkillDirFor, + CLAUDE_AGENT_SKILL_DIR, + installAgentSkillInto, + removeAgentSkillFrom, + type AgentSkillApplyResult, +} from './hooks-config.js'; +import type { AgentSkillDir } from './config/cli-registry/types.js'; +import { getCli, listClis } from './config/cli-registry/registry.js'; import { registerAgentCommands } from './cli-agent.js'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; @@ -140,7 +149,8 @@ export function resolveCliCasePath(name: string): string { /** * Resolve where `skill install` / `skill uninstall` operate. Global is * `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new - * session); `--case ` targets `/.claude/skills/codeman`, resolved through + * session); `--case ` targets `//codeman` for the given skills + * directory (default: Claude Code's, `/.claude/skills/codeman`), resolved through * `resolveCliCasePath()` above. The web server's automatic per-case injection * (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool * and stays single-user. @@ -151,17 +161,18 @@ export function resolveCliCasePath(name: string): string { */ export function resolveSkillTargetPath(options: { case?: string; + skillDir?: AgentSkillDir; }): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } { if (options.case) { const casePath = resolveCliCasePath(options.case); if (!existsSync(casePath)) return { missingCase: casePath }; - return { target: join(casePath, '.claude', 'skills', 'codeman') }; + return { target: agentSkillDirFor(casePath, options.skillDir ?? CLAUDE_AGENT_SKILL_DIR) }; } return { target: join(homedir(), '.claude', 'skills', 'codeman') }; } /** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */ -function resolveSkillTarget(options: { case?: string }): string { +function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }): string { const resolved = resolveSkillTargetPath(options); if (resolved.missingCase !== undefined) { console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`)); @@ -170,8 +181,44 @@ function resolveSkillTarget(options: { case?: string }): string { return resolved.target; } -/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */ -function reportSkillResult(result: AgentSkillApplyResult, target: string): void { +/** + * The skills directory `skill install --mode ` writes into, or why it cannot: a + * mode the registry gives no skill, or a user-level install for a CLI other than Claude + * Code (each CLI keeps its user-level skills somewhere else, and `npx skills add` + * already knows every one of those places). + */ +export function resolveSkillInstallDir(options: { + mode?: string; + case?: string; +}): { skillDir: AgentSkillDir; error?: undefined } | { skillDir?: undefined; error: string } { + const mode = options.mode ?? 'claude'; + const cli = getCli(mode); + if (!cli) return { error: `Unknown mode: ${mode}` }; + const skillDir = agentSkillDir(cli.capabilities); + if (!skillDir) return { error: `Mode ${mode} takes no Codeman agent skill` }; + if (!options.case && skillDir !== CLAUDE_AGENT_SKILL_DIR) { + return { + error: `A user-level install for ${mode} goes through the skills installer: npx skills add Ark0N/Codeman --skill codeman -g (or pass --case )`, + }; + } + return { skillDir }; +} + +/** + * Every distinct skills directory the registry can inject into, so + * `skill uninstall --case` removes whatever any CLI's session left there. + */ +export function registryAgentSkillDirs(): AgentSkillDir[] { + const dirs = new Set(); + for (const cli of listClis()) { + const dir = agentSkillDir(cli.capabilities); + if (dir) dirs.add(dir); + } + return [...dirs]; +} + +/** An AgentSkillApplyResult in words, and whether it counts as success. */ +function describeSkillResult(result: AgentSkillApplyResult, target: string): { ok: boolean; text: string } { const messages: Record = { installed: { ok: true, text: `Agent skill installed: ${target}` }, refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` }, @@ -187,7 +234,12 @@ function reportSkillResult(result: AgentSkillApplyResult, target: string): void text: `${target} (or its parent) is a symlink, refusing to write through it.`, }, }; - const message = messages[result]; + return messages[result]; +} + +/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */ +function reportSkillResult(result: AgentSkillApplyResult, target: string): void { + const message = describeSkillResult(result, target); if (message.ok) { console.log(palette.ok(`✓ ${message.text}`)); } else { @@ -205,9 +257,19 @@ skillCmd .description('Install the agent skill globally (~/.claude/skills/codeman) or into one case') .option('-g, --global', 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)') .option('-c, --case ', 'Install into /.claude/skills/codeman instead (linked cases resolve too)') - .action(async (options: { global?: boolean; case?: string }) => { + .option( + '-m, --mode ', + 'With --case: install where that run mode looks for skills (codex: /.agents/skills/codeman)', + 'claude' + ) + .action(async (options: { global?: boolean; case?: string; mode?: string }) => { try { - const target = resolveSkillTarget(options); + const resolved = resolveSkillInstallDir(options); + if (resolved.error !== undefined) { + console.error(palette.err(`✗ ${resolved.error}`)); + process.exit(1); + } + const target = resolveSkillTarget({ case: options.case, skillDir: resolved.skillDir }); reportSkillResult(await installAgentSkillInto(target), target); } catch (err) { console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`)); @@ -217,13 +279,35 @@ skillCmd skillCmd .command('uninstall') - .description('Remove a Codeman-managed agent skill copy (never touches a user-authored one)') + .description('Remove Codeman-managed agent skill copies (never touches a user-authored one)') .option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)') - .option('-c, --case ', 'Remove from /.claude/skills/codeman instead (linked cases resolve too)') + .option( + '-c, --case ', + "Remove every Codeman-managed copy from that case instead, whichever CLI's directory it is in (linked cases resolve too)" + ) .action(async (options: { global?: boolean; case?: string }) => { try { - const target = resolveSkillTarget(options); - reportSkillResult(await removeAgentSkillFrom(target), target); + if (!options.case) { + const target = resolveSkillTarget(options); + reportSkillResult(await removeAgentSkillFrom(target), target); + return; + } + let failed = false; + let removedAny = false; + for (const skillDir of registryAgentSkillDirs()) { + const target = resolveSkillTarget({ case: options.case, skillDir }); + const result = await removeAgentSkillFrom(target); + if (result === 'absent') continue; + removedAny = true; + const message = describeSkillResult(result, target); + if (message.ok) console.log(palette.ok(`✓ ${message.text}`)); + else { + console.error(palette.err(`✗ ${message.text}`)); + failed = true; + } + } + if (!removedAny) console.log(palette.ok(`✓ No Codeman-managed agent skill in case ${options.case}`)); + if (failed) process.exit(1); } catch (err) { console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`)); process.exit(1); diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 18cb81fb..a41ffedb 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -15,7 +15,10 @@ import { z } from 'zod'; import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; -import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js'; +import type { AgentSkillDir, LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js'; + +/** `capabilities.agentSkillDir`: every value must stay a fixed, relative, `..`-free path. */ +export const AGENT_SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const satisfies readonly AgentSkillDir[]; /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ const cliId = z @@ -302,6 +305,9 @@ const capabilitiesSchema = z respawn: z.boolean(), effort: z.boolean(), agentSkillInjection: z.boolean(), + // A closed list on purpose: it decides where the server WRITES, and this file is + // user-editable, so it can never be a free path (`..`, absolute). + agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(), statusLineTelemetry: z.boolean(), // How many columns this CLI indents its transcript body by, so a copy can take // that much off the clipboard. Bounded, because it is the whole strip: a copy diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index ae497207..9ebf0b82 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -76,6 +76,21 @@ function agentDefaults(): Pick< }; } +/** + * The agent skill in `/.agents/skills/codeman`, the one project skills + * directory every CLI that spreads this scans. Measured per CLI on + * 2026-10-11 against the installed builds or their source: codex 0.162 (every dir from + * the git root down to cwd), gemini (cwd only, behind folder trust, which `--skip-trust` + * already answers), opencode 1.3 (cwd up to the worktree), agy 1.3.2 (its `.agents/` + * customization root), grok 1.0.5 (each tier from cwd up to the repo root), dsh 0.1.1 + * (the git root, else cwd) and omp (cwd up to the git root). Claude Code does NOT read + * `.agents/skills`, so a case shared with a claude session lists each skill once there. + */ +const AGENTS_DIR_SKILL: Pick = { + agentSkillInjection: true, + agentSkillDir: '.agents/skills', +}; + // `accent` on every entry below (except SHELL, which the frontend renders no // distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-` // CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest @@ -278,6 +293,7 @@ const CLAUDE: CliEntry = { respawn: true, effort: true, agentSkillInjection: true, + agentSkillDir: '.claude/skills', statusLineTelemetry: true, model: { source: 'claude-settings-file' }, privilegedParams: [], @@ -494,6 +510,7 @@ const OPENCODE: CliEntry = { }, capabilities: { ...agentDefaults(), + ...AGENTS_DIR_SKILL, altScreen: 'strip-mux-and-mouse', echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, // Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real @@ -627,6 +644,7 @@ const CODEX: CliEntry = { }, capabilities: { ...agentDefaults(), + ...AGENTS_DIR_SKILL, // Codex draws `› Ask Codex to do anything` on its composer row and // `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no // braille spinner, and it never prints `esc to interrupt` at rest, so that phrase @@ -816,6 +834,7 @@ const GEMINI: CliEntry = { }, capabilities: { ...agentDefaults(), + ...AGENTS_DIR_SKILL, altScreen: 'strip-full', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real @@ -924,6 +943,7 @@ const ANTIGRAVITY: CliEntry = { }, capabilities: { ...agentDefaults(), + ...AGENTS_DIR_SKILL, altScreen: 'strip-mux-only', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Like codex: an ABSENT config already defaults safe (no bypass flag), so only a @@ -1019,6 +1039,11 @@ const PI: CliEntry = { }, capabilities: { ...agentDefaults(), + // No agent skill (agentSkillInjection stays false): any project-level `.agents/skills` + // or `.pi/skills` makes pi ask "Trust project folder?" at startup + // (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on + // a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi + // user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`). altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn, @@ -1169,6 +1194,7 @@ const GROK: CliEntry = { }, capabilities: { ...agentDefaults(), + ...AGENTS_DIR_SKILL, // Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured // (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full // erase-scrollback+DECSET strip. @@ -1341,6 +1367,7 @@ const DEEPSEEK: CliEntry = { }, capabilities: { ...agentDefaults(), + ...AGENTS_DIR_SKILL, // Definitive rather than inferred: the harness TUI reports idle/working/blocked to a // supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because // the session can disarm the bridge, and docker/remote cannot reach it at all. @@ -1506,6 +1533,7 @@ const OMP: CliEntry = { }, capabilities: { ...agentDefaults(), + ...AGENTS_DIR_SKILL, // Fullscreen alt-screen TUI, same shape as antigravity/grok: only the // tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. altScreen: 'strip-mux-only', diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 2fb689a4..4ef61084 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -69,6 +69,14 @@ export type Cond = */ export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single'; +/** + * Workspace-relative directories an agent CLI scans for project skills. A closed list + * rather than a free path: the value decides where the server WRITES, and + * `~/.codeman/clis.json` can set it, so it must never be able to name `..` or an + * absolute path. + */ +export type AgentSkillDir = '.claude/skills' | '.agents/skills'; + /** One argv element. */ export type ArgSpec = /** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */ @@ -515,7 +523,20 @@ export interface CliCapabilities { ralph: boolean; respawn: boolean; effort: boolean; + /** + * Codeman may write its agent skill (`skills/codeman`) into this CLI's workspace and + * seed its per-session preamble (behind the synced `agentSkillEnabled` setting, + * add-only, marker-owned). Where it lands comes from `agentSkillDir` below. + */ agentSkillInjection: boolean; + /** + * The workspace-relative directory this CLI scans for project skills, so the skill + * lands at `//codeman/SKILL.md` where the CLI discovers it. + * Measured per CLI. Absent with `agentSkillInjection: true` means Claude Code's + * `.claude/skills`, which is what the boolean meant before this field existed, so a + * `~/.codeman/clis.json` written against the old schema keeps its behaviour. + */ + agentSkillDir?: AgentSkillDir; statusLineTelemetry: boolean; /** Where a model override is delivered. Claude uniquely writes settings.local.json. */ model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string }; diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 19c2f788..99b63219 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -36,6 +36,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { HookEventType } from './types.js'; +import type { AgentSkillDir, CliCapabilities } from './config/cli-registry/types.js'; import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js'; import { dataPath } from './config/instance.js'; import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js'; @@ -1228,8 +1229,8 @@ interface AgentSkillFile { */ async function readAgentSkillSource(): Promise { const src = agentSkillSourceDir(); - const skill = await readFile(join(src, 'SKILL.md'), 'utf-8'); - const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }]; + const skillMd = await readFile(join(src, 'SKILL.md'), 'utf-8'); + const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skillMd.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }]; let referenceNames: string[] = []; try { referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort(); @@ -1285,7 +1286,8 @@ export type AgentSkillApplyResult = /** * Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman` - * directory, e.g. `/.claude/skills/codeman` or `~/.claude/skills/codeman`). + * directory, e.g. `/.claude/skills/codeman`, `~/.claude/skills/codeman` or + * `/.agents/skills/codeman`). * * Refuses two shapes rather than writing through them: * - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding @@ -1336,16 +1338,20 @@ export async function installAgentSkillInto(skillDir: string): Promise.sh`, + * Seed a session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-.sh`, * default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent * skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the * model has to type out (measured live: that paste alone cost a spawn run ~47 s of * generation time). The path formula must match the skill's * `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so * reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the - * shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (a - * remote or in-container HOME is not this filesystem) and treat it as best-effort: the - * skill's §0 fallback block self-heals a missing or stale file. + * shell's `:-`, treating empty as unset). Callers gate to LOCAL sessions whose CLI gets + * the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing + * claude-specific; a remote or in-container HOME is not this filesystem) and treat it as + * best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex + * caveat: codex 0.162 runs tool commands in a shared app-server daemon whose + * `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's + * file and its self id is wrong; the skill tells the agent how to notice. */ export async function seedAgentSessionPreamble(sessionId: string): Promise { const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8'); @@ -1478,31 +1484,59 @@ export async function removeAgentSkillFrom(skillDir: string): Promise {}); // fails when non-empty, fine await rmdir(skillDir).catch(() => {}); - await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty + await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty return 'removed'; }); } /** - * Add or remove the Codeman agent skill in `/.claude/skills/codeman`, - * mirroring `applyStatusLineConfig`'s shape. Gated by the synced `agentSkillEnabled` - * app setting (default OFF); callers gate on Claude mode, since the skill is discovered - * via `.claude/skills/`, which only Claude Code reads. + * Claude Code's project skills directory, and the meaning `agentSkillInjection: true` + * had before `capabilities.agentSkillDir` existed. + */ +export const CLAUDE_AGENT_SKILL_DIR: AgentSkillDir = '.claude/skills'; + +/** + * The workspace-relative skills directory this CLI scans, i.e. where its sessions get + * the skill, or `null` when they get none. Pure registry data, so no call site + * branches on a CLI id. + */ +export function agentSkillDir( + capabilities: Pick | undefined +): AgentSkillDir | null { + if (!capabilities?.agentSkillInjection) return null; + return capabilities.agentSkillDir ?? CLAUDE_AGENT_SKILL_DIR; +} + +/** `/

/codeman`, the directory the skill's SKILL.md lives in. */ +export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string { + return join(casePath, dir, 'codeman'); +} + +/** + * Add or remove the Codeman agent skill in `//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). * * 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 `.claude/` - * dir, so a single create while the setting is off must not yank the skill out from + * skip the call), for the statusLine reason: sessions in a repo share one workspace, + * so a single create while the setting is off must not yank the skill out from * under other live sessions. * * ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately * no server-side toggle-off sweep (it would have to walk every case, including ones - * with live sessions, and would hit exactly the shared-`.claude/` hazard above), so + * with live sessions, and would hit exactly the shared-workspace hazard above), so * already-injected copies stay on disk until removed per case with - * `codeman skill uninstall --case `. The `enabled: false` branch here backs that - * CLI and the tests; it has no server call site. Keep the README's Agent Skill note in - * sync if this ever changes. + * `codeman skill uninstall --case `, which removes the Codeman-managed copy from + * every skills directory the registry could have written. The `enabled: false` branch + * here backs that CLI and the tests; it has no server call site. Keep the README's + * Agent Skill note in sync if this ever changes. */ -export async function applyAgentSkill(casePath: string, enabled: boolean): Promise { - const skillDir = join(casePath, '.claude', 'skills', 'codeman'); +export async function applyAgentSkill( + casePath: string, + enabled: boolean, + dir: AgentSkillDir = CLAUDE_AGENT_SKILL_DIR +): Promise { + const skillDir = agentSkillDirFor(casePath, dir); return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir); } diff --git a/src/web/public/index.html b/src/web/public/index.html index cb6d38d0..916416d4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2597,6 +2597,19 @@ +
+

All agent CLIs

synced
+
+
+
+ Codeman Agent Skill + 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. +
+ +
+
+
+

Claude

synced
@@ -2633,13 +2646,6 @@
-
-
- Codeman Agent Skill - Let new sessions start workers, send prompts and wait for results over the API. -
- -
Workspace Hooks diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts index e6a50f5d..a989fb4d 100644 --- a/src/web/routes/reboot-restore-routes.ts +++ b/src/web/routes/reboot-restore-routes.ts @@ -41,7 +41,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { Session } from '../../session.js'; import { resolveClaudeModeForUsername } from '../../user-store.js'; import { getCli } from '../../config/cli-registry/registry.js'; -import { applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js'; +import { agentSkillDir, applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js'; import { SseEvent } from '../sse-events.js'; @@ -247,7 +247,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes // skill falls back to writing out the whole ~150-line §0 preamble. Remote and // docker sessions never reach here (the plan rejects them as // `remote-or-docker`), so the local-only condition is structural. - if (getCli(session.mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) { + if (agentSkillDir(getCli(session.mode)?.capabilities) && (await ctx.getAgentSkillEnabled())) { await seedAgentSessionPreamble(session.id).catch((err: unknown) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`) ); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 5960d9e6..59c969f5 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -114,7 +114,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; -import type { NewlineSequence } from '../../config/cli-registry/types.js'; +import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; import { applyLaunchDefaults } from '../launch-defaults.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; @@ -124,6 +124,8 @@ import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, + agentSkillDir, + agentSkillDirFor, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, @@ -792,8 +794,8 @@ function abortOnClientHangUp(reply: FastifyReply): AbortController { * Injection is best-effort and stays that way: neither a refusal nor a thrown error * may fail the create. */ -async function injectAgentSkill(casePath: string): Promise { - const skillDir = join(casePath, '.claude', 'skills', 'codeman'); +async function injectAgentSkill(casePath: string, dir: AgentSkillDir): Promise { + const skillDir = agentSkillDirFor(casePath, dir); try { // Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, // written once by `codeman skill install`) over the case copy injected below, so a @@ -802,7 +804,7 @@ async function injectAgentSkill(casePath: string): Promise { // current on the same trigger. Refresh-only + marker-guarded; quiet on refusal, // since a foreign user copy is the user's own authored skill, not a config error. await refreshUserAgentSkill(); - const result = await applyAgentSkill(casePath, true); + const result = await applyAgentSkill(casePath, true, dir); if (result === 'foreign') { console.warn( `[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.` @@ -1133,13 +1135,6 @@ export function registerSessionRoutes( // materializing in ~/.claude/settings.local.json was never asked for. if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') { await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled()); - // Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared- - // .claude rationale as the statusLine above: a create must never remove the - // skill from under other live sessions in the repo. Marker-guarded, so a - // user's own skills/codeman is never touched. - if (await ctx.getAgentSkillEnabled()) { - await injectAgentSkill(workingDir); - } } // Refuse up front if the requested CLI cannot start, rather than spawning a pane that @@ -1163,6 +1158,18 @@ export function registerSessionRoutes( } } + // Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared- + // workspace rationale as the statusLine above: a create must never remove the + // skill from under other live sessions in the repo. Marker-guarded, so a user's + // own copy is never touched. Every CLI that takes the skill gets it in the directory + // that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir + // conditions as the hooks above, and only once the launch pre-flight passed, so a + // CLI that cannot start leaves nothing in the workspace. + const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities); + if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) { + await injectAgentSkill(workingDir, createSkillDir); + } + // Pre-validate resumeSessionId: check that the conversation file actually exists // in Claude's projects directory. If not, skip resume to avoid confusing // "No conversation found" errors from Claude CLI. @@ -1274,8 +1281,9 @@ export function registerSessionRoutes( ctx.persistSessionState(session); await ctx.setupSessionListeners(session); // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line - // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort. - if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) { + // loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the + // skill; best-effort. + if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) { await seedAgentSessionPreamble(session.id).catch((err: unknown) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`) ); @@ -3870,12 +3878,14 @@ export function registerSessionRoutes( } // Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create, - // marker-guarded (a user's own skills/codeman is never touched). Claude mode only - // (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose - // casePath lives on another host. Docker cases qualify: hostWorkspacePath is a - // real host dir and the skill crosses the bind mount like the rest of `.claude/`. - if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) { - await injectAgentSkill(resolvedCasePath); + // marker-guarded (a user's own copy is never touched). Each CLI that takes the + // skill gets it in the directory it scans (`capabilities.agentSkillDir`: + // `.claude/skills` for claude, `.agents/skills` for most others); skipped for + // remote cases, whose casePath lives on another host. Docker cases qualify: + // hostWorkspacePath is a real host dir and the skill crosses the bind mount. + const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities); + if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) { + await injectAgentSkill(resolvedCasePath, quickStartSkillDir); } // Docker cases: the workspace is a REAL host dir bind-mounted into the container. @@ -4188,8 +4198,9 @@ export function registerSessionRoutes( ctx.persistSessionState(session); await ctx.setupSessionListeners(session); // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line - // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort. - if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) { + // loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the + // skill; best-effort. + if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) { await seedAgentSessionPreamble(session.id).catch((err: unknown) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`) ); diff --git a/test/agent-skill-targets.test.ts b/test/agent-skill-targets.test.ts new file mode 100644 index 00000000..49d414c8 --- /dev/null +++ b/test/agent-skill-targets.test.ts @@ -0,0 +1,196 @@ +/** + * @fileoverview Agent skill per CLI: which workspace directory each CLI's sessions get + * the one packaged skill in (`capabilities.agentSkillDir`), and the + * `codeman skill install|uninstall` resolution that follows the registry. + * + * The skill is read from the REAL packaged source (`skills/codeman/`), so this also + * guards that it meets the Agent Skills format every CLI (and the `npx skills` + * installer) parses: a `name` equal to its directory, lowercase with hyphens, and a + * description of at most 1024 characters. + * + * Pure filesystem and registry tests in a per-test temp dir. Port: N/A. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, readFile, readdir } from 'node:fs/promises'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { agentSkillDir, agentSkillDirFor, applyAgentSkill, CLAUDE_AGENT_SKILL_DIR } from '../src/hooks-config.js'; +import { getCli, listClis } from '../src/config/cli-registry/registry.js'; +import { CliEntrySchema } from '../src/config/cli-registry/schema.js'; +import { registryAgentSkillDirs, resolveSkillInstallDir } from '../src/cli.js'; + +const MARKER_PREFIX = '