mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94aa53c65b | ||
|
|
e88b971bb7 | ||
|
|
8406c497e2 |
@@ -1,5 +1,91 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- The Codeman agent skill is now installable, so an agent running inside a Codeman session can drive the API without you pasting docs into its prompt. Plus six fixes to the packaged skill, each found by running it live against a real instance.
|
||||
|
||||
## What the skill is
|
||||
|
||||
`skills/codeman` is a Claude Code skill that teaches an agent inside a Codeman session how to start worker sessions, send them prompts, block until they finish, read their answers and clean up. It ships in the npm package. It self-gates: outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act, so installing it globally costs unrelated sessions nothing.
|
||||
|
||||
## Installing it
|
||||
|
||||
Three ways, pick one:
|
||||
|
||||
```bash
|
||||
codeman skill install # ~/.claude/skills/codeman, every new Claude Code session sees it
|
||||
codeman skill install --case myproject # just that case; linked cases resolve by name too
|
||||
codeman skill uninstall # reverses either one
|
||||
```
|
||||
|
||||
Or turn on **App Settings > Agent Skill** (`agentSkillEnabled`, synced, default off) and Codeman injects the skill into each case when a Claude session is created there.
|
||||
|
||||
Installs are marker-owned: a `skills/codeman` that Codeman did not write is never touched, a stale managed copy is refreshed in place, and a symlinked skill directory is refused rather than written through. Re-run `codeman skill install` after upgrading Codeman to refresh the copy.
|
||||
|
||||
Note that turning `agentSkillEnabled` back off does **not** remove already-injected copies, because a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` directory. Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
## Using it
|
||||
|
||||
Once installed, just ask: "spin up three workers and have them lint, typecheck and test in parallel, then report back". The skill supplies the guard, the safety rules and the recipes. What it does under the hood:
|
||||
|
||||
**1. Guard.** Every Bash call re-runs a preamble that refuses outside `CODEMAN_MUX=1`, reads `CODEMAN_API_URL` and `CODEMAN_SESSION_ID`, recovers a password from the data dir `.env` or the install's service definition if one is set, and defines a fail-closed `delete_session`. It re-runs it every call because shell state does not survive between an agent's tool calls.
|
||||
|
||||
**2. Start a worker.**
|
||||
|
||||
```bash
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-1","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
```
|
||||
|
||||
`mode` is any of `claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`.
|
||||
|
||||
**3. Wait until it is actually ready.** A new session reports `idle` before its CLI has spawned, and a brand-new case shows a trust dialog first, so the skill waits for the composer's own status bar and treats the dialog as a bounded fallback.
|
||||
|
||||
**4. Send a prompt and wait for the turn to end.**
|
||||
|
||||
```bash
|
||||
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"codeman-agent-1",seq:1,wait:true,waitTimeout:60000}')
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' --data-binary "$BODY"
|
||||
```
|
||||
|
||||
Send-and-wait registers the waiter before typing, which closes the race where a separate wait reports the previous turn's idle state as this turn's answer. For `claude` workers it resolves on the `stop` hook, typically within seconds.
|
||||
|
||||
**5. Read the answer.**
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text'
|
||||
```
|
||||
|
||||
**6. Clean up.** `delete_session "$SID"`, for ids you created and nothing else.
|
||||
|
||||
Hook-less modes (`shell` and the external CLIs) have no `stop` signal and coarse lifecycle transitions, so the skill synchronizes those with a unique split marker and `wait-output ... from=buffer` instead. Worked fan-out flows, the per-mode signal table, error codes and the Docker/remote caveats live in the skill's `reference/` files, loaded on demand.
|
||||
|
||||
## The rules that bite
|
||||
|
||||
The skill documents these because each one silently wastes a run:
|
||||
- **Every input must end with `\r`** or Enter is never sent and the text sits unsubmitted on the worker's prompt. `delivered:true` means "written to the pane", not "submitted".
|
||||
- **Input is single-line.** Newlines are stripped.
|
||||
- **A wait timeout is HTTP 200** with `wait.timedOut:true`, not an error. Loop over short waits; timeouts clamp to [1s, 600s] and the applied value comes back as `wait.timeoutMs`.
|
||||
- **`stop` and `blocked` are `claude`-only.** Requesting them elsewhere is a 400.
|
||||
- **Signals are edge-triggered with no history.** One that fires while no waiter is registered is unobservable afterwards, so never fire-and-forget N prompts and then gather signal-waits worker by worker.
|
||||
- **Your typed command echoes into the output stream**, so a marker that appears verbatim in the input line matches before the command runs. Split it.
|
||||
- **A full-screen TUI stream is space-less**, so match a single space-free token, never a phrase.
|
||||
- **`pid != null` proves startup, not life.** A worker that dies inside its pane keeps `status:"idle"` and a pid. `wait?until=exit` is the death check.
|
||||
|
||||
## Fixes to the packaged skill
|
||||
- **The self-delete guard failed open.** The old `is_self "$SID" || curl -X DELETE ...` shape meant an undefined `is_self` exited 127, the `||` branch fired, and the agent deleted its own session with the one guard bypassed. That is reachable because shell state does not survive between tool calls, so a partially re-pasted preamble was enough. The DELETE now lives inside a fail-closed `delete_session`, which also refuses an empty id and refuses when `$SELF` is unset or too short to prove the target is not the caller.
|
||||
- **`clientId` was built from `$$`.** The pid changes between tool calls, so the documented "resend the identical request" loop stopped being recognized as a duplicate and retyped the prompt, submitting the turn twice. It is a fixed literal now.
|
||||
- **`GET /api/v1/sessions/:id/last-response` was undocumented.** It returns the agent's final message as clean transcript text; the terminal scrape the skill previously recommended returns a wall of TUI repaint noise with the answer buried in it. It is now the documented read path for `claude` and `codex`, with the terminal buffer demoted to diagnosis and hook-less modes. Because the transcript flush lags the `stop` signal, the recipes poll it instead of reading once.
|
||||
- **`quick-start` responses were never checked for `.success`.** On failure `.data.sessionId` is absent, `jq -r` prints the string `null`, and the flow burned its full readiness budget against `/api/v1/sessions/null` before reporting jq noise instead of the cause.
|
||||
- **`codeman skill install --case <name>` could not resolve a linked case.** It hardcoded `~/codeman-cases/<name>` while the server resolves through `linked-cases.json` first, so it failed with "Case not found" for a case the web UI handled fine.
|
||||
- **Documentation corrections**: `SESSION_BUSY` on `quick-start` is the 50-session cap rather than the waiter cap; `caseName` resolves linked cases, so a generic name can land a worker in a real repo; and the claim that a toggle-off sweep exists was wrong, so the per-case `skill uninstall` cleanup is now stated in both the README and the code.
|
||||
|
||||
## Also in this release
|
||||
- **Terminal**: the wheel is no longer forwarded to codex, which ignores SGR mouse reports.
|
||||
|
||||
## 1.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.14.0 (must match `package.json`)
|
||||
**Version**: 1.14.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -182,7 +182,7 @@ 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 so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case <name>]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). 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). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
@@ -212,7 +212,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
|
||||
**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
|
||||
@@ -691,6 +691,18 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
|
||||
For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running _inside_ a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too.
|
||||
|
||||
> **Shortcut: install the packaged agent skill.** Everything below (plus worked multi-worker recipes) ships as a Claude Code skill in [`skills/codeman`](skills/codeman/SKILL.md), so an agent inside a session can drive Codeman without you pasting docs into the prompt. Three ways to get it:
|
||||
>
|
||||
> - `npx skills add Ark0N/Codeman --skill codeman -g`: global, works for any skills-aware agent
|
||||
> - `codeman skill install` (global) or `codeman skill install --case <name>`: for npm installs that never cloned the repo; `codeman skill uninstall` reverses it
|
||||
> - **App Settings → Agent Skill** (`agentSkillEnabled`, default off): Codeman then injects the skill into each case on Claude session create; a user-authored `skills/codeman` in the case is never overwritten
|
||||
>
|
||||
> A global install (`codeman skill install`, or `npx skills add`) is picked up by **every new Claude Code session on the machine**, inside Codeman or not. The skill self-gates: outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act, so a global install costs an idle session nothing.
|
||||
>
|
||||
> ⚠️ 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>`.
|
||||
|
||||
|
||||
|
||||
### Detect that you're inside Codeman
|
||||
|
||||
When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything:
|
||||
|
||||
+52
-11
@@ -1,8 +1,9 @@
|
||||
# Agent Control Plan: skill packaging + wait primitives
|
||||
|
||||
**Status**: steps 1 to 5 IMPLEMENTED and multi-round verified, uncommitted as of 2026-08-08.
|
||||
Step 6 (CLI install command + per-case injection + `agentSkillEnabled`) is not built.
|
||||
See [§7 Build log](#7-build-log-what-actually-happened) for what shipped, what each
|
||||
**Status**: steps 1 to 6 IMPLEMENTED, uncommitted as of 2026-08-09. Steps 1 to 5 were
|
||||
multi-round verified on 2026-08-08; step 6 (CLI install command + per-case injection +
|
||||
`agentSkillEnabled`) was built 2026-08-09; see the step-6 entry at the end of
|
||||
[§7 Build log](#7-build-log-what-actually-happened) for what shipped, what each
|
||||
verification round found, and what is still open.
|
||||
|
||||
**Date**: 2026-08-08
|
||||
@@ -522,8 +523,8 @@ Bundled manifests plus local override only, no network.
|
||||
| 2 ✅ | `GET .../wait` + wiring in listener-wiring, hook-event-routes, server teardown | 15 route tests green; live-verified on an isolated `CODEMAN_INSTANCE=waittest` instance (immediate resolve, 400 on a bad signal, 200+`timedOut` on timeout, hook `stop` and `permission_prompt`→`blocked` waking an in-flight wait, delete delivering `exit`, SIGTERM not blocked); full `test:ci` sweep green |
|
||||
| 3 ✅ | `GET .../wait-output` | 16 route tests green; live-verified on real PTY bytes (`echo MARKER` waking a blocked request in ~1s, `from=buffer` immediate hit, never-seen marker timing out at exactly 2001ms, nocase, `regex` refused with a 400); full `test:ci` sweep green |
|
||||
| 4 ✅ | `wait` field on `POST .../input`, non-wait path proven unchanged | 16 route tests green; live-verified (no-wait returns in 26ms with the historical bare body; an idle session did NOT satisfy a `wait` request, blocking the full 2001ms, which is the race the endpoint exists to close; the stop hook resolved a send-and-wait at 1510ms and the input was confirmed in the tmux pane; `wait:null` accepted) |
|
||||
| 5 | `skills/codeman/SKILL.md` + reference files + `.claude/skills` symlink | live dogfood: a real session orchestrates a worker end to end |
|
||||
| 6 | `codeman skill install` CLI + `applyAgentSkill()` + `agentSkillEnabled` setting | settings partial-PUT test, case-creation test |
|
||||
| 5 ✅ | `skills/codeman/SKILL.md` + reference files + `.claude/skills` symlink | live dogfood: a real session orchestrates a worker end to end |
|
||||
| 6 ✅ | `codeman skill install` CLI + `applyAgentSkill()` + `agentSkillEnabled` setting | 10 unit tests (`test/agent-skill.test.ts`) + real-server case-creation tests (`test/quick-start.test.ts`, incl. the settings PUT accepting the key) green; CLI verified live (install/uninstall, global + `--case`, foreign/symlink refusals) |
|
||||
| 7 | Docs: api-reference, extending-codeman, README | |
|
||||
| 8 | COM (minor bump: new endpoints, new setting, new optional fields) | both CI and Release workflows green |
|
||||
|
||||
@@ -532,12 +533,14 @@ without the wait endpoints, so the wait work goes first.
|
||||
|
||||
## 6. Open questions for the owner
|
||||
|
||||
1. `skills/` at the repo root, accepted despite the short-root rule? (Recommended yes, the
|
||||
install one-liner depends on it.)
|
||||
2. `agentSkillEnabled` default: OFF for the first release then flip, or ON immediately?
|
||||
3. Auto-inject the skill into every case's `.claude/skills/`, or global install only?
|
||||
1. ✅ `skills/` at the repo root: accepted (built that way; the install one-liner depends on it).
|
||||
2. ✅ `agentSkillEnabled` default: **OFF** for the first release, per §2.2's rationale (skills
|
||||
cost context on every turn; measure before defaulting on). Flip later if dogfooding earns it.
|
||||
3. ✅ Both: global install via `npx skills add` / `codeman skill install`, AND per-case
|
||||
auto-injection behind the (default-off) setting. Injection is add-only at session create and
|
||||
marker-guarded, so a user-authored copy is never touched.
|
||||
4. Is `X-Codeman-Caller-Session` self-protection worth the 10 lines, given it is a footgun guard
|
||||
and not a security boundary?
|
||||
and not a security boundary? (Still open, not built with step 6.)
|
||||
5. Regex support in `wait-output`: confirm literal-only for v1.
|
||||
|
||||
---
|
||||
@@ -654,9 +657,47 @@ success without running its task. Two traps recurred often enough to name:
|
||||
- **Release checklist**: `package.json` `files` includes `skills`, which is still
|
||||
untracked. `git add skills/` must be part of the release commit, or npm publishes
|
||||
a tarball without the skill (a `files` entry that does not exist is silently
|
||||
ignored, so nothing fails).
|
||||
ignored, so nothing fails). `test/agent-skill.test.ts` reads the packaged source,
|
||||
so CI at least fails loudly if the directory goes missing from a checkout.
|
||||
- The 1.13.0 changeset is written under `.changeset/`; consuming it (COM flow),
|
||||
the release commit, and the deploy remain.
|
||||
- Deferred with Part 3: the latched last-signal-per-turn. Nice-to-haves from the
|
||||
reviews: N2 (create the death-watcher inside its `try`) and converting
|
||||
timeout-shaped test detections into fast assertions.
|
||||
- §2.4's `X-Codeman-Caller-Session` footgun guard: still not built (open question 4).
|
||||
|
||||
### Step 6 (2026-08-09): install command, per-case injection, the setting
|
||||
|
||||
Built to the §2.6 file list, mirroring the statusLine mechanism throughout:
|
||||
|
||||
| Piece | Where |
|
||||
| ----- | ----- |
|
||||
| `applyAgentSkill(casePath, enabled)` + `installAgentSkillInto` / `removeAgentSkillFrom` | `src/hooks-config.ts` |
|
||||
| `codeman skill install` / `skill uninstall` (`--global` default, `--case <name>`) | `src/cli.ts` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js` |
|
||||
| Injection call sites (Claude mode only) | `POST /api/sessions` next to `refreshStaleCodemanHooks`; `POST /api/quick-start` after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) |
|
||||
| Tests | `test/agent-skill.test.ts` (10 unit), `test/quick-start.test.ts` (real server: default-off, PUT accepts key, injection on create, shell-mode skipped) |
|
||||
|
||||
Decisions worth keeping:
|
||||
|
||||
- **Ownership marker, prefix-matched.** The injected SKILL.md ends with
|
||||
`<!-- codeman-managed-agent-skill: … -->`; install/refresh/remove all refuse a copy
|
||||
without the marker (a user's own skill) and match on the PREFIX so a wording change
|
||||
cannot disown older injected copies (the `BACKGROUND_WAKE_MARKER_PREFIX` pattern).
|
||||
- **Symlink refusal.** This repo's own dogfooding layout
|
||||
(`.claude/skills/codeman -> ../../skills/codeman`) means the injector must `lstat`
|
||||
the skill dir AND its `skills/` parent and bail on a symlink, or enabling the
|
||||
setting in the Codeman repo itself would overwrite the skill source through the link.
|
||||
- **ADD-ONLY at session create**, same shared-`.claude` rationale as the statusLine:
|
||||
a create while the setting is off must not yank the skill out from under other live
|
||||
sessions in the repo. The remove path exists (CLI `skill uninstall`, tests); no
|
||||
automatic sweep removes on toggle-off.
|
||||
- **Removal is manifest-based, never `rm -rf`**: only files the packaged source would
|
||||
have written are deleted, directories are pruned bottom-up only if they emptied, so
|
||||
a user's extra notes in `reference/` survive an uninstall.
|
||||
- **Source resolution**: `join(moduleDir, '..', 'skills', 'codeman')` works from
|
||||
`src/` (tsx), `dist/` (tsc build), and the npm tarball alike, because all three sit
|
||||
one level below the package root and `files` ships `skills/`.
|
||||
- **Nothing acts on the setting at PUT time**: injection reads the merged persisted
|
||||
settings at session create (`readSettings`, ~2s cache), so the partial-PUT invariant
|
||||
(`toggleService` reading `merged`) is untouched by construction.
|
||||
|
||||
@@ -80,7 +80,9 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (codex, claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful.
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
||||
|
||||
|
||||
@@ -160,6 +160,13 @@ Around 200 handlers across 21 route files cover sessions, cases, files, cron,
|
||||
respawn, Ralph, the orchestrator, search, and admin. Each route module carries an
|
||||
`@fileoverview` describing its endpoints.
|
||||
|
||||
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>]`, 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.
|
||||
|
||||
The common ones:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -163,6 +163,57 @@ both self-reporting, so the retest ask is now "open the console and paste the `[
|
||||
- iPhone: Claude or shell session, and whether a full tab kill changes anything.
|
||||
- Browser console: `app.terminalUi?.terminal?.modes?.mouseTrackingMode` (false-path 4).
|
||||
|
||||
## ROUND 3 (2026-08-09): Codex wheel dead — CONFIRMED AND FIXED
|
||||
|
||||
DodgyBadger (Codex latest, Chrome, Windows 11): mouse wheel does nothing in a CODEX session
|
||||
while working fine in shell and web tabs; DRAGGING THE SCROLLBAR WORKS, so xterm's local
|
||||
buffer demonstrably has content for their codex pane. Analysis against the shipped code:
|
||||
|
||||
- `_shouldForwardWheelToApp` returns true UNCONDITIONALLY for `codex` (no version gate, unlike
|
||||
claude's `>= 2.1.187`), so every plain wheel tick is sent as SGR reports to Codex.
|
||||
- The "verified to scroll its transcript on SGR wheel reports" claim for codex predates
|
||||
current Codex builds; if Codex latest ignores SGR wheel, forwarding eats the gesture while
|
||||
the healthy local scrollback (proven by the working scrollbar) sits unused.
|
||||
- The #227 PageUp fallback cannot rescue this: it is gated to `claude` mode AND `baseY === 0`,
|
||||
and codex here has real local scrollback. The `[scroll]` diagnostic will still say
|
||||
`forward-sgr (mode=codex, ...)`, confirming the branch, worth asking the reporter to paste.
|
||||
|
||||
**CONFIRMED by the reporter's `[scroll]` line (2026-08-09, PR #227 comment)**:
|
||||
`forward-sgr (mode=codex, cliVersion=unknown, localScrollbackOptOut=false, mouseTracking=none,
|
||||
localScrollbackRows=967)`. Forwarding branch active, 967 rows of healthy local scrollback
|
||||
unused, Codex ignoring the SGR reports. Environment: Codex latest, Chrome, Windows 11.
|
||||
|
||||
**Measured against codex-cli 0.147.0** (isolated `tmux -L codexwheel`, fake `CODEX_HOME/auth.json`,
|
||||
history built with 401ing prompts), which settles it without needing a version gate at all:
|
||||
|
||||
| Probe | Result |
|
||||
| ---------------------------------------------- | ----------------------------------------------- |
|
||||
| `#{mouse_any_flag}` once the TUI is up | `0`: codex never enables mouse tracking |
|
||||
| `#{alternate_on}` | `0`: inline viewport, not an alt-screen pager |
|
||||
| `#{history_size}` while prompting | grows 3 → 32: the transcript goes to scrollback |
|
||||
| 6 × `\x1b[<64;10;10M` written to the pane | pane capture byte-identical, nothing happens |
|
||||
| control: literal `zz` | pane changes, so the probe can see changes |
|
||||
| `\x1b[<0;12;5M` + release (the click-tap path) | no change either: taps are no-ops, not garbage |
|
||||
|
||||
Codex has no in-app pager to drive: its history lives in the terminal's own scrollback, which is
|
||||
exactly what forwarding was stealing the gesture from. A version gate would be the wrong fix (and
|
||||
`cliVersion=unknown` means there is no codex probe to gate on anyway).
|
||||
|
||||
**Fix (shipped):** `_shouldForwardWheelToApp` now returns true for `claude >= 2.1.187` and nothing
|
||||
else. Codex falls to the normal local-scrollback path like shell/gemini/opencode, so wheel and touch
|
||||
scroll the same history the scrollbar drag was already scrolling. The claude-only PageUp fallback is
|
||||
untouched: codex never needs it, its local buffer is real. Taps stay hand-encoded for codex
|
||||
(`_sessionUsesServerMouseStrip`), measured harmless, so click-to-position is merely unavailable
|
||||
there rather than damaging. Lesson for the next mode added to the forward list: "it is a strip mode"
|
||||
proves nothing, write a real SGR report into a live pane and diff the capture first.
|
||||
|
||||
Verified end-to-end in Chromium against a live codex session on an isolated instance
|
||||
(`CODEMAN_INSTANCE=codexwheel`, port 5055, `envOverrides.CODEX_HOME` pointing at the fake auth
|
||||
dir): trusted `page.mouse.wheel` up now logs
|
||||
`[scroll] … → local-scrollback (mode=codex, …, localScrollbackRows=43)`, moves the viewport
|
||||
39 → 4 (back to the Codex banner), and sends ZERO bytes to the PTY. Unit coverage:
|
||||
`test/terminal-touch-tap.test.ts` ("only claude forwards — codex and gemini keep the local wheel").
|
||||
|
||||
Original plan follows.
|
||||
|
||||
## Reports
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+94
-24
@@ -17,7 +17,24 @@ sessions. Every recipe below was verified live. Full endpoint tables and
|
||||
troubleshooting: [reference/endpoints.md](reference/endpoints.md). Worked multi-worker
|
||||
flows: [reference/recipes.md](reference/recipes.md).
|
||||
|
||||
## 0. Guard — run this before anything else
|
||||
## 0. Guard, and the one thing that breaks every recipe below
|
||||
|
||||
⚠️ **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. Three consequences, all of which have
|
||||
teeth:
|
||||
|
||||
- **Re-run this entire preamble at the top of every Bash call that touches the API.**
|
||||
Running it once and assuming it stuck is the single most likely way to break a run.
|
||||
- **Never re-paste only half of it.** The delete guard below is written so that a
|
||||
missing definition deletes nothing, but that only holds if you never hand-roll a
|
||||
`DELETE` of your own.
|
||||
- **Never put `$$` in a `clientId`.** It changes per call, so the "resend the identical
|
||||
request" loop in §3 would stop being a duplicate and would **retype the prompt**,
|
||||
submitting the turn twice. Use a fixed literal (`codeman-agent-1` below).
|
||||
|
||||
Only real environment variables (`CODEMAN_*`) survive, which is why this preamble
|
||||
rebuilds everything else from them.
|
||||
|
||||
```bash
|
||||
test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; }
|
||||
@@ -39,13 +56,35 @@ if [ -z "${CODEMAN_PASSWORD:-}" ]; then # stock installs: install.sh puts it
|
||||
UNIT="$HOME/.config/systemd/user/codeman-web.service"
|
||||
PLIST="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if [ -f "$UNIT" ]; then
|
||||
CODEMAN_PASSWORD=$(sed -n 's/^Environment="CODEMAN_PASSWORD=\(.*\)"$/\1/p' "$UNIT" | head -1)
|
||||
# install.sh backslash-escapes " and \ in the unit value; undo it or a password
|
||||
# containing either recovers wrong and auth fails.
|
||||
CODEMAN_PASSWORD=$(sed -n 's/^Environment="CODEMAN_PASSWORD=\(.*\)"$/\1/p' "$UNIT" | head -1 | sed 's/\\\(["\\]\)/\1/g')
|
||||
elif [ -f "$PLIST" ]; then
|
||||
CODEMAN_PASSWORD=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$PLIST" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
|
||||
# install.sh XML-escapes the plist value; undo it (& LAST, mirroring escape order).
|
||||
CODEMAN_PASSWORD=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$PLIST" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p' \
|
||||
| sed -e 's/</</g' -e 's/>/>/g' -e 's/&/\&/g')
|
||||
fi
|
||||
fi
|
||||
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
|
||||
CURL=(curl -sk "${AUTH[@]}") # -k: harmless on http, required on https (self-signed cert)
|
||||
|
||||
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||
# `is_self "$SID" || curl -X DELETE ...` shape failed OPEN, because an undefined
|
||||
# is_self exits 127 and the `||` branch then ran the delete completely unguarded.
|
||||
# Undefined delete_session is "command not found", which deletes nothing.
|
||||
delete_session() {
|
||||
local id="${1:-}"
|
||||
[ -n "$id" ] || { echo "refusing: empty session id"; return 1; }
|
||||
[ "${#SELF}" -ge 8 ] || { echo "refusing: \$SELF unset or too short to prove this is not me"; return 1; }
|
||||
# ids appear in full AND 8-char form (Docker exports a truncated $SELF; mux names and
|
||||
# UI surfaces carry 8-char ids), so compare by prefix in BOTH directions. Equality or
|
||||
# a one-directional check each miss a real combination, and the miss deletes you.
|
||||
case "$id" in "$SELF"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||
case "$SELF" in "$id"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||
"${CURL[@]}" -X DELETE "$API/api/v1/sessions/$id"
|
||||
}
|
||||
|
||||
CID=codeman-agent-1 # FIXED literal, never "agent-$$" (see §0)
|
||||
```
|
||||
|
||||
- If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
@@ -67,20 +106,16 @@ CURL=(curl -sk "${AUTH[@]}") # -k: harmless on http, required on https (self-s
|
||||
|
||||
You are yourself a session on this server, and the API has **no undo**.
|
||||
|
||||
- **Never act on your own session — and know that this check is the ONLY guard.**
|
||||
- **Never act on your own session, and know that `delete_session` is the ONLY guard.**
|
||||
The server has no self-protection: a session that DELETEs its own id succeeds and
|
||||
dies silently (verified live). Session ids appear in both full and 8-character
|
||||
forms (Docker cases export a truncated `$SELF`; mux names and UI surfaces carry
|
||||
8-char ids), so compare by prefix **in both directions**, never by equality:
|
||||
|
||||
```bash
|
||||
is_self() { case "$1" in "$SELF"*) return 0 ;; esac; case "$SELF" in "$1"*) return 0 ;; esac; return 1; }
|
||||
```
|
||||
|
||||
One-directional or equality checks each miss a real combination (full `$SELF` vs
|
||||
a target you transcribed in 8-char form, or truncated `$SELF` vs a full target)
|
||||
and the miss deletes you. Check `is_self` before every `DELETE`, kill, respawn,
|
||||
or input call.
|
||||
dies silently (verified live). **Always delete through `delete_session "$SID"` from
|
||||
§0; never write a bare `curl -X DELETE` and never reintroduce the
|
||||
`is_self … || curl -X DELETE …` shape.** That older form failed open: with the
|
||||
function undefined (a half-re-pasted preamble, see §0) bash returns 127, the `||`
|
||||
branch fires, and the delete runs with no self-check at all. Wrapping the request
|
||||
inside the guard is what makes a lost preamble delete nothing instead of deleting
|
||||
you. Apply the same prefix-both-directions reasoning before any kill, respawn, or
|
||||
input call you write by hand.
|
||||
- **Mutating calls you may make unprompted** (this is an allowlist):
|
||||
`POST /api/v1/quick-start`, `POST /api/v1/sessions/:id/input`, and
|
||||
`DELETE /api/v1/sessions/:id` **only** for a session you created in this
|
||||
@@ -162,15 +197,25 @@ the composer is not) and always pays it in full before the fallback runs — the
|
||||
budget belongs to stage 3, after the dialog is answered:
|
||||
|
||||
```bash
|
||||
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-1","mode":"claude"}' | jq -r '.data.sessionId')
|
||||
# ALWAYS check .success: on failure `.data.sessionId` is null, jq -r prints the string
|
||||
# "null", and the flow below then burns its full readiness budget against
|
||||
# /api/v1/sessions/null before reporting jq noise instead of the actual cause.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-1","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
if [ -z "$SID" ]; then
|
||||
# SESSION_BUSY here is the 50-session cap, not the waiter cap; FORBIDDEN/CONFLICT/
|
||||
# OPERATION_FAILED/INVALID_INPUT are the others. None are retryable in a loop.
|
||||
jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed; stopping."
|
||||
exit 1
|
||||
fi
|
||||
for _ in $(seq 1 30); do # bounded: a bad SID would otherwise poll forever
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||
done
|
||||
# ⚠️ pid != null proves STARTUP only, never life: a worker that later dies inside
|
||||
# its pane keeps status "idle" and a pid (the local tmux attach client, not the
|
||||
# worker). The death check is wait?until=exit, below.
|
||||
CID="agent-$$"; SEQ=1
|
||||
SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$
|
||||
# the composer's status bar ("bypass permissions on") is the ready marker — Codeman
|
||||
# spawns claude in bypass mode. Single-token matches only: TUI text is space-less.
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
@@ -245,15 +290,40 @@ SEQ=$((SEQ+1))
|
||||
The typed line shows `${M}_…`, the real output shows `DONE_… rc=<exit code>`, and the
|
||||
snippet carries the exit code back to you.
|
||||
|
||||
**Read a worker's output** — the terminal buffer, tail in **bytes** (`textOutput` in
|
||||
`GET .../output` stays empty for interactive sessions; don't use it):
|
||||
**Read a worker's answer.** For `claude` and `codex` workers this is the read path:
|
||||
`last-response` returns the agent's final message as clean text, taken from the
|
||||
transcript rather than the screen, so it carries none of the TUI's box-drawing or
|
||||
repaint noise.
|
||||
|
||||
```bash
|
||||
for _ in $(seq 1 10); do # the transcript write LAGS the stop signal
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
```
|
||||
|
||||
`.data` is `{text, timestamp}`. ⚠️ **Poll it, do not read it once.** `text` is written
|
||||
from the transcript file, which is flushed slightly *after* the `stop` hook fires, so a
|
||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, verified live), which is
|
||||
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer there, tail in **bytes**
|
||||
(`textOutput` in `GET .../output` stays empty for interactive sessions; don't use it):
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=3000" | jq -r '.data.terminalBuffer' \
|
||||
| sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g' | grep -v '^[[:space:]]*$' | tail -30
|
||||
```
|
||||
|
||||
Avoid `?full=1` (entire tmux scrollback, a context bomb) unless doing a post-mortem.
|
||||
⚠️ Do not use that pipeline to read a **claude/codex** answer. A full-screen TUI draws
|
||||
with cursor moves, so the stripped buffer is largely one long line: `tail -30` has
|
||||
almost nothing to split on and you get a wall of repaint noise with the answer buried
|
||||
in it (verified live, side by side with `last-response` returning the exact prose).
|
||||
The terminal buffer is for *diagnosis* (is my prompt sitting unsubmitted?), not for
|
||||
reading answers. Avoid `?full=1` (entire tmux scrollback, a context bomb) unless doing
|
||||
a post-mortem.
|
||||
|
||||
**Detect a dead worker cheaply**: `GET .../wait?until=exit&timeout=60000` answers
|
||||
immediately (`signal:"exit"`, `immediate:true`) if the PTY is gone — including a
|
||||
@@ -262,10 +332,10 @@ as `status:"idle"` with a pid (that pid is the local tmux attach client, not the
|
||||
worker). The wait routes are the only liveness check; a worker dying while a wait
|
||||
is parked resolves it within ~3 s. A session deleted mid-wait resolves in ~1 s.
|
||||
|
||||
**Clean up** — only ids you created, `is_self`-checked, one at a time:
|
||||
**Clean up** — only ids you created, one at a time, always through the §0 helper:
|
||||
|
||||
```bash
|
||||
is_self "$SID" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
Everything else (endpoint tables, per-mode signal table, error codes, capacity
|
||||
|
||||
@@ -14,7 +14,7 @@ Every JSON response: `{"success":true,"data":…}` or
|
||||
| `INVALID_INPUT` | 400 | malformed request; the message names the bad field |
|
||||
| `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope — `jq` dies with a parse error, see the guard in SKILL.md |
|
||||
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
|
||||
| `SESSION_BUSY` | 409 | this session's waiter cap (16, combined signal+output) is full |
|
||||
| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: the 50-session cap is full, so clean up before starting more |
|
||||
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
|
||||
| `OPERATION_FAILED` | 422 | well-formed but could not be completed |
|
||||
| `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full — back off; switching sessions will not help |
|
||||
@@ -32,16 +32,19 @@ Every JSON response: `{"success":true,"data":…}` or
|
||||
| unified list incl. history | `GET /api/v1/sessions/unified` → `.data.sessions[]` (NOT `.data[]`), and it folds in transcript history from the whole machine — never use it to verify cleanup; `GET /api/v1/sessions` is the cleanup check |
|
||||
| start case + session in one call | `POST /api/v1/quick-start` |
|
||||
| send input | `POST /api/v1/sessions/:id/input` |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer` |
|
||||
| **read a worker's answer** (claude/codex) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}` — clean transcript text, no TUI noise. ⚠️ **Poll it**: the transcript flush lags the `stop` signal, so a read taken the instant send-and-wait returns is `""` (verified live). Also `""` before the first completed turn, and always `""` for `shell`/`opencode`/`gemini`/`antigravity` (no transcript) |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer` — for *diagnosis* (unsubmitted prompt?), not for reading answers |
|
||||
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| background agents of a session | `GET /api/v1/subagents` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
| background agents, global list | `GET /api/v1/subagents` (admin-only in multi-user mode) |
|
||||
| server status / version | `GET /api/v1/status` → `.data.version` |
|
||||
| delete one session (yours, `is_self`-checked) | `DELETE /api/v1/sessions/:id` |
|
||||
| delete one session (yours only, via `delete_session`) | `DELETE /api/v1/sessions/:id` — never call it bare; the fail-closed helper in SKILL.md §0 is the only self-protection that exists |
|
||||
|
||||
⚠️ `GET /api/v1/sessions/:id/output` → `.data.textOutput` looks like the obvious read
|
||||
but stays **empty for interactive tmux-backed sessions** (it is fed only by the legacy
|
||||
JSON-stream path). Verified empty on live claude and shell sessions. Read
|
||||
`terminal?tail=` instead and strip ANSI:
|
||||
JSON-stream path). Verified empty on live claude and shell sessions. Use
|
||||
`last-response` for claude/codex answers; only fall back to `terminal?tail=` for
|
||||
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
|
||||
|
||||
```bash
|
||||
… | jq -r '.data.terminalBuffer' | sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g'
|
||||
@@ -53,6 +56,18 @@ JSON-stream path). Verified empty on live claude and shell sessions. Read
|
||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||
on the user's disk) if missing — do not retry it in a loop, and remember the name.
|
||||
|
||||
⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field
|
||||
is absent, `jq -r` prints the literal string `null`, and every later call then targets
|
||||
`/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise
|
||||
instead of the real cause. Failure modes here are `SESSION_BUSY` (the **50-session
|
||||
cap**, not the waiter cap), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and
|
||||
`INVALID_INPUT`; none of them are retryable in a loop.
|
||||
|
||||
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
|
||||
to match a case the user linked in lands in that **real repo**, not a fresh scratch
|
||||
directory. Pick distinctive scratch names, and use a linked name deliberately when you
|
||||
do want a worker in an existing checkout.
|
||||
|
||||
`POST /api/v1/sessions/:id/input` body:
|
||||
`{"input":"one line\r","useMux":true,"clientId":"agent-1","seq":1}` plus optionally
|
||||
`"wait"` / `"waitTimeout"` (below).
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# Worked orchestration flows
|
||||
|
||||
Loaded on demand from the `codeman` skill. Every flow assumes the guard preamble from
|
||||
SKILL.md ran (`$API`, `$SELF`, `"${CURL[@]}"`, `is_self`). Track every session id you
|
||||
create; delete them (and only them) when done. Remember the two silent killers:
|
||||
**every input ends with `\r`**, and **markers must be split** so the typed-line echo
|
||||
does not match them.
|
||||
Loaded on demand from the `codeman` skill. Every flow assumes the SKILL.md §0 preamble
|
||||
is in scope (`$API`, `$SELF`, `$CID`, `"${CURL[@]}"`, `delete_session`).
|
||||
|
||||
⚠️ **That preamble does not survive between tool calls**, so re-run it at the top of
|
||||
every Bash call that uses these flows, in full. Re-pasting only part of it is the
|
||||
failure mode the fail-closed `delete_session` exists to contain, and a `clientId` you
|
||||
rebuild from `$$` changes per call, which turns the duplicate-resend loop in Flow 1
|
||||
into a second typed prompt.
|
||||
|
||||
Track every session id you create; delete them (and only them) when done. The two
|
||||
silent killers: **every input ends with `\r`**, and **markers must be split** so the
|
||||
typed-line echo does not match them.
|
||||
|
||||
## Flow 1: claude worker, end to end
|
||||
|
||||
@@ -13,11 +20,15 @@ the turn to finish, read the answer, clean up. Verified live: the stop hook reso
|
||||
the send-and-wait within seconds of the turn ending.
|
||||
|
||||
```bash
|
||||
# 1. start (returns before the CLI inside is ready)
|
||||
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-tests","mode":"claude"}' | jq -r '.data.sessionId')
|
||||
# 1. start (returns before the CLI inside is ready). ALWAYS check .success: on failure
|
||||
# .data.sessionId is null, jq -r yields the string "null", and every step below
|
||||
# then runs against /api/v1/sessions/null and reports jq noise, not the cause.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-tests","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed"; exit 1; }
|
||||
CREATED+=("$SID") # the cleanup list
|
||||
CID="agent-$$"; SEQ=1
|
||||
SEQ=1 # $CID is the fixed literal from §0; never rebuild it from $$
|
||||
|
||||
# 2. readiness. "wait for idle" or "wait for ❯" is NOT readiness: a fresh session
|
||||
# reports idle before anything spawned, and the first-run trust dialog contains ❯.
|
||||
@@ -84,13 +95,23 @@ case "$(jq -r '.data.wait.signal' <<<"$R")" in
|
||||
null) jq -e '.data.wait.ended' <<<"$R" >/dev/null && echo "worker deleted mid-wait" ;;
|
||||
esac
|
||||
|
||||
# 5. read the answer: terminal tail (BYTES), ANSI-stripped. textOutput stays empty
|
||||
# for interactive sessions; terminal?full=1 is a context bomb.
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=4000" | jq -r '.data.terminalBuffer' \
|
||||
| sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g' | grep -v '^[[:space:]]*$' | tail -30
|
||||
# 5. read the answer. For a claude worker this is last-response: clean transcript text,
|
||||
# no TUI repaint noise. Do NOT scrape the terminal for this — a full-screen TUI
|
||||
# draws with cursor moves, so the stripped buffer is nearly one long line and the
|
||||
# answer arrives buried in redraw garbage.
|
||||
# POLL it: the transcript flush lags the stop signal, so a single read taken the
|
||||
# instant step 3 returned comes back "" even though the turn finished (verified live).
|
||||
for _ in $(seq 1 10); do
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||
# always "" for shell/opencode/gemini/antigravity, which have no transcript — use
|
||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||
|
||||
# 6. clean up — exact id, own list only, self-check
|
||||
is_self "$SID" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
|
||||
# 6. clean up — exact id, own list only, through the fail-closed §0 helper
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
Increment `SEQ` for every *new* input to the same worker. Reuse the same `SEQ` only to
|
||||
@@ -104,8 +125,10 @@ live), so send-and-wait can burn its whole timeout. The reliable pattern is a sp
|
||||
unique marker plus `wait-output from=buffer`:
|
||||
|
||||
```bash
|
||||
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"builder","mode":"shell"}' | jq -r '.data.sessionId')
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"builder","mode":"shell"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed"; exit 1; }
|
||||
CREATED+=("$SID")
|
||||
for _ in $(seq 1 30); do
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||
@@ -115,7 +138,7 @@ done
|
||||
# An unsplit marker matches the echo of your own keystrokes before the build runs.
|
||||
N="${RANDOM}_$$"; MARK="DONE_$N"
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"M=DONE; npm run build; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"build-'$$'","seq":1}'
|
||||
-d '{"input":"M=DONE; npm run build; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"codeman-build-1","seq":1}'
|
||||
|
||||
for TRY in $(seq 1 30); do # BOUNDED (30 min): a \r-less send makes an uncapped loop infinite
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
@@ -136,8 +159,10 @@ waiter cap is 16 and abandoned concurrent waits pile up against it.
|
||||
```bash
|
||||
declare -A WORKER MARKS
|
||||
for task in lint typecheck unit; do
|
||||
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"fan-'"$task"'","mode":"shell"}' | jq -r '.data.sessionId')
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"fan-'"$task"'","mode":"shell"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "$task: spawn failed"; continue; }
|
||||
WORKER[$task]=$SID; CREATED+=("$SID")
|
||||
done
|
||||
for task in "${!WORKER[@]}"; do
|
||||
@@ -147,7 +172,7 @@ for task in "${!WORKER[@]}"; do
|
||||
done
|
||||
N="${task}_${RANDOM}"; MARKS[$task]="DONE_$N"
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"M=DONE; npm run '"$task"'; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"fan-'$$'","seq":1}'
|
||||
-d '{"input":"M=DONE; npm run '"$task"'; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"codeman-fan-'"$task"'","seq":1}'
|
||||
done
|
||||
for task in "${!WORKER[@]}"; do # sequential gather; each wait blocks until that worker is done
|
||||
for TRY in $(seq 1 30); do # BOUNDED per worker, same reasoning as Flow 2
|
||||
@@ -171,8 +196,8 @@ other was still running):
|
||||
|
||||
```bash
|
||||
sendwait() { # $1=sid $2=prompt $3=seq — assumes the worker passed Flow 1's readiness
|
||||
local body; body=$(jq -n --arg p "$2" --argjson s "$3" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:"fan-'$$'",seq:$s,wait:true,waitTimeout:600000}')
|
||||
local body; body=$(jq -n --arg p "$2" --argjson s "$3" --arg c "codeman-fan-$1" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:600000}')
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$1/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$body" > "/tmp/fan-$1.json"
|
||||
}
|
||||
@@ -200,7 +225,7 @@ declare -A TOK
|
||||
for i in 1 2; do
|
||||
TOK[$i]="${RANDOM}_$i"
|
||||
BODY=$(jq -n --arg p "do task $i; when completely done print the word WORKDONE immediately followed by _${TOK[$i]}" \
|
||||
--arg c "fan-$$" --argjson s 2 '{input:($p+"\r"),useMux:true,clientId:$c,seq:$s}')
|
||||
--arg c "codeman-fan-$i" --argjson s 2 '{input:($p+"\r"),useMux:true,clientId:$c,seq:$s}')
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/${SIDS[$i]}/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$BODY"
|
||||
done
|
||||
@@ -237,12 +262,17 @@ At the end of the conversation (or on abort), delete exactly what you created:
|
||||
|
||||
```bash
|
||||
for id in "${CREATED[@]}"; do
|
||||
is_self "$id" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$id"
|
||||
delete_session "$id"
|
||||
done
|
||||
```
|
||||
|
||||
- Only ids from your own `CREATED` list. Never enumerate `/api/v1/sessions` and
|
||||
delete by pattern; other sessions belong to the user.
|
||||
- Always go through `delete_session`. It refuses an empty id, refuses when `$SELF` is
|
||||
unset or too short to prove the target is not you, and prefix-checks in both
|
||||
directions. A hand-written `curl -X DELETE`, or the old
|
||||
`is_self "$id" || curl -X DELETE …`, has none of that: an undefined `is_self` exits
|
||||
127 and the `||` branch deletes unguarded.
|
||||
- If you created a *case* purely as scratch and the user confirmed it is disposable,
|
||||
`DELETE /api/v1/cases/:name` removes it — but that recursively deletes the
|
||||
directory from disk, so never do it without the user's explicit go-ahead for that
|
||||
|
||||
+110
-2
@@ -12,9 +12,11 @@ import chalk from 'chalk';
|
||||
import { createRequire } from 'module';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
@@ -119,6 +121,112 @@ program
|
||||
console.log(makeAttachmentMagicLink(filePath));
|
||||
});
|
||||
|
||||
// ============ Skill Commands ============
|
||||
|
||||
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
|
||||
/**
|
||||
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
|
||||
* shared single-user cases dir. Mirrors `resolveCasePath()` in `case-routes.ts`, which
|
||||
* is what the web UI and `quick-start` use. Without the linked-cases lookup this
|
||||
* command rejected every case linked in from outside `~/codeman-cases` with
|
||||
* "Case not found", even though the server resolved the same name fine.
|
||||
*
|
||||
* Sync and tolerant on purpose: a missing or malformed registry means "no linked
|
||||
* cases", never a crash.
|
||||
*/
|
||||
function resolveCliCasePath(name: string): string {
|
||||
try {
|
||||
const linked = JSON.parse(readFileSync(LINKED_CASES_FILE, 'utf-8')) as Record<string, string>;
|
||||
const target = linked?.[name];
|
||||
if (typeof target === 'string' && target) return target;
|
||||
} catch {
|
||||
// no registry yet, or unreadable/invalid JSON: fall through to the cases dir
|
||||
}
|
||||
return join(homedir(), 'codeman-cases', name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>/.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.
|
||||
*/
|
||||
function resolveSkillTarget(options: { case?: string }): string {
|
||||
if (options.case) {
|
||||
const casePath = resolveCliCasePath(options.case);
|
||||
if (!existsSync(casePath)) {
|
||||
console.error(chalk.red(`✗ Case not found: ${casePath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return join(casePath, '.claude', 'skills', 'codeman');
|
||||
}
|
||||
return join(homedir(), '.claude', 'skills', 'codeman');
|
||||
}
|
||||
|
||||
/** 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}` },
|
||||
unchanged: { ok: true, text: `Agent skill already up to date: ${target}` },
|
||||
removed: { ok: true, text: `Agent skill removed: ${target}` },
|
||||
absent: { ok: true, text: `Nothing to remove at ${target}` },
|
||||
foreign: {
|
||||
ok: false,
|
||||
text: `${target} exists but is not Codeman-managed (no marker), refusing to touch it. Remove it yourself if you want the packaged skill there.`,
|
||||
},
|
||||
symlink: {
|
||||
ok: false,
|
||||
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
|
||||
},
|
||||
};
|
||||
const message = messages[result];
|
||||
if (message.ok) {
|
||||
console.log(chalk.green(`✓ ${message.text}`));
|
||||
} else {
|
||||
console.error(chalk.red(`✗ ${message.text}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const skillCmd = program
|
||||
.command('skill')
|
||||
.description('Manage the Codeman agent skill (lets an agent inside a session drive the API)');
|
||||
|
||||
skillCmd
|
||||
.command('install')
|
||||
.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)')
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await installAgentSkillInto(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
skillCmd
|
||||
.command('uninstall')
|
||||
.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 from <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
|
||||
+171
-2
@@ -27,8 +27,9 @@
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { readFile, writeFile, mkdir, lstat, readdir, unlink, rmdir } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
@@ -720,3 +721,171 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Agent skill injection ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Version-agnostic ownership prefix for the injected agent skill, same pattern as
|
||||
* `BACKGROUND_WAKE_MARKER_PREFIX`: ownership is decided on the prefix so a wording
|
||||
* change in the full marker cannot disown every previously injected copy.
|
||||
*/
|
||||
const AGENT_SKILL_MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
|
||||
/**
|
||||
* Marker appended to the injected SKILL.md. Its presence is what makes a copy OURS:
|
||||
* install/refresh/remove all refuse to touch a `skills/codeman` whose SKILL.md lacks
|
||||
* it, so a user's hand-authored or hand-edited-and-de-marked skill is never clobbered.
|
||||
*/
|
||||
const AGENT_SKILL_MARKER = `${AGENT_SKILL_MARKER_PREFIX}: installed by Codeman; edits are overwritten while the agent-skill setting is on -->`;
|
||||
|
||||
/**
|
||||
* Packaged source of the skill: `skills/codeman/` at the package root. Resolved
|
||||
* relative to this module so it works from `src/` (tsx dev), `dist/` (tsc build),
|
||||
* and an npm install (`files` includes `skills`), all of which sit one level below
|
||||
* the package root.
|
||||
*/
|
||||
function agentSkillSourceDir(): string {
|
||||
return join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'codeman');
|
||||
}
|
||||
|
||||
interface AgentSkillFile {
|
||||
/** Path relative to the target skill dir (e.g. `reference/endpoints.md`). */
|
||||
relPath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the packaged skill: SKILL.md (marker appended) plus every markdown file
|
||||
* under `reference/`. Enumerated from disk rather than a hardcoded manifest so a
|
||||
* new reference file ships without touching this module.
|
||||
*/
|
||||
async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
|
||||
const src = agentSkillSourceDir();
|
||||
const skill = await readFile(join(src, 'SKILL.md'), 'utf-8');
|
||||
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
|
||||
let referenceNames: string[] = [];
|
||||
try {
|
||||
referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort();
|
||||
} catch {
|
||||
// no reference dir in the source; SKILL.md alone is still a valid skill
|
||||
}
|
||||
for (const name of referenceNames) {
|
||||
files.push({ relPath: join('reference', name), content: await readFile(join(src, 'reference', name), 'utf-8') });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function isSymlink(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await lstat(path)).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** What an install/remove actually did, so callers (CLI, logs) can say so. */
|
||||
export type AgentSkillApplyResult =
|
||||
| 'installed' // fresh copy written
|
||||
| 'refreshed' // our copy was stale and got rewritten
|
||||
| 'unchanged' // our copy already matches the packaged source
|
||||
| 'removed' // our copy deleted
|
||||
| 'absent' // nothing there to remove
|
||||
| 'foreign' // a copy exists but is not ours; left untouched
|
||||
| 'symlink'; // the skill dir (or its parent) is a symlink; left untouched
|
||||
|
||||
/**
|
||||
* Install or refresh the Codeman agent skill into `skillDir` (a `.../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
|
||||
* layout (`.claude/skills/codeman -> ../../skills/codeman`) would otherwise have
|
||||
* the injector overwrite the repo source through the link;
|
||||
* - a FOREIGN copy (SKILL.md present without our marker): that is the user's own
|
||||
* skill, and per the statusLine rule we never clobber what we did not write.
|
||||
*
|
||||
* Idempotent and cheap: unchanged files are not rewritten, so calling on every
|
||||
* session create causes no mtime churn.
|
||||
*/
|
||||
export async function installAgentSkillInto(skillDir: string): Promise<AgentSkillApplyResult> {
|
||||
if ((await isSymlink(dirname(skillDir))) || (await isSymlink(skillDir))) return 'symlink';
|
||||
|
||||
let existing: string | null = null;
|
||||
try {
|
||||
existing = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
} catch {
|
||||
// absent: fresh install
|
||||
}
|
||||
if (existing !== null && !existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
|
||||
|
||||
const files = await readAgentSkillSource();
|
||||
let changed = false;
|
||||
for (const file of files) {
|
||||
const target = join(skillDir, file.relPath);
|
||||
let current: string | null = null;
|
||||
try {
|
||||
current = await readFile(target, 'utf-8');
|
||||
} catch {
|
||||
// missing: will be written
|
||||
}
|
||||
if (current === file.content) continue;
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, file.content);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return 'unchanged';
|
||||
return existing === null ? 'installed' : 'refreshed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
|
||||
* refusals as the install path. Deletes only files the packaged source would have
|
||||
* written (never `rm -rf`, so a user's extra files in the directory survive), then
|
||||
* prunes the directories bottom-up if they emptied.
|
||||
*/
|
||||
export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkillApplyResult> {
|
||||
if ((await isSymlink(dirname(skillDir))) || (await isSymlink(skillDir))) return 'symlink';
|
||||
|
||||
let existing: string | null = null;
|
||||
try {
|
||||
existing = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
} catch {
|
||||
return 'absent';
|
||||
}
|
||||
if (!existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
|
||||
|
||||
// Manifest-based, with SKILL.md as the fallback when the packaged source is
|
||||
// unreadable: removal must still work on an install whose skills/ dir went missing.
|
||||
const files = await readAgentSkillSource().catch((): AgentSkillFile[] => [{ relPath: 'SKILL.md', content: '' }]);
|
||||
for (const file of files) {
|
||||
await unlink(join(skillDir, file.relPath)).catch(() => {});
|
||||
}
|
||||
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
|
||||
await rmdir(skillDir).catch(() => {});
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `.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-`.claude/` hazard above), so
|
||||
* already-injected copies stay on disk until removed per case with
|
||||
* `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): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ConfigPort {
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
||||
getAgentSkillEnabled(): Promise<boolean>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
|
||||
@@ -1322,7 +1322,7 @@
|
||||
</div>
|
||||
<!-- Input Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Leave OFF for Claude/Codex sessions: those CLIs redraw the screen in place and keep no local scrollback, so the wheel would have almost nothing to scroll. In Claude sessions Codeman then falls back to paging the CLI's own transcript; Codex sessions have no such fallback, so the wheel goes dead. Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Only Claude sessions forward, so this setting only affects them: Claude redraws the screen in place and keeps almost no local scrollback, so with this ON the wheel has little to scroll and Codeman falls back to paging Claude's own transcript. Codex, Gemini, shell and OpenCode sessions always scroll local scrollback. Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Wheel Scrolls Local History</span>
|
||||
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
|
||||
@@ -1637,6 +1637,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Agent Skill</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsAgentSkill">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Give new Claude sessions the Codeman skill (start workers, send prompts, wait for results via the API)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Claude Model</label>
|
||||
<select id="appSettingsClaudeModel" class="form-select">
|
||||
|
||||
@@ -385,6 +385,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyCodexSettingsVisibility();
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||
@@ -1553,6 +1554,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
|
||||
|
||||
@@ -452,8 +452,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.registerFilePathLinkProvider();
|
||||
|
||||
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
||||
// wheel reports (codex, and claude 2.1.187+ — see _shouldForwardWheelToApp),
|
||||
// local scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
|
||||
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// transcript on SGR wheel reports — scrolled-away tool blocks re-render
|
||||
// live and stay clickable — and its select menus no longer capture wheel
|
||||
// as option navigation (verified against 2.1.202: /model menu highlight
|
||||
@@ -3107,11 +3107,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
||||
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
||||
// scroll its transcript on SGR wheel reports: codex, or claude 2.1.187+
|
||||
// (older Claude Code captures wheel as select-menu option navigation; an
|
||||
// unknown version is treated as older). Gemini is a strip mode too but its
|
||||
// wheel behavior is unverified, so it keeps the local wheel — taps/clicks
|
||||
// are still forwarded for it (harmless no-ops at worst).
|
||||
// scroll its transcript on SGR wheel reports — which today is claude 2.1.187+
|
||||
// and nothing else (older Claude Code captures wheel as select-menu option
|
||||
// navigation; an unknown version is treated as older). Gemini and codex are
|
||||
// strip modes too but keep the local wheel — taps/clicks are still forwarded
|
||||
// for them (harmless no-ops at worst).
|
||||
//
|
||||
// Codex USED to forward here and was the #227 regression (DodgyBadger, Codex
|
||||
// latest / Chrome / Win11: dead wheel in codex, working scrollbar drag).
|
||||
// Measured on codex-cli 0.147.0 in a bare tmux: it never enables mouse
|
||||
// tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change
|
||||
// NOTHING on screen — it runs an inline viewport (`alternate_on=0`) and pushes
|
||||
// its transcript into the terminal's own scrollback (tmux `history_size`
|
||||
// grows), so there is no in-app pager to drive and local scrollback IS the
|
||||
// codex transcript. Forwarding therefore swallowed every tick.
|
||||
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
|
||||
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
|
||||
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
|
||||
@@ -3165,11 +3174,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode && mode !== 'none') return false;
|
||||
const session = this.sessions?.get(this.activeSessionId);
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
if (sessionMode === 'claude') {
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
} else if (sessionMode !== 'codex') {
|
||||
return false;
|
||||
}
|
||||
if (sessionMode !== 'claude') return false;
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so
|
||||
// that leaving the bottom handed the wheel back to local scrollback and both
|
||||
// histories stayed reachable without a mode switch. In practice that inverted
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
applyStatusLineConfig,
|
||||
applyAgentSkill,
|
||||
refreshStaleCodemanHooks,
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -699,6 +700,13 @@ export function registerSessionRoutes(
|
||||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
||||
if ((body.mode ?? 'claude') === 'claude') {
|
||||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
||||
// 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 applyAgentSkill(workingDir, true).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
@@ -2766,6 +2774,15 @@ export function registerSessionRoutes(
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||||
// marker-guarded (a user's own skills/codeman is never touched). Claude mode only
|
||||
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
|
||||
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
|
||||
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
|
||||
if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) {
|
||||
await applyAgentSkill(resolvedCasePath, true).catch(() => {});
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||
|
||||
@@ -760,6 +760,14 @@ export const SettingsUpdateSchema = z
|
||||
/** Floating ultracode run windows w/ tab connector lines (default OFF). Also starts workflowRunWatcher. SYNCED. */
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Inject the Codeman agent skill (`skills/codeman`) into `<case>/.claude/skills/`
|
||||
* on Claude session create, so an agent inside the session can drive the API
|
||||
* (see docs/agent-control-plan.md §2). SYNCED, default OFF: every skill's
|
||||
* name+description costs context on every turn, so it is opt-in. Injection is
|
||||
* add-only at create; a marker keeps user-authored copies untouched.
|
||||
*/
|
||||
agentSkillEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||
|
||||
@@ -622,6 +622,7 @@ export class WebServer extends EventEmitter {
|
||||
getModelConfig: this.getModelConfig.bind(this),
|
||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
|
||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||
getLightState: this.getLightState.bind(this),
|
||||
getLightSessionsState: this.getLightSessionsState.bind(this),
|
||||
@@ -1652,6 +1653,13 @@ export class WebServer extends EventEmitter {
|
||||
return resolveTerminalHistoryConfig(settings);
|
||||
}
|
||||
|
||||
// Whether the Codeman agent skill is injected into cases on Claude session create
|
||||
// (synced `agentSkillEnabled` setting, default OFF; docs/agent-control-plan.md §2).
|
||||
private async getAgentSkillEnabled(): Promise<boolean> {
|
||||
const settings = await this.readSettings();
|
||||
return settings.agentSkillEnabled === true;
|
||||
}
|
||||
|
||||
// Helper to get model configuration from settings
|
||||
private async getModelConfig(): Promise<{
|
||||
defaultModel?: string;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the agent-skill injection helpers in hooks-config.ts
|
||||
* (`applyAgentSkill`, `installAgentSkillInto`, `removeAgentSkillFrom`).
|
||||
*
|
||||
* These run against the REAL packaged source (`skills/codeman/` at the repo root),
|
||||
* so they double as a guard that the skill files exist and are readable: an npm
|
||||
* publish without them would be caught here before the `files` entry silently
|
||||
* ignores the missing directory.
|
||||
*
|
||||
* Pure filesystem tests in a per-test temp dir. Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { applyAgentSkill, installAgentSkillInto, removeAgentSkillFrom } from '../src/hooks-config.js';
|
||||
|
||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
|
||||
let casePath: string;
|
||||
const skillDir = () => join(casePath, '.claude', 'skills', 'codeman');
|
||||
|
||||
beforeEach(async () => {
|
||||
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
it('installs SKILL.md (marker appended) and the reference files from the packaged source', async () => {
|
||||
const result = await applyAgentSkill(casePath, true);
|
||||
expect(result).toBe('installed');
|
||||
|
||||
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
|
||||
// Reference files ride along byte-for-byte (no marker there).
|
||||
const sourceEndpoints = await readFile(
|
||||
join(process.cwd(), 'skills', 'codeman', 'reference', 'endpoints.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const injectedEndpoints = await readFile(join(skillDir(), 'reference', 'endpoints.md'), 'utf-8');
|
||||
expect(injectedEndpoints).toBe(sourceEndpoints);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'recipes.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent: a second run reports unchanged', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('refreshes a stale Codeman-managed copy back to the packaged content', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const original = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
// Simulate an older injected version: content differs but the marker is intact.
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale content\n${MARKER_PREFIX}: old -->\n`);
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toBe(original);
|
||||
});
|
||||
|
||||
it('never clobbers a user-authored skills/codeman (no marker)', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), '---\nname: codeman\n---\nmy own skill\n');
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
|
||||
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
|
||||
await mkdir(join(casePath, '.claude', 'skills'), { recursive: true });
|
||||
await symlink(join(casePath, 'elsewhere'), skillDir());
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skills/ parent', async () => {
|
||||
await mkdir(join(casePath, 'real-skills'), { recursive: true });
|
||||
await mkdir(join(casePath, '.claude'), { recursive: true });
|
||||
await symlink(join(casePath, 'real-skills'), join(casePath, '.claude', 'skills'));
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
it('removes our copy and prunes the emptied directories', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
|
||||
// `.claude` itself is not ours to prune.
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports absent when there is nothing to remove', async () => {
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('absent');
|
||||
});
|
||||
|
||||
it('leaves a user-authored copy untouched', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), 'my own skill\n');
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('foreign');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a user's extra files in the directory (no rm -rf)", async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), 'reference', 'my-notes.md'), 'mine\n');
|
||||
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(false);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
|
||||
// The user's file and the directories holding it survive.
|
||||
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
getModelConfig: vi.fn(async () => null),
|
||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
||||
getAgentSkillEnabled: vi.fn(async () => false),
|
||||
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
||||
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
||||
getLightSessionsState: vi.fn(() => {
|
||||
|
||||
@@ -332,3 +332,83 @@ describe('Case Management', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent skill injection (agentSkillEnabled)', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
const createdCases: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createTestServer(TEST_PORT + 4); // 3103
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 4}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
for (const caseName of createdCases) {
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
if (existsSync(casePath)) {
|
||||
rmSync(casePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not inject by default, accepts the setting via PUT, then injects on quick-start', async () => {
|
||||
// 1. Default OFF: a claude quick-start creates the case without the skill.
|
||||
const offCase = 'test-skill-off-' + Date.now();
|
||||
createdCases.push(offCase);
|
||||
const offResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: offCase }),
|
||||
});
|
||||
const offData = await offResponse.json();
|
||||
expect(offData.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, offCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
|
||||
// 2. The `.strict()` settings schema accepts the new synced key.
|
||||
const putResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: true }),
|
||||
});
|
||||
const putData = await putResponse.json();
|
||||
expect(putData.success).toBe(true);
|
||||
|
||||
// 3. The server's settings read is cached ~2s; outwait it so the create sees the toggle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
|
||||
// 4. Quick-start now injects the marker-carrying skill into the new case.
|
||||
const onCase = 'test-skill-on-' + Date.now();
|
||||
createdCases.push(onCase);
|
||||
const onResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: onCase }),
|
||||
});
|
||||
const onData = await onResponse.json();
|
||||
expect(onData.success).toBe(true);
|
||||
|
||||
const skillDir = join(CASES_DIR, onCase, '.claude', 'skills', 'codeman');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const skillMd = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain('<!-- codeman-managed-agent-skill');
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDir, 'reference', 'recipes.md'))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('does not inject for shell-mode quick-start even when enabled', async () => {
|
||||
const shellCase = 'test-skill-shell-' + Date.now();
|
||||
createdCases.push(shellCase);
|
||||
const response = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: shellCase, mode: 'shell' }),
|
||||
});
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, shellCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -379,7 +379,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(withVersion('garbage')).toBe(false); // unparseable → assume older
|
||||
});
|
||||
|
||||
it('wheel: codex forwards without a version; gemini never forwards', () => {
|
||||
it('wheel: only claude forwards — codex and gemini keep the local wheel', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.terminal = {
|
||||
@@ -387,8 +387,14 @@ describe('terminal touch tap mouse guard', () => {
|
||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||
};
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]); // verified TUI, no version gate
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
// Codex used to forward unconditionally, which is PR #227's regression: measured
|
||||
// on codex-cli 0.147.0, it never enables mouse tracking and ignores SGR wheel
|
||||
// reports outright, so forwarding ate every tick while its real local scrollback
|
||||
// (the codex transcript lives there — inline viewport, no in-app pager) sat unused.
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9' }]]); // no version rescues it
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user