mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 03:09:44 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c041a418e0 |
@@ -198,9 +198,11 @@ 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, 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`/`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 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 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`
|
||||
|
||||
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="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" width="900"></a>
|
||||
@@ -743,13 +743,13 @@ Everything in this section also ships as an **agent skill**, [`skills/codeman`](
|
||||
|
||||
| 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 |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, works for any skills-aware agent |
|
||||
| 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) |
|
||||
| Bundled CLI | `codeman skill install --case <name>` | 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) |
|
||||
|
||||
`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.
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs, 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 workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
⚠️ 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 <name>`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 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`"*.
|
||||
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`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
@@ -962,6 +962,55 @@ codeman agent rm "$SID" # any session except t
|
||||
|
||||
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
|
||||
|
||||
### MCP server (`codeman mcp`): drive Codeman from any MCP client
|
||||
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged.
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `"CODEMAN_PASSWORD"` to `env` when the server has a password (or let it read the data dir's `.env`). Without the `codeman` command on PATH (a git checkout run without the installer), use `node /path/to/Codeman/dist/index.js mcp`.
|
||||
|
||||
| Tool | Does |
|
||||
| ---- | ---- |
|
||||
| `list_sessions` | Every session you can see, with its run mode, status, folder, and whether these tools may prompt it |
|
||||
| `start_session` | A new worker in a case (a fresh scratch folder, or an existing case) in any installed CLI; waits until it can take a prompt |
|
||||
| `send_prompt` | Types a one-line prompt and presses Enter; optionally waits for the end of the turn |
|
||||
| `wait_for_session` | Waits for a signal (`stop`, `blocked`, `idle`, `working`, `exit`) or a literal marker in the output |
|
||||
| `read_output` | The last answer, the whole conversation, or the last bytes of the terminal |
|
||||
| `interrupt_session` | One Escape: stops the current turn, keeps the conversation |
|
||||
| `delete_session` | Closes a worker (its case folder stays) |
|
||||
|
||||
Rules the server enforces rather than documents:
|
||||
|
||||
- **It never guesses a server.** The URL comes from `CODEMAN_API_URL` or `--url`; without one it still starts and lists its tools, and every call says how to configure it.
|
||||
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions this server started (and, run inside a Codeman session, that session's own workers). Listing, reading and waiting work on everything. `codeman mcp --allow-any-session` lifts the limit, for a user who wants an assistant to drive their existing tabs. It never acts on the session it runs in.
|
||||
- **Waits fit the client.** A wait defaults to 45 s (Codex cuts MCP tools off at 60 s by default) and is capped at 600 s; `timed_out` / `still_working` means call again.
|
||||
- **Both protocol eras.** It answers the 2026-07-28 revision (per-request `_meta`, `server/discover`) and the `initialize` handshake of 2024-11-05 to 2025-11-25.
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.41.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
"source": "github"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"packageArguments": [
|
||||
{
|
||||
"type": "positional",
|
||||
"value": "mcp"
|
||||
}
|
||||
],
|
||||
"environmentVariables": [
|
||||
{
|
||||
"name": "CODEMAN_API_URL",
|
||||
"description": "Where your Codeman server runs, for example https://127.0.0.1:3000. Never guessed.",
|
||||
"isRequired": true,
|
||||
"format": "string"
|
||||
},
|
||||
{
|
||||
"name": "CODEMAN_PASSWORD",
|
||||
"description": "Codeman's HTTP Basic password, when the server has one.",
|
||||
"isRequired": false,
|
||||
"isSecret": true,
|
||||
"format": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -555,7 +555,9 @@ 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 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 <signals>`; `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 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 <signals>`; `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 mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
|
||||
|
||||
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
|
||||
|
||||
|
||||
@@ -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 <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'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.
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -128,18 +128,6 @@ 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 `<workspace>/.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.
|
||||
|
||||
@@ -163,10 +163,13 @@ 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 <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
|
||||
wait/orchestration recipes.
|
||||
[--case <name>]`, 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.
|
||||
|
||||
If the caller speaks the Model Context Protocol (Claude Desktop, Cursor, Codex, any MCP
|
||||
client), point it at `codeman mcp` instead: seven tools over these same endpoints,
|
||||
configured with `CODEMAN_API_URL`. See the README section "MCP server".
|
||||
|
||||
The common ones:
|
||||
|
||||
|
||||
@@ -4,34 +4,26 @@ 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. 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.
|
||||
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.
|
||||
|
||||
## The agent skill
|
||||
|
||||
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.
|
||||
A Claude Code skill that 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. The way to give it to pi. |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. |
|
||||
| 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 <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 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. |
|
||||
| Bundled CLI | `codeman skill install --case <name>` | One case. |
|
||||
| Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` 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.
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a
|
||||
`skills/codeman` you wrote yourself.
|
||||
|
||||
### Then just ask
|
||||
|
||||
@@ -70,12 +62,12 @@ completion signals.
|
||||
|
||||
## The `codeman agent` commands
|
||||
|
||||
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`"*.
|
||||
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`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
@@ -98,6 +90,51 @@ codeman agent rm "$SID" # any session except t
|
||||
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
|
||||
worker exited, `4` refused. `--json` prints the response's `data`.
|
||||
|
||||
## The MCP server
|
||||
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server, so any MCP
|
||||
client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor) can drive Codeman, from
|
||||
inside a Codeman session or from an assistant on your desktop. It is a client of the same
|
||||
HTTP API, with seven tools: `list_sessions`, `start_session`, `send_prompt`,
|
||||
`wait_for_session`, `read_output`, `interrupt_session` and `delete_session`.
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `CODEMAN_PASSWORD` to
|
||||
`env` when the server has a password.
|
||||
|
||||
- **It never guesses a server.** Without `CODEMAN_API_URL` (or `--url`) every call says how
|
||||
to configure it.
|
||||
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions it
|
||||
started; listing, reading and waiting work on everything. `codeman mcp
|
||||
--allow-any-session` lifts that, for an assistant that should drive your existing tabs.
|
||||
- **Waits are short by default** (45 s, at most 600 s), so they fit a client's tool
|
||||
timeout. A `timed_out` answer means call again.
|
||||
|
||||
## The manual path
|
||||
|
||||
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
|
||||
|
||||
@@ -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 | 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). |
|
||||
| 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). |
|
||||
| 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. |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: codeman
|
||||
description: >-
|
||||
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
|
||||
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
|
||||
another session, or start and manage workers. Only usable inside a Codeman-managed
|
||||
session (CODEMAN_MUX=1); refuse to act otherwise.
|
||||
---
|
||||
@@ -31,31 +29,17 @@ 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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
@@ -63,9 +47,8 @@ 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 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
|
||||
**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
|
||||
later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,6 +29,9 @@ const SOURCE = 'skills/codeman';
|
||||
const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`;
|
||||
const MIRROR = `${PLUGIN_DIR}/skills/codeman`;
|
||||
const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json'];
|
||||
// The MCP registry manifest for `codeman mcp` (published with `mcp-publisher` from its
|
||||
// directory): the server and its npm package both carry the release version.
|
||||
const MCP_REGISTRY_MANIFEST = 'config/mcp-registry/server.json';
|
||||
|
||||
const check = process.argv.includes('--check');
|
||||
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
@@ -78,6 +81,21 @@ for (const file of MANIFESTS) {
|
||||
if (changed && !check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// 3. The MCP registry manifest.
|
||||
{
|
||||
const json = JSON.parse(readFileSync(MCP_REGISTRY_MANIFEST, 'utf8'));
|
||||
const targets = [json, ...(json.packages ?? []).filter((p) => p.registryType === 'npm')];
|
||||
let changed = false;
|
||||
for (const target of targets) {
|
||||
if (target.version !== version) {
|
||||
drift.push(`${MCP_REGISTRY_MANIFEST}: ${target.version} -> ${version}`);
|
||||
target.version = version;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed && !check) writeFileSync(MCP_REGISTRY_MANIFEST, JSON.stringify(json, null, 2) + '\n');
|
||||
}
|
||||
|
||||
if (drift.length === 0) {
|
||||
console.log(`plugin in step: mirror identical, manifests at ${version}`);
|
||||
} else if (check) {
|
||||
|
||||
+12
-29
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: codeman
|
||||
description: >-
|
||||
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
|
||||
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
|
||||
another session, or start and manage workers. Only usable inside a Codeman-managed
|
||||
session (CODEMAN_MUX=1); refuse to act otherwise.
|
||||
---
|
||||
@@ -31,31 +29,17 @@ 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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
@@ -63,9 +47,8 @@ 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 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
|
||||
**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
|
||||
later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
|
||||
+10
-8
@@ -253,20 +253,21 @@ export interface RequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
/** Socket timeout; long-polls pass their own timeout plus headroom. */
|
||||
timeoutMs?: number;
|
||||
/** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
|
||||
|
||||
/** Every request carries these; they are ignored on endpoints that do not read them. */
|
||||
export function baseHeaders(ctx: AgentContext): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||
// may create one (see agentSpawn), never anything else.
|
||||
'X-Codeman-Parent-Session': ctx.selfId,
|
||||
};
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||
// may create one (see agentSpawn), never anything else. Absent for a caller outside
|
||||
// any session (`codeman mcp` launched by a desktop MCP client), which has no self.
|
||||
if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId;
|
||||
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
|
||||
if (authorization) headers.Authorization = authorization;
|
||||
return headers;
|
||||
@@ -296,6 +297,7 @@ export const httpRequest: ApiRequest = (ctx, options) => {
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: options.timeoutMs ?? 30_000,
|
||||
signal: options.signal,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
+33
-97
@@ -18,17 +18,9 @@ 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 {
|
||||
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 { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { registerAgentCommands } from './cli-agent.js';
|
||||
import { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
@@ -149,8 +141,7 @@ 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 <name>` targets `<case>/<skillDir>/codeman` for the given skills
|
||||
* directory (default: Claude Code's, `<case>/.claude/skills/codeman`), resolved through
|
||||
* session); `--case <name>` targets `<case>/.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.
|
||||
@@ -161,18 +152,17 @@ 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: agentSkillDirFor(casePath, options.skillDir ?? CLAUDE_AGENT_SKILL_DIR) };
|
||||
return { target: join(casePath, '.claude', 'skills', 'codeman') };
|
||||
}
|
||||
return { target: join(homedir(), '.claude', 'skills', 'codeman') };
|
||||
}
|
||||
|
||||
/** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */
|
||||
function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }): string {
|
||||
function resolveSkillTarget(options: { case?: string }): string {
|
||||
const resolved = resolveSkillTargetPath(options);
|
||||
if (resolved.missingCase !== undefined) {
|
||||
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
|
||||
@@ -181,44 +171,8 @@ function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }
|
||||
return resolved.target;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skills directory `skill install --mode <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 <name>)`,
|
||||
};
|
||||
}
|
||||
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<AgentSkillDir>();
|
||||
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 } {
|
||||
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
|
||||
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
|
||||
const messages: Record<AgentSkillApplyResult, { ok: boolean; text: string }> = {
|
||||
installed: { ok: true, text: `Agent skill installed: ${target}` },
|
||||
refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` },
|
||||
@@ -234,12 +188,7 @@ function describeSkillResult(result: AgentSkillApplyResult, target: string): { o
|
||||
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
|
||||
},
|
||||
};
|
||||
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);
|
||||
const message = messages[result];
|
||||
if (message.ok) {
|
||||
console.log(palette.ok(`✓ ${message.text}`));
|
||||
} else {
|
||||
@@ -257,19 +206,9 @@ 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 <name>', 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.option(
|
||||
'-m, --mode <mode>',
|
||||
'With --case: install where that run mode looks for skills (codex: <case>/.agents/skills/codeman)',
|
||||
'claude'
|
||||
)
|
||||
.action(async (options: { global?: boolean; case?: string; mode?: string }) => {
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
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 });
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await installAgentSkillInto(target), target);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
@@ -279,35 +218,13 @@ skillCmd
|
||||
|
||||
skillCmd
|
||||
.command('uninstall')
|
||||
.description('Remove Codeman-managed agent skill copies (never touches a user-authored one)')
|
||||
.description('Remove a Codeman-managed agent skill copy (never touches a user-authored one)')
|
||||
.option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)')
|
||||
.option(
|
||||
'-c, --case <name>',
|
||||
"Remove every Codeman-managed copy from that case instead, whichever CLI's directory it is in (linked cases resolve too)"
|
||||
)
|
||||
.option('-c, --case <name>', 'Remove from <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
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);
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
@@ -318,6 +235,25 @@ skillCmd
|
||||
|
||||
registerAgentCommands(program);
|
||||
|
||||
// ============ MCP server (any MCP client drives Codeman) ============
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description(
|
||||
'Run a Model Context Protocol server on stdio, so MCP clients (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start, prompt, wait on, read and delete Codeman sessions'
|
||||
)
|
||||
.option('--url <url>', 'Codeman server URL (default: $CODEMAN_API_URL; never guessed)')
|
||||
.option(
|
||||
'--allow-any-session',
|
||||
'Let the tools prompt, interrupt and delete sessions this server did not start (default: only its own)'
|
||||
)
|
||||
.action(async (options: { url?: string; allowAnySession?: boolean }) => {
|
||||
await runMcpStdio(
|
||||
resolveMcpConfig({ url: options.url, allowAnySession: options.allowAnySession, version: pkg.version })
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.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[];
|
||||
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -305,9 +302,6 @@ 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
|
||||
|
||||
@@ -76,21 +76,6 @@ function agentDefaults(): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent skill in `<workspace>/.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<CliEntry['capabilities'], 'agentSkillInjection' | 'agentSkillDir'> = {
|
||||
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-<id>`
|
||||
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
|
||||
@@ -293,7 +278,6 @@ const CLAUDE: CliEntry = {
|
||||
respawn: true,
|
||||
effort: true,
|
||||
agentSkillInjection: true,
|
||||
agentSkillDir: '.claude/skills',
|
||||
statusLineTelemetry: true,
|
||||
model: { source: 'claude-settings-file' },
|
||||
privilegedParams: [],
|
||||
@@ -510,7 +494,6 @@ 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
|
||||
@@ -644,7 +627,6 @@ 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
|
||||
@@ -834,7 +816,6 @@ 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
|
||||
@@ -943,7 +924,6 @@ 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
|
||||
@@ -1039,11 +1019,6 @@ 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,
|
||||
@@ -1194,7 +1169,6 @@ 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.
|
||||
@@ -1367,7 +1341,6 @@ 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.
|
||||
@@ -1533,7 +1506,6 @@ 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',
|
||||
|
||||
@@ -69,14 +69,6 @@ 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. */
|
||||
@@ -523,20 +515,7 @@ 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 `<workspace>/<agentSkillDir>/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 };
|
||||
|
||||
+20
-54
@@ -36,7 +36,6 @@ 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';
|
||||
@@ -1229,8 +1228,8 @@ interface AgentSkillFile {
|
||||
*/
|
||||
async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
|
||||
const src = agentSkillSourceDir();
|
||||
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` }];
|
||||
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` }];
|
||||
let referenceNames: string[] = [];
|
||||
try {
|
||||
referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort();
|
||||
@@ -1286,8 +1285,7 @@ export type AgentSkillApplyResult =
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
* directory, e.g. `<case>/.claude/skills/codeman` or `~/.claude/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
|
||||
@@ -1338,20 +1336,16 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
|
||||
* Seed a claude session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.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 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
|
||||
@@ -1484,59 +1478,31 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
|
||||
}
|
||||
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
|
||||
await rmdir(skillDir).catch(() => {});
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty
|
||||
return 'removed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CliCapabilities, 'agentSkillInjection' | 'agentSkillDir'> | undefined
|
||||
): AgentSkillDir | null {
|
||||
if (!capabilities?.agentSkillInjection) return null;
|
||||
return capabilities.agentSkillDir ?? CLAUDE_AGENT_SKILL_DIR;
|
||||
}
|
||||
|
||||
/** `<casePath>/<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 `<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).
|
||||
* Add or remove the Codeman agent skill in `<case>/.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.
|
||||
*
|
||||
* 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,
|
||||
* 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 `.claude/`
|
||||
* dir, 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-workspace hazard above), so
|
||||
* with live sessions, and would hit exactly the shared-`.claude/` hazard above), so
|
||||
* already-injected copies stay on disk until removed per case with
|
||||
* `codeman skill uninstall --case <name>`, 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.
|
||||
* `codeman skill uninstall --case <name>`. 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,
|
||||
dir: AgentSkillDir = CLAUDE_AGENT_SKILL_DIR
|
||||
): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = agentSkillDirFor(casePath, dir);
|
||||
export async function applyAgentSkill(casePath: string, enabled: boolean): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir);
|
||||
}
|
||||
|
||||
+1070
File diff suppressed because it is too large
Load Diff
@@ -2597,19 +2597,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="agentSkillGroup">
|
||||
<div class="set-group-head"><h4>All agent CLIs</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
<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>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -2646,6 +2633,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentTeams"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="agent skill codeman api workers">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Let new sessions start workers, send prompts and wait for results over the API.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Workspace Hooks</span>
|
||||
|
||||
@@ -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 { agentSkillDir, applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
|
||||
import { 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 (agentSkillDir(getCli(session.mode)?.capabilities) && (await ctx.getAgentSkillEnabled())) {
|
||||
if (getCli(session.mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
|
||||
@@ -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 { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import type { 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,8 +124,6 @@ import {
|
||||
writeHooksConfig,
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
agentSkillDir,
|
||||
agentSkillDirFor,
|
||||
applyAgentSkill,
|
||||
refreshUserAgentSkill,
|
||||
seedAgentSessionPreamble,
|
||||
@@ -794,8 +792,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, dir: AgentSkillDir): Promise<void> {
|
||||
const skillDir = agentSkillDirFor(casePath, dir);
|
||||
async function injectAgentSkill(casePath: string): Promise<void> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
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
|
||||
@@ -804,7 +802,7 @@ async function injectAgentSkill(casePath: string, dir: AgentSkillDir): Promise<v
|
||||
// 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, dir);
|
||||
const result = await applyAgentSkill(casePath, true);
|
||||
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.`
|
||||
@@ -1135,6 +1133,13 @@ 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
|
||||
@@ -1158,18 +1163,6 @@ 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.
|
||||
@@ -1281,9 +1274,8 @@ 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 sessions of a CLI that takes the
|
||||
// skill; best-effort.
|
||||
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
@@ -3878,14 +3870,12 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
@@ -4198,9 +4188,8 @@ 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 sessions of a CLI that takes the
|
||||
// skill; best-effort.
|
||||
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* @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 = '<!-- codeman-managed-agent-skill';
|
||||
const AGENTS_DIR = '.agents/skills' as const;
|
||||
|
||||
/** The frontmatter fields the Agent Skills format defines, with a `>-` folded description joined. */
|
||||
function frontmatter(skillMd: string): { name?: string; description?: string } {
|
||||
const match = /^---\n([\s\S]*?)\n---\n/.exec(skillMd);
|
||||
if (!match) return {};
|
||||
const lines = match[1].split('\n');
|
||||
const out: { name?: string; description?: string } = {};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const name = /^name:\s*(.+)$/.exec(lines[i]);
|
||||
if (name) out.name = name[1].trim();
|
||||
const desc = /^description:\s*(.*)$/.exec(lines[i]);
|
||||
if (desc) {
|
||||
if (desc[1].trim() === '>-' || desc[1].trim() === '>') {
|
||||
const folded: string[] = [];
|
||||
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) folded.push(lines[++i].trim());
|
||||
out.description = folded.join(' ');
|
||||
} else {
|
||||
out.description = desc[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let casePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-dirs-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the packaged skill meets the Agent Skills format', () => {
|
||||
it('skills/codeman/SKILL.md: name equals the directory, description within limits', () => {
|
||||
const fm = frontmatter(readFileSync(join(process.cwd(), 'skills', 'codeman', 'SKILL.md'), 'utf-8'));
|
||||
expect(fm.name).toBe('codeman');
|
||||
expect(fm.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
expect(fm.description, 'description').toBeTruthy();
|
||||
expect(fm.description!.length).toBeLessThanOrEqual(1024);
|
||||
// It refuses outside Codeman, and says so where every CLI reads first.
|
||||
expect(fm.description).toContain('CODEMAN_MUX=1');
|
||||
});
|
||||
|
||||
it('is the only packaged skill (one skill for every CLI)', async () => {
|
||||
expect(await readdir(join(process.cwd(), 'skills'))).toEqual(['codeman']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAgentSkill with a directory', () => {
|
||||
it('installs into <case>/.agents/skills/codeman, marker appended, and nothing under .claude', async () => {
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('installed');
|
||||
const skillDir = join(casePath, '.agents', 'skills', 'codeman');
|
||||
const skillMd = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('removes it again and prunes the emptied skills dir, never the CLI dir above it', async () => {
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
expect(await applyAgentSkill(casePath, false, AGENTS_DIR)).toBe('removed');
|
||||
expect(existsSync(join(casePath, '.agents', 'skills'))).toBe(false);
|
||||
expect(existsSync(join(casePath, '.agents'))).toBe(true);
|
||||
});
|
||||
|
||||
it('two CLIs sharing one workspace each get an identical copy where they look', async () => {
|
||||
await applyAgentSkill(casePath, true, CLAUDE_AGENT_SKILL_DIR);
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
const claudeCopy = await readFile(join(casePath, '.claude', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
const agentsCopy = await readFile(join(casePath, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
expect(agentsCopy).toBe(claudeCopy);
|
||||
});
|
||||
|
||||
it("the default directory is still Claude Code's", async () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(existsSync(agentSkillDirFor(casePath, CLAUDE_AGENT_SKILL_DIR))).toBe(true);
|
||||
});
|
||||
|
||||
it('writes nothing outside the target directory', async () => {
|
||||
await mkdir(join(casePath, 'keep'), { recursive: true });
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
expect((await readdir(casePath)).sort()).toEqual(['.agents', 'keep']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentSkillDir', () => {
|
||||
it('is null when the CLI takes no skill', () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: false, agentSkillDir: AGENTS_DIR })).toBeNull();
|
||||
expect(agentSkillDir(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to Claude Code's directory when only the old boolean is set (pre-field clis.json)", () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: true })).toBe(CLAUDE_AGENT_SKILL_DIR);
|
||||
});
|
||||
|
||||
it('uses the declared directory otherwise', () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: true, agentSkillDir: AGENTS_DIR })).toBe(AGENTS_DIR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stock registry', () => {
|
||||
it('pins the per-CLI table (measured 2026-10-11, see AGENTS_DIR_SKILL in stock.ts)', () => {
|
||||
const table = Object.fromEntries(
|
||||
['claude', 'codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'shell'].map((id) => [
|
||||
id,
|
||||
agentSkillDir(getCli(id)?.capabilities),
|
||||
])
|
||||
);
|
||||
expect(table).toEqual({
|
||||
claude: '.claude/skills',
|
||||
codex: AGENTS_DIR,
|
||||
gemini: AGENTS_DIR,
|
||||
opencode: AGENTS_DIR,
|
||||
antigravity: AGENTS_DIR,
|
||||
// A project-level skill makes pi ask "Trust project folder?" at startup.
|
||||
pi: null,
|
||||
grok: AGENTS_DIR,
|
||||
deepseek: AGENTS_DIR,
|
||||
omp: AGENTS_DIR,
|
||||
shell: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities.agentSkillDir validation (clis.json is user-editable)', () => {
|
||||
const withDir = (dir: unknown) => {
|
||||
const claude = getCli('claude')!;
|
||||
return CliEntrySchema.safeParse({ ...claude, capabilities: { ...claude.capabilities, agentSkillDir: dir } });
|
||||
};
|
||||
|
||||
it('accepts a listed directory', () => {
|
||||
expect(withDir('.agents/skills').success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a free path, traversal included', () => {
|
||||
expect(withDir('../../.ssh').success).toBe(false);
|
||||
expect(withDir('/etc').success).toBe(false);
|
||||
expect(withDir('.pi/skills').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeman skill install|uninstall follow the registry', () => {
|
||||
it('--mode claude (the default) installs into .claude/skills, user-level or per case', () => {
|
||||
expect(resolveSkillInstallDir({})).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
||||
expect(resolveSkillInstallDir({ mode: 'claude', case: 'x' })).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
||||
});
|
||||
|
||||
it('--mode codex installs per case into .agents/skills, and points a user-level install at npx skills', () => {
|
||||
expect(resolveSkillInstallDir({ mode: 'codex', case: 'x' })).toEqual({ skillDir: AGENTS_DIR });
|
||||
expect(resolveSkillInstallDir({ mode: 'codex' }).error).toContain('npx skills add Ark0N/Codeman --skill codeman -g');
|
||||
});
|
||||
|
||||
it('refuses a mode without a skill and an unknown mode', () => {
|
||||
expect(resolveSkillInstallDir({ mode: 'shell', case: 'x' }).error).toContain('takes no Codeman agent skill');
|
||||
expect(resolveSkillInstallDir({ mode: 'nope', case: 'x' }).error).toContain('Unknown mode');
|
||||
});
|
||||
|
||||
it('uninstall --case covers every distinct directory the registry can write, once each', () => {
|
||||
const dirs = registryAgentSkillDirs();
|
||||
expect(new Set(dirs).size).toBe(dirs.length);
|
||||
expect(dirs).toContain(CLAUDE_AGENT_SKILL_DIR);
|
||||
for (const cli of listClis()) {
|
||||
const dir = agentSkillDir(cli.capabilities);
|
||||
if (dir) expect(dirs, cli.id).toContain(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -132,22 +132,20 @@ describe('linked-cases.json tolerance', () => {
|
||||
});
|
||||
|
||||
describe('skill command wiring', () => {
|
||||
it('registers install and uninstall, both accepting --case and --global, install also --mode', () => {
|
||||
it('registers install and uninstall, both accepting --case and --global', () => {
|
||||
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
|
||||
expect(skill).toBeDefined();
|
||||
|
||||
const subcommands = skill!.commands.map((cmd) => cmd.name());
|
||||
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
|
||||
|
||||
const flagsOf = (name: string) =>
|
||||
skill!.commands
|
||||
for (const name of ['install', 'uninstall']) {
|
||||
const flags = skill!.commands
|
||||
.find((cmd) => cmd.name() === name)!
|
||||
.options.map((opt) => opt.long)
|
||||
.sort();
|
||||
// --mode picks the skill and directory that run mode reads (capabilities.agentSkill);
|
||||
// uninstall needs none, since --case removes every Codeman-managed copy.
|
||||
expect(flagsOf('install')).toEqual(['--case', '--global', '--mode']);
|
||||
expect(flagsOf('uninstall')).toEqual(['--case', '--global']);
|
||||
expect(flags).toEqual(['--case', '--global']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* @fileoverview `codeman mcp` (src/mcp-server.ts): the dual-era MCP protocol core, the
|
||||
* seven tools against a recording fake transport, and one end-to-end run of the real
|
||||
* CLI over stdio against a local fake Codeman.
|
||||
*
|
||||
* Pinned invariants: no guessed URL (unconfigured = tools answer how to configure, list
|
||||
* still works); prompting/interrupting/deleting only sessions this server started (or
|
||||
* the calling session's own children, or with --allow-any-session), never itself;
|
||||
* control bytes refused before any request; tool failures are `isError` results;
|
||||
* cancellation aborts the HTTP call and sends nothing for that id.
|
||||
*
|
||||
* The protocol and tool tests need no port. The stdio test binds a raw http server on
|
||||
* port 0 and reads `address().port`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildToolDefinitions,
|
||||
createMcpServer,
|
||||
createToolRunner,
|
||||
DEFAULT_WAIT_MS,
|
||||
MAX_OUTPUT_CHARS,
|
||||
resolveMcpConfig,
|
||||
RPC_ERROR,
|
||||
type McpConfig,
|
||||
} from '../src/mcp-server.js';
|
||||
import type { ApiRequest, ApiResponse, RequestOptions } from '../src/cli-agent.js';
|
||||
|
||||
const MODERN_META = {
|
||||
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
||||
'io.modelcontextprotocol/clientCapabilities': {},
|
||||
};
|
||||
|
||||
const SELF = 'aaaaaaaa-0000-0000-0000-000000000000';
|
||||
const MINE = 'bbbbbbbb-1111-1111-1111-111111111111';
|
||||
const USERS = 'cccccccc-2222-2222-2222-222222222222';
|
||||
const CHILD = 'dddddddd-3333-3333-3333-333333333333';
|
||||
|
||||
function config(overrides: Partial<McpConfig> = {}): McpConfig {
|
||||
return { apiUrl: 'http://127.0.0.1:9', allowAnySession: false, version: '0.0.0-test', ...overrides };
|
||||
}
|
||||
|
||||
function ok(data: unknown): ApiResponse {
|
||||
return { status: 200, json: { success: true, data }, text: JSON.stringify({ success: true, data }) };
|
||||
}
|
||||
|
||||
interface FakeCall extends RequestOptions {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** A fake Codeman: a session list plus per-route answers; records every call. */
|
||||
function fakeCodeman(
|
||||
rows: Array<Record<string, unknown>> = [
|
||||
{ id: SELF, name: 'lead', mode: 'claude', status: 'busy' },
|
||||
{ id: USERS, name: 'w1-myapp', mode: 'claude', status: 'idle' },
|
||||
{ id: CHILD, name: 'child', mode: 'codex', status: 'idle', parentSessionId: SELF },
|
||||
],
|
||||
answers: Record<string, (call: FakeCall) => ApiResponse | Promise<ApiResponse>> = {}
|
||||
): { request: ApiRequest; calls: FakeCall[] } {
|
||||
const calls: FakeCall[] = [];
|
||||
const request: ApiRequest = async (ctx, options) => {
|
||||
const call = { ...options, url: ctx.apiUrl };
|
||||
calls.push(call);
|
||||
const key = `${options.method} ${options.path}`;
|
||||
for (const [pattern, answer] of Object.entries(answers)) {
|
||||
if (key === pattern || (pattern.endsWith('*') && key.startsWith(pattern.slice(0, -1)))) return answer(call);
|
||||
}
|
||||
if (key === 'GET /api/v1/sessions') return ok(rows);
|
||||
if (key === 'POST /api/v1/quick-start') {
|
||||
rows.push({ id: MINE, name: 'worker', mode: String(options.body?.mode ?? 'claude'), status: 'idle' });
|
||||
return ok({
|
||||
sessionId: MINE,
|
||||
caseName: options.body?.caseName,
|
||||
casePath: `/cases/${String(options.body?.caseName)}`,
|
||||
});
|
||||
}
|
||||
if (key.endsWith('/wait-output')) return ok({ wait: { matched: true, match: options.query?.match } });
|
||||
if (key.endsWith('/input'))
|
||||
return ok({ delivered: true, wait: { signal: 'stop', until: ['stop', 'idle', 'exit'] } });
|
||||
if (options.method === 'DELETE') return ok({});
|
||||
return { status: 404, json: { success: false, error: 'not found', errorCode: 'NOT_FOUND' }, text: '' };
|
||||
};
|
||||
return { request, calls };
|
||||
}
|
||||
|
||||
function runner(cfg: McpConfig, fake = fakeCodeman()) {
|
||||
return {
|
||||
...fake,
|
||||
tools: createToolRunner({ config: cfg, request: fake.request, started: new Set(), clientId: 'c', now: () => 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drive the protocol core; returns every message it wrote. */
|
||||
function server(cfg: McpConfig = config(), fake = fakeCodeman()) {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
const srv = createMcpServer({
|
||||
config: cfg,
|
||||
write: (m) => out.push(m),
|
||||
request: fake.request,
|
||||
modes: ['claude', 'codex', 'shell'],
|
||||
});
|
||||
const send = async (message: unknown) => srv.handleLine(JSON.stringify(message));
|
||||
return { srv, out, send, ...fake };
|
||||
}
|
||||
|
||||
describe('protocol: both eras', () => {
|
||||
it('answers a legacy initialize with the requested revision, or its newest legacy one', async () => {
|
||||
const { out, send } = server();
|
||||
await send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-06-18', capabilities: {} },
|
||||
});
|
||||
await send({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2030-01-01' } });
|
||||
const [first, second] = out as Array<{ result: Record<string, unknown> }>;
|
||||
expect(first.result.protocolVersion).toBe('2025-06-18');
|
||||
expect(first.result.serverInfo).toMatchObject({ name: 'codeman' });
|
||||
expect(first.result.capabilities).toEqual({ tools: { listChanged: false } });
|
||||
expect(first.result.resultType).toBeUndefined();
|
||||
expect(second.result.protocolVersion).toBe('2025-11-25');
|
||||
});
|
||||
|
||||
it('serves server/discover with resultType, supported versions and serverInfo in _meta', async () => {
|
||||
const { out, send } = server();
|
||||
await send({ jsonrpc: '2.0', id: 'd', method: 'server/discover', params: { _meta: MODERN_META } });
|
||||
const result = (out[0] as { result: Record<string, unknown> }).result;
|
||||
expect(result.resultType).toBe('complete');
|
||||
expect(result.supportedVersions).toEqual(['2026-07-28']);
|
||||
expect((result._meta as Record<string, unknown>)['io.modelcontextprotocol/serverInfo']).toMatchObject({
|
||||
name: 'codeman',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unsupported modern version with -32022 listing what it supports', async () => {
|
||||
const { out, send } = server();
|
||||
await send({
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'tools/list',
|
||||
params: { _meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': '1999-01-01' } },
|
||||
});
|
||||
expect(out[0]).toMatchObject({
|
||||
id: 3,
|
||||
error: {
|
||||
code: RPC_ERROR.unsupportedProtocolVersion,
|
||||
data: { supported: ['2026-07-28'], requested: '1999-01-01' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a modern request without clientCapabilities as invalid params', async () => {
|
||||
const { out, send } = server();
|
||||
await send({
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: 'tools/list',
|
||||
params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } },
|
||||
});
|
||||
expect(out[0]).toMatchObject({ id: 4, error: { code: RPC_ERROR.invalidParams } });
|
||||
});
|
||||
|
||||
it('lists the same seven tools in a fixed order, decorated for the era', async () => {
|
||||
const { out, send } = server();
|
||||
await send({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
await send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: MODERN_META } });
|
||||
const legacy = (out[0] as { result: { tools: Array<{ name: string }>; resultType?: string } }).result;
|
||||
const modern = (out[1] as { result: { tools: Array<{ name: string }>; resultType?: string } }).result;
|
||||
const names = [
|
||||
'list_sessions',
|
||||
'start_session',
|
||||
'send_prompt',
|
||||
'wait_for_session',
|
||||
'read_output',
|
||||
'interrupt_session',
|
||||
'delete_session',
|
||||
];
|
||||
expect(legacy.tools.map((t) => t.name)).toEqual(names);
|
||||
expect(modern.tools.map((t) => t.name)).toEqual(names);
|
||||
expect(legacy.resultType).toBeUndefined();
|
||||
expect(modern.resultType).toBe('complete');
|
||||
});
|
||||
|
||||
it('puts the caching hints the modern revision requires on tools/list and server/discover', async () => {
|
||||
// Claude Code 2.1.296 validates these and drops every tool when they are missing.
|
||||
const { out, send } = server();
|
||||
await send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: MODERN_META } });
|
||||
await send({ jsonrpc: '2.0', id: 2, method: 'server/discover', params: { _meta: MODERN_META } });
|
||||
for (const message of out as Array<{ result: { ttlMs?: unknown; cacheScope?: unknown } }>) {
|
||||
expect(typeof message.result.ttlMs).toBe('number');
|
||||
expect(message.result.ttlMs as number).toBeGreaterThanOrEqual(0);
|
||||
expect(['public', 'private']).toContain(message.result.cacheScope);
|
||||
}
|
||||
});
|
||||
|
||||
it('answers protocol faults with JSON-RPC errors and notifications with nothing', async () => {
|
||||
const { out, srv, send } = server();
|
||||
await srv.handleLine('{not json');
|
||||
await send([{ jsonrpc: '2.0', id: 1, method: 'ping' }]);
|
||||
await send({ jsonrpc: '2.0', id: 2, method: 'resources/list' });
|
||||
await send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'rm_rf', arguments: {} } });
|
||||
await send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
||||
await send({ jsonrpc: '2.0', id: 4, method: 'ping' });
|
||||
expect(out.map((m) => (m as { error?: { code: number } }).error?.code ?? 'ok')).toEqual([
|
||||
RPC_ERROR.parse,
|
||||
RPC_ERROR.invalidRequest,
|
||||
RPC_ERROR.methodNotFound,
|
||||
RPC_ERROR.invalidParams,
|
||||
'ok',
|
||||
]);
|
||||
expect(out[0]).toMatchObject({ id: null });
|
||||
});
|
||||
|
||||
it('a cancelled call aborts its HTTP request and is never answered', async () => {
|
||||
let seen: AbortSignal | undefined;
|
||||
const fake = fakeCodeman(undefined, {
|
||||
'GET /api/v1/sessions/*': (call) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
seen = call.signal;
|
||||
call.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}),
|
||||
});
|
||||
const { srv, out, send } = server(config(), fake);
|
||||
const pending = send({
|
||||
jsonrpc: '2.0',
|
||||
id: 7,
|
||||
method: 'tools/call',
|
||||
params: { name: 'wait_for_session', arguments: { session_id: USERS, until: ['stop'] } },
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(srv.inflightCount()).toBe(1);
|
||||
await send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 7 } });
|
||||
await pending;
|
||||
expect(seen?.aborted).toBe(true);
|
||||
expect(out).toEqual([]);
|
||||
expect(srv.inflightCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('a tool failure is an isError result, not a protocol error', async () => {
|
||||
const { out, send } = server(config({ apiUrl: undefined }));
|
||||
await send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } });
|
||||
const result = (out[0] as { result: { isError: boolean; content: Array<{ text: string }> } }).result;
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('CODEMAN_API_URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool definitions', () => {
|
||||
it('every tool has an object input schema and honest annotations', () => {
|
||||
for (const tool of buildToolDefinitions(['claude'])) {
|
||||
expect(tool.inputSchema.type, tool.name).toBe('object');
|
||||
expect(tool.inputSchema.additionalProperties, tool.name).toBe(false);
|
||||
expect(tool.description.length, tool.name).toBeGreaterThan(80);
|
||||
expect(tool.annotations.openWorldHint).toBe(false);
|
||||
}
|
||||
const byName = Object.fromEntries(buildToolDefinitions(['claude']).map((t) => [t.name, t]));
|
||||
for (const readOnly of ['list_sessions', 'wait_for_session', 'read_output']) {
|
||||
expect(byName[readOnly].annotations.readOnlyHint, readOnly).toBe(true);
|
||||
}
|
||||
expect(byName.delete_session.annotations.destructiveHint).toBe(true);
|
||||
});
|
||||
|
||||
it('start_session offers the run modes it is given, defaulting to claude', () => {
|
||||
const start = buildToolDefinitions(['claude', 'codex']).find((t) => t.name === 'start_session')!;
|
||||
const mode = (start.inputSchema.properties as Record<string, { enum?: string[]; default?: string }>).mode;
|
||||
expect(mode.enum).toEqual(['claude', 'codex']);
|
||||
expect(mode.default).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration never guesses a server', () => {
|
||||
it('takes --url over CODEMAN_API_URL, and neither means unconfigured', () => {
|
||||
const env = { CODEMAN_API_URL: 'https://127.0.0.1:3000/' };
|
||||
expect(resolveMcpConfig({ version: 'x' }, env, () => ({})).apiUrl).toBe('https://127.0.0.1:3000');
|
||||
expect(resolveMcpConfig({ url: 'http://10.0.0.2:5000', version: 'x' }, env, () => ({})).apiUrl).toBe(
|
||||
'http://10.0.0.2:5000'
|
||||
);
|
||||
expect(resolveMcpConfig({ version: 'x' }, {}, () => ({})).apiUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a URL that is not http(s)', () => {
|
||||
const cfg = resolveMcpConfig({ url: 'file:///etc/passwd', version: 'x' }, {}, () => ({}));
|
||||
expect(cfg.apiUrl).toBeUndefined();
|
||||
expect(cfg.urlError).toContain('not an http(s) URL');
|
||||
});
|
||||
|
||||
it('knows its own session only inside a Codeman pane', () => {
|
||||
expect(resolveMcpConfig({ version: 'x' }, { CODEMAN_MUX: '1', CODEMAN_SESSION_ID: SELF }, () => ({})).selfId).toBe(
|
||||
SELF
|
||||
);
|
||||
expect(resolveMcpConfig({ version: 'x' }, { CODEMAN_SESSION_ID: SELF }, () => ({})).selfId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('an unconfigured tool call makes no request at all', async () => {
|
||||
const { tools, calls } = runner(config({ apiUrl: undefined }));
|
||||
const result = await tools.run('start_session', { case: 'x' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('who may be prompted, interrupted and deleted', () => {
|
||||
it('refuses a session this server did not start, with how to allow it, and sends nothing', async () => {
|
||||
const { tools, calls } = runner(config());
|
||||
for (const [tool, args] of [
|
||||
['send_prompt', { session_id: USERS, text: 'hi' }],
|
||||
['interrupt_session', { session_id: USERS }],
|
||||
['delete_session', { session_id: USERS }],
|
||||
] as const) {
|
||||
const result = await tools.run(tool, args);
|
||||
expect(result.isError, tool).toBe(true);
|
||||
expect(result.text).toContain('--allow-any-session');
|
||||
}
|
||||
expect(calls.filter((c) => c.method !== 'GET')).toEqual([]);
|
||||
});
|
||||
|
||||
it('a session it started is fair game, and leaves the allowlist once deleted', async () => {
|
||||
const { tools, calls } = runner(config());
|
||||
const started = await tools.run('start_session', { case: 'review-1', mode: 'codex' });
|
||||
expect(started.structured?.session_id).toBe(MINE);
|
||||
expect((await tools.run('send_prompt', { session_id: MINE, text: 'go', wait: false })).isError).toBe(false);
|
||||
expect((await tools.run('delete_session', { session_id: MINE.slice(0, 8) })).isError).toBe(false);
|
||||
expect(calls.some((c) => c.method === 'DELETE' && c.path === `/api/v1/sessions/${MINE}`)).toBe(true);
|
||||
expect((await tools.run('send_prompt', { session_id: MINE, text: 'again' })).isError).toBe(true);
|
||||
});
|
||||
|
||||
it('inside Codeman, the calling session can drive its own children', async () => {
|
||||
const { tools } = runner(config({ selfId: SELF }));
|
||||
expect((await tools.run('send_prompt', { session_id: CHILD, text: 'go', wait: false })).isError).toBe(false);
|
||||
expect((await tools.run('send_prompt', { session_id: USERS, text: 'go', wait: false })).isError).toBe(true);
|
||||
});
|
||||
|
||||
it('--allow-any-session opens every session except its own', async () => {
|
||||
const { tools } = runner(config({ selfId: SELF, allowAnySession: true }));
|
||||
expect((await tools.run('send_prompt', { session_id: USERS, text: 'go', wait: false })).isError).toBe(false);
|
||||
const self = await tools.run('delete_session', { session_id: SELF });
|
||||
expect(self.isError).toBe(true);
|
||||
expect(self.text).toContain('the session this server runs in');
|
||||
});
|
||||
|
||||
it('list_sessions marks the caller and what it may control', async () => {
|
||||
const { tools } = runner(config({ selfId: SELF }));
|
||||
const sessions = (await tools.run('list_sessions', {})).structured?.sessions as Array<Record<string, unknown>>;
|
||||
expect(sessions.map((s) => [s.short_id, s.you, s.controllable])).toEqual([
|
||||
['aaaaaaaa', true, false],
|
||||
['cccccccc', false, false],
|
||||
['dddddddd', false, true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tools', () => {
|
||||
it('start_session posts quick-start with lineage and the scratch label, then waits for the ready mark', async () => {
|
||||
const { tools, calls } = runner(config({ selfId: SELF }));
|
||||
const result = await tools.run('start_session', { case: 'review-1', mode: 'claude', name: 'rev' });
|
||||
const post = calls.find((c) => c.path === '/api/v1/quick-start')!;
|
||||
expect(post.body).toEqual({ caseName: 'review-1', mode: 'claude', parentSessionId: SELF, sessionName: 'rev' });
|
||||
expect(post.headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-mcp' });
|
||||
const ready = calls.find((c) => c.path.endsWith('/wait-output'))!;
|
||||
expect(ready.query?.timeout).toBe(DEFAULT_WAIT_MS);
|
||||
expect(result.structured).toMatchObject({ session_id: MINE, ready: true });
|
||||
});
|
||||
|
||||
it('send_prompt refuses a newline or control byte before any request', async () => {
|
||||
const { tools, calls } = runner(config());
|
||||
for (const text of ['line one\nline two', 'esc \u001b[A', 'tab\there']) {
|
||||
expect((await tools.run('send_prompt', { session_id: USERS, text })).isError).toBe(true);
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('send_prompt says when "finished" is definite and when it is a quiet-screen guess', async () => {
|
||||
const quiet = fakeCodeman(undefined, {
|
||||
'POST /api/v1/sessions/*': () => ok({ delivered: true, wait: { signal: 'idle', until: ['idle', 'exit'] } }),
|
||||
});
|
||||
const { tools } = runner(config({ allowAnySession: true }), quiet);
|
||||
const guessed = await tools.run('send_prompt', { session_id: USERS, text: 'go' });
|
||||
expect(guessed.structured).toMatchObject({ outcome: 'finished', signal: 'idle' });
|
||||
expect(String(guessed.structured?.note)).toContain('not definite');
|
||||
|
||||
const definite = runner(config({ allowAnySession: true }));
|
||||
const finished = await definite.tools.run('send_prompt', { session_id: USERS, text: 'go' });
|
||||
expect(String(finished.structured?.note)).toContain('finished its turn');
|
||||
const input = definite.calls.find((c) => c.path.endsWith('/input'))!;
|
||||
expect(input.body).toMatchObject({ input: 'go\r', useMux: true, wait: true, waitTimeout: DEFAULT_WAIT_MS });
|
||||
});
|
||||
|
||||
it('send_prompt reports a still-working timeout and a dead pane distinctly', async () => {
|
||||
const slow = fakeCodeman(undefined, {
|
||||
'POST /api/v1/sessions/*': () => ok({ delivered: true, wait: { signal: null, timedOut: true } }),
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await runner(config({ allowAnySession: true }), slow).tools.run('send_prompt', {
|
||||
session_id: USERS,
|
||||
text: 'go',
|
||||
})
|
||||
).structured
|
||||
).toMatchObject({ outcome: 'still_working' });
|
||||
const dead = fakeCodeman(undefined, { 'POST /api/v1/sessions/*': () => ok({ delivered: false }) });
|
||||
const result = await runner(config({ allowAnySession: true }), dead).tools.run('send_prompt', {
|
||||
session_id: USERS,
|
||||
text: 'go',
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structured).toMatchObject({ outcome: 'not_delivered' });
|
||||
});
|
||||
|
||||
it('wait_for_session needs exactly one of until and match, and passes a marker literally', async () => {
|
||||
const { tools, calls } = runner(config());
|
||||
expect((await tools.run('wait_for_session', { session_id: USERS })).isError).toBe(true);
|
||||
expect((await tools.run('wait_for_session', { session_id: USERS, until: ['stop'], match: 'X' })).isError).toBe(
|
||||
true
|
||||
);
|
||||
const matched = await tools.run('wait_for_session', { session_id: USERS, match: 'DONE_7f3a' });
|
||||
expect(matched.structured).toMatchObject({ outcome: 'matched' });
|
||||
expect(calls.at(-1)?.query).toMatchObject({ match: 'DONE_7f3a', from: 'buffer', timeout: DEFAULT_WAIT_MS });
|
||||
});
|
||||
|
||||
it('read_output strips the terminal of escape codes and keeps its tail under the cap, as text only', async () => {
|
||||
const huge = `\u001b[31mred\u001b[0m${'x'.repeat(MAX_OUTPUT_CHARS + 500)}END`;
|
||||
const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ terminalBuffer: huge }) });
|
||||
const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS, source: 'terminal' });
|
||||
const [note, body] = [
|
||||
result.text.slice(0, result.text.indexOf('\n')),
|
||||
result.text.slice(result.text.indexOf('\n') + 1),
|
||||
];
|
||||
expect(note).toBe(`[showing the last ${MAX_OUTPUT_CHARS} of ${MAX_OUTPUT_CHARS + 506} characters]`);
|
||||
expect(body.length).toBe(MAX_OUTPUT_CHARS);
|
||||
expect(body.endsWith('END')).toBe(true);
|
||||
expect(result.text).not.toContain('\u001b');
|
||||
// No structuredContent: a client that prefers it would show the model metadata, not
|
||||
// the output (measured with Claude Code, a terminal read came back "empty").
|
||||
expect(result.structured).toBeUndefined();
|
||||
});
|
||||
|
||||
it('read_output tidies a pane capture: LF line ends, blank runs collapsed, no trailing blanks', async () => {
|
||||
const pane = 'one\r\ntwo \r\n\r\n\r\n\r\nthree\r\n\r\n\r\n';
|
||||
const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ terminalBuffer: pane }) });
|
||||
const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS, source: 'terminal' });
|
||||
expect(result.text).toBe('one\ntwo\n\nthree');
|
||||
});
|
||||
|
||||
it('read_output says what to try when there is no answer yet', async () => {
|
||||
const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ text: '' }) });
|
||||
const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS });
|
||||
expect(result.text).toContain('source terminal');
|
||||
});
|
||||
|
||||
it('refuses a session id shorter than 8 characters and an ambiguous prefix', async () => {
|
||||
const twins = [{ id: 'eeeeeeee-1111-1111-1111-111111111111' }, { id: 'eeeeeeee-2222-2222-2222-222222222222' }];
|
||||
const { tools } = runner(config(), fakeCodeman(twins));
|
||||
expect((await tools.run('read_output', { session_id: 'eeee' })).text).toContain('shorter than 8');
|
||||
expect((await tools.run('read_output', { session_id: 'eeeeeeee' })).text).toContain('ambiguous');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeman mcp over real stdio', () => {
|
||||
it('handshakes and lists sessions from a live (fake) Codeman', async () => {
|
||||
const api = http.createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (req.url === '/api/v1/sessions') {
|
||||
res.end(JSON.stringify({ success: true, data: [{ id: USERS, name: 'w1', mode: 'claude', status: 'idle' }] }));
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ success: false, error: 'nope' }));
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => api.listen(0, '127.0.0.1', resolve));
|
||||
const port = (api.address() as AddressInfo).port;
|
||||
try {
|
||||
const env = { ...process.env, CODEMAN_API_URL: `http://127.0.0.1:${port}` };
|
||||
delete env.CODEMAN_MUX;
|
||||
delete env.CODEMAN_SESSION_ID;
|
||||
const child = spawn('npx', ['tsx', 'src/index.ts', 'mcp'], { cwd: process.cwd(), env });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString()));
|
||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString()));
|
||||
const lines = () => stdout.split('\n').filter(Boolean);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {} } })}\n`
|
||||
);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } })}\n`
|
||||
);
|
||||
const deadline = Date.now() + 25_000;
|
||||
while (lines().length < 2 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
|
||||
child.stdin.end();
|
||||
const exitCode = await new Promise<number | null>((resolve) => child.on('exit', resolve));
|
||||
|
||||
const messages = lines().map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
expect(messages.map((m) => m.id)).toEqual([1, 2]);
|
||||
const listed = (messages[1].result as { structuredContent: { sessions: Array<{ id: string }> } })
|
||||
.structuredContent;
|
||||
expect(listed.sessions.map((s) => s.id)).toEqual([USERS]);
|
||||
expect(stderr).toContain(`driving http://127.0.0.1:${port}`);
|
||||
expect(exitCode).toBe(0);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => api.close(() => resolve()));
|
||||
}
|
||||
}, 40_000);
|
||||
});
|
||||
|
||||
describe('the MCP registry manifest (config/mcp-registry/server.json)', () => {
|
||||
const manifest = JSON.parse(readFileSync(join(process.cwd(), 'config', 'mcp-registry', 'server.json'), 'utf-8'));
|
||||
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
|
||||
|
||||
it("carries the name package.json's mcpName declares (the registry's ownership check)", () => {
|
||||
expect(manifest.name).toBe(pkg.mcpName);
|
||||
expect(manifest.description.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('runs this npm package as `<bin> mcp` over stdio, at the package version (sync-plugin.mjs keeps it)', () => {
|
||||
expect(manifest.version).toBe(pkg.version);
|
||||
const [npm] = manifest.packages;
|
||||
expect(npm).toMatchObject({
|
||||
registryType: 'npm',
|
||||
identifier: pkg.name,
|
||||
version: pkg.version,
|
||||
transport: { type: 'stdio' },
|
||||
packageArguments: [{ type: 'positional', value: 'mcp' }],
|
||||
});
|
||||
const env = Object.fromEntries(npm.environmentVariables.map((v: { name: string }) => [v.name, v]));
|
||||
expect(env.CODEMAN_API_URL.isRequired).toBe(true);
|
||||
expect(env.CODEMAN_PASSWORD.isSecret).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -10,25 +10,16 @@
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { resolveCliLaunchError } from '../../src/utils/cli-launcher.js';
|
||||
|
||||
// External CLIs are pre-flighted (is the binary there?) before anything touches the
|
||||
// workspace; CI has none installed, so the pre-flight is stubbed to "can start" and one
|
||||
// test flips it to a refusal.
|
||||
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
|
||||
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
|
||||
@@ -113,58 +104,6 @@ describe('POST /api/sessions agent-skill injection', () => {
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
});
|
||||
|
||||
it('gives a codex session the skill in .agents/skills, where codex looks, and none in .claude', async () => {
|
||||
harness = await createHarness(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-codex', mode: 'codex', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const skillMd = await readFile(join(workingDir, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(workingDir, '.agents', 'skills', 'codeman', 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
|
||||
// The preamble cache is seeded for it too, so the skill's §0 is the two-line loader
|
||||
// in codex as in claude (nothing in the preamble is claude-specific).
|
||||
const sessionId = (JSON.parse(res.body) as { session: { id: string } }).session.id;
|
||||
const preamble = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), `codeman-agent-${sessionId}.sh`);
|
||||
expect(existsSync(preamble)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives a pi session nothing: a project skill would put up pi\'s trust prompt', async () => {
|
||||
harness = await createHarness(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-pi', mode: 'pi', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
|
||||
expect(existsSync(join(workingDir, '.pi'))).toBe(false);
|
||||
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing when the CLI cannot start (pre-flight refusal)', async () => {
|
||||
vi.mocked(resolveCliLaunchError).mockResolvedValueOnce('codex not found');
|
||||
harness = await createHarness(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-codex-missing', mode: 'codex', workingDir },
|
||||
});
|
||||
|
||||
expect(JSON.parse(res.body).success).toBe(false);
|
||||
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults an omitted mode to claude and injects', async () => {
|
||||
// The frontend omits `mode` for a plain claude create, so the default branch
|
||||
// is the common path, not an edge case.
|
||||
|
||||
Reference in New Issue
Block a user