mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(agent-cases): tag agent-spawned case dirs and sweep their leftovers
A long orchestration creates one case directory per worker and deleting the sessions never removed them, so ~/codeman-cases accumulated scratch folders that were indistinguishable from real projects. They are now labelled and have a cleanup path. - src/agent-case-marker.ts: a case dir quick-start CREATES for an agent-driven spawn gets a .codeman-agent-case.json marker (when, by whom, parent session, mode). Only the create branch writes it, so a linked case, a cloned repo or any pre-existing path is never labelled; reading is total, so a malformed marker means "not agent-created" rather than a half-trusted entry. - The signal is the new X-Codeman-Agent-Origin header the skill preamble sets on its shared curl (preamble bumped to 1.22.0), or an agentOrigin body field, falling back to a resolved parentSessionId so a worker spawned by a stale skill copy is still labelled. - GET /api/cases publishes it as agentCreated; GET /api/cases/agent-created is a read-only cleanup listing adding inUse and modifiedAt; Add Case -> Manage badges each case and offers a review-then-delete sweep that names every directory in its confirm and skips any case a live session is working in. Removal stays on the existing DELETE /api/cases/:name. - Agent preamble caches are collected too: ~/.cache/codeman-agent-<id>.sh was written per claude session and never removed (236 leftovers measured on a working machine). Now deleted with the session and swept at boot, guarded by a live-session keep set plus a 7-day age floor. Verified end to end on an isolated instance: marker written for header, body and lineage-only spawns, absent with no agent signal and for a pre-existing directory; inUse flipping on session end; badge, sticky bar, confirm and sweep driven in a browser; preamble seeded on create, removed on delete, boot sweep taking only the aged orphans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,6 +193,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**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` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ 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. ⚠️ **`deepseek` is therefore the one non-claude mode the skill drives like claude** — `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. Two traps are baked into the preamble rather than left to the agent: the harness's boot `idle` report lands ~300 ms BEFORE its composer paints (2.26 s vs 2.56 s, measured), so readiness must come from the composer and never from the signal, or a send-and-wait resolves on the boot edge and reports a turn that never ran; and `sendwait` asks for `wait:"stop,exit"` rather than the default set, because that set also carries `idle`, which for an external CLI is inferred from output stabilization — on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn with minutes left to run. 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). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-<id>.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [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` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ 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. ⚠️ **`deepseek` is therefore the one non-claude mode the skill drives like claude** — `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. Two traps are baked into the preamble rather than left to the agent: the harness's boot `idle` report lands ~300 ms BEFORE its composer paints (2.26 s vs 2.56 s, measured), so readiness must come from the composer and never from the signal, or a send-and-wait resolves on the boot edge and reports a turn that never ran; and `sendwait` asks for `wait:"stop,exit"` rather than the default set, because that set also carries `idle`, which for an external CLI is inferred from output stabilization — on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn with minutes left to run. 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). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-<id>.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||||
|
|
||||||
|
**Agent-created case marker** (`src/agent-case-marker.ts`): a case directory `POST /api/quick-start` **creates** for an agent-driven spawn gets a `.codeman-agent-case.json` marker, so the scratch workspaces a long orchestration leaves behind (one per worker, and deleting the session does not remove them) can still be told apart from the user's real projects months later. `GET /api/cases` publishes it as `agentCreated`; `GET /api/cases/agent-created` is the read-only cleanup listing, adding `inUse` (a live session's `workingDir` is that case) and `modifiedAt`; Add Case → Manage badges each one and offers a review-then-delete sweep. The signal is the skill preamble's `X-Codeman-Agent-Origin` header (or an `agentOrigin` body field), falling back to a RESOLVED `parentSessionId` — nothing in the browser UI sets lineage, so a create request naming its spawning session came from an agent by construction, and that fallback is what still labels workers spawned by a stale skill copy. ⚠️ **Only the branch that CREATES the directory may write it.** A linked case, a cloned repo or any pre-existing path must never be labelled: the label drives a recursive-delete affordance, and mislabelling someone's repo there is the one failure mode that costs real work. `POST /api/sessions` takes an existing `workingDir`, so it writes no marker at all, by construction. ⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as *not* agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it. ⚠️ Removal stays on the existing `DELETE /api/cases/:name`, one name at a time, so there is exactly ONE recursive-delete path; the UI's sweep names every directory in its confirm and EXCLUDES an `inUse` case outright rather than confirming it away. ⚠️ Marker in the case dir rather than a registry under `~/.codeman`: it survives a wiped data dir or a different instance, is removed by the same `rm -rf` that removes the case (so no stale-entry pruning), and a user who runs `ls -a` can see what labelled their directory. Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0) — a cached copy is version-checked, and forgetting the bump leaves every already-seeded agent sending the old headers. Tests: `test/agent-case-marker.test.ts`, `test/routes/agent-case-marker-routes.test.ts`.
|
||||||
|
|
||||||
|
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
||||||
|
|
||||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||||
|
|
||||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. Claude-mode only, since an external CLI has no `❯`, so nothing would ever arm the confirmation and the session would latch busy.
|
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. Claude-mode only, since an external CLI has no `❯`, so nothing would ever arm the confirmation and the session would latch busy.
|
||||||
@@ -364,7 +368,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### API Routes
|
### API Routes
|
||||||
|
|
||||||
~227 handlers across 25 route files in `src/web/routes/`: system (56), sessions (34), cases (29), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), me (2), teams (2), tab-layout (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
~228 handlers across 25 route files in `src/web/routes/`: system (56), sessions (34), cases (30), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), me (2), teams (2), tab-layout (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||||
|
|
||||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.21.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -96,7 +96,10 @@ AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:
|
|||||||
# draw the lineage. Set once here and every present and future create call carries it;
|
# draw the lineage. Set once here and every present and future create call carries it;
|
||||||
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
||||||
# fail a spawn, so there is no case where you would want to leave it off.
|
# fail a spawn, so there is no case where you would want to leave it off.
|
||||||
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
|
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
|
||||||
|
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
|
||||||
|
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
|
||||||
|
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF" -H "X-Codeman-Agent-Origin: codeman-skill")
|
||||||
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
|
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
|
||||||
|
|
||||||
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||||
@@ -322,10 +325,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.21.0
|
CODEMAN_PREAMBLE=1.22.0
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with the same two loader lines from
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -376,7 +379,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -441,7 +444,8 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
|||||||
strands the prompt on the composer until a bare `\r` follows: all three are reasons
|
strands the prompt on the composer until a bare `\r` follows: all three are reasons
|
||||||
to let `sendwait` build the call rather than hand-rolling it.
|
to let `sendwait` build the call rather than hand-rolling it.
|
||||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||||
- Deleting the sessions does **not** remove the case directories: §5.14.
|
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||||
|
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||||
|
|
||||||
### DeepSeek Harness workers
|
### DeepSeek Harness workers
|
||||||
|
|
||||||
@@ -494,7 +498,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
|||||||
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
|
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
|
||||||
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
|
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
|
||||||
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
|
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
|
||||||
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it | [§5.14](reference/verbs.md#514-clean-up) |
|
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
|
||||||
|
|
||||||
## 3. Rules digest
|
## 3. Rules digest
|
||||||
|
|
||||||
@@ -595,7 +599,7 @@ these**; open the one row you actually hit.
|
|||||||
| [5.11 List and find yourself](reference/verbs.md#511-list-and-find-yourself) | enumerate sessions, or match `$SELF` by prefix |
|
| [5.11 List and find yourself](reference/verbs.md#511-list-and-find-yourself) | enumerate sessions, or match `$SELF` by prefix |
|
||||||
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
|
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
|
||||||
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
|
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
|
||||||
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove |
|
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
|
||||||
|
|
||||||
## 6. Setup and auth
|
## 6. Setup and auth
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -18,7 +18,10 @@ AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:
|
|||||||
# draw the lineage. Set once here and every present and future create call carries it;
|
# draw the lineage. Set once here and every present and future create call carries it;
|
||||||
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
||||||
# fail a spawn, so there is no case where you would want to leave it off.
|
# fail a spawn, so there is no case where you would want to leave it off.
|
||||||
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
|
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
|
||||||
|
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
|
||||||
|
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
|
||||||
|
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF" -H "X-Codeman-Agent-Origin: codeman-skill")
|
||||||
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
|
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
|
||||||
|
|
||||||
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||||
@@ -244,4 +247,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.21.0
|
CODEMAN_PREAMBLE=1.22.0
|
||||||
|
|||||||
@@ -364,6 +364,15 @@ the global 50, or the per-user 25 in multi-user mode, never the waiter cap),
|
|||||||
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
|
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
|
||||||
loop.
|
loop.
|
||||||
|
|
||||||
|
⚠️ A case directory quick-start **creates** for you is labelled agent-created (a
|
||||||
|
`.codeman-agent-case.json` marker, written because the §0 preamble sends
|
||||||
|
`X-Codeman-Agent-Origin`), which is what lets the user find it afterwards:
|
||||||
|
`GET /api/v1/cases/agent-created` returns `.data.cases[]` of
|
||||||
|
`{name, path, createdAt, createdBy, parentSessionId, inUse, modifiedAt}`, newest first,
|
||||||
|
read-only, scoped to the caller's own case space. Report it when you finish; deleting is
|
||||||
|
`DELETE /api/v1/cases/:name` and is the user's call by name ([§5.14](verbs.md#514-clean-up)).
|
||||||
|
A directory that already existed is never labelled.
|
||||||
|
|
||||||
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
|
⚠️ `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
|
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
|
directory. Pick distinctive scratch names, and use a linked name deliberately when you
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
@@ -731,6 +731,21 @@ Deleting a session ends the agent and its pane. It does **not** remove:
|
|||||||
it, and ask before running `git worktree remove`, which discards uncommitted work
|
it, and ask before running `git worktree remove`, which discards uncommitted work
|
||||||
inside it.
|
inside it.
|
||||||
|
|
||||||
|
Those case directories are **labelled** rather than left anonymous. A directory
|
||||||
|
`quick-start` creates for a spawn carrying the preamble's `X-Codeman-Agent-Origin`
|
||||||
|
header gets a `.codeman-agent-case.json` marker, which is what puts it in the web UI's
|
||||||
|
agent-case cleanup list (Add Case → Manage) and in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"${CURL[@]}" "$API/api/v1/cases/agent-created" | jq -r '.data.cases[] | "\(.name)\t\(.createdAt)\tinUse=\(.inUse)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-only, scoped to the user's own case space, and `inUse` is true while a live
|
||||||
|
session is still working in that directory. Report that list when you finish a run
|
||||||
|
with workers, so the user knows exactly what to sweep; the deletion is still theirs to
|
||||||
|
ask for by name. Only a directory Codeman **created** is ever labelled, so a linked
|
||||||
|
case, a cloned repo or a worktree never appears there.
|
||||||
|
|
||||||
Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unified`
|
Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unified`
|
||||||
(that one folds in transcript history from the whole machine and will keep showing
|
(that one folds in transcript history from the whole machine and will keep showing
|
||||||
your worker forever).
|
your worker forever).
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The marker file that records a case directory as one Codeman scaffolded
|
||||||
|
* FOR an agent-spawned session, so scratch worker workspaces can be told apart from the
|
||||||
|
* user's real projects long after the sessions that created them are gone.
|
||||||
|
*
|
||||||
|
* Why a file in the case directory rather than a central registry in `~/.codeman`:
|
||||||
|
* the thing being labelled is a directory on the user's disk, and the label has to
|
||||||
|
* survive everything that can happen to Codeman's own state (a wiped data dir, a
|
||||||
|
* different instance, a hand-moved case). A registry would also need stale-entry
|
||||||
|
* pruning and owner scoping of its own, while a marker is deleted by the same `rm -rf`
|
||||||
|
* that deletes the case, and is discoverable by a user who just runs `ls -a`.
|
||||||
|
*
|
||||||
|
* ⚠️ Written ONLY on the path that CREATES the directory (`POST /api/quick-start`'s
|
||||||
|
* `!existsSync` branch). A linked case, a cloned repo, a git worktree or any other
|
||||||
|
* pre-existing directory must never be labelled agent-created: the label drives a
|
||||||
|
* cleanup affordance, and mislabelling someone's repo there is the one failure mode
|
||||||
|
* that costs real work. `POST /api/sessions` takes an existing `workingDir` and so
|
||||||
|
* writes no marker at all, by construction.
|
||||||
|
*
|
||||||
|
* ⚠️ Reading is strict and total: anything that does not parse as a version-1 marker
|
||||||
|
* (truncated write, hand-edited junk, a user's unrelated file of the same name) reads
|
||||||
|
* as "not agent-created" rather than as a partially-trusted entry. A marker is
|
||||||
|
* metadata; deleting the file is the supported way to adopt a scratch case as a real
|
||||||
|
* one, which is what the `note` field written into it tells the user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/** Marker filename inside the case directory. Dot-prefixed so it stays out of the way. */
|
||||||
|
export const AGENT_CASE_MARKER_FILE = '.codeman-agent-case.json';
|
||||||
|
|
||||||
|
/** Current marker schema version. A marker of any other version reads as absent. */
|
||||||
|
export const AGENT_CASE_MARKER_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin recorded when a create request carried a resolvable spawning session but no
|
||||||
|
* explicit origin of its own (an agent driving the API by hand, or an older copy of
|
||||||
|
* the skill). Nothing in the browser UI sets lineage, so this really does mean "another
|
||||||
|
* session spawned this", not "a human clicked Run".
|
||||||
|
*/
|
||||||
|
export const AGENT_ORIGIN_SPAWNED_BY_SESSION = 'agent-session';
|
||||||
|
|
||||||
|
/** Origin the packaged agent skill sends on its shared curl invocation. */
|
||||||
|
export const AGENT_ORIGIN_CODEMAN_SKILL = 'codeman-skill';
|
||||||
|
|
||||||
|
/** Longest accepted origin token (the value is echoed into the UI and the marker). */
|
||||||
|
const MAX_ORIGIN_LENGTH = 32;
|
||||||
|
|
||||||
|
/** Longest accepted free-text field read back out of a marker. */
|
||||||
|
const MAX_MARKER_FIELD_LENGTH = 200;
|
||||||
|
|
||||||
|
/** Lowercase token: what an origin may look like on the wire and on disk. */
|
||||||
|
const AGENT_ORIGIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
||||||
|
|
||||||
|
/** Explains the file to whoever finds it in their case directory. */
|
||||||
|
const MARKER_NOTE =
|
||||||
|
'Created by a Codeman agent worker (see the Manage tab in Add Case). ' +
|
||||||
|
'Delete this file to keep the case out of the agent-case cleanup list; ' +
|
||||||
|
'deleting the whole directory removes the case.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a case directory records about the agent spawn that created it.
|
||||||
|
* Every field beyond `version`/`createdAt`/`createdBy` is decoration for the cleanup UI.
|
||||||
|
*/
|
||||||
|
export interface AgentCaseMarker {
|
||||||
|
version: typeof AGENT_CASE_MARKER_VERSION;
|
||||||
|
/** ISO timestamp of the spawn that created the directory. */
|
||||||
|
createdAt: string;
|
||||||
|
/** Who asked: `codeman-skill`, `agent-session`, or another caller's own token. */
|
||||||
|
createdBy: string;
|
||||||
|
/** Full id of the session that spawned the worker, when one resolved. */
|
||||||
|
parentSessionId?: string;
|
||||||
|
/** That session's display name at spawn time, so the user recognises it later. */
|
||||||
|
parentSessionName?: string;
|
||||||
|
/** Run mode the worker was started in (`claude`, `deepseek`, …). */
|
||||||
|
mode?: string;
|
||||||
|
/** Owner the case was created for, in multi-user mode. */
|
||||||
|
owner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an origin token coming off the wire (`agentOrigin` body field or the
|
||||||
|
* `X-Codeman-Agent-Origin` header). Returns `undefined` for anything that is not a
|
||||||
|
* short lowercase token — the value reaches the UI and a JSON file, so it is
|
||||||
|
* allowlisted rather than escaped at each use.
|
||||||
|
*/
|
||||||
|
export function normalizeAgentOrigin(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
if (!value || value.length > MAX_ORIGIN_LENGTH) return undefined;
|
||||||
|
return AGENT_ORIGIN_PATTERN.test(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim an optional free-text marker field to something safe to store and render. */
|
||||||
|
function normalizeField(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const value = raw.trim();
|
||||||
|
return value ? value.slice(0, MAX_MARKER_FIELD_LENGTH) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a marker from a spawn's details. Pure, so the route can hand it straight to
|
||||||
|
* the writer and the tests can assert on the shape without touching a disk.
|
||||||
|
*/
|
||||||
|
export function buildAgentCaseMarker(input: {
|
||||||
|
createdBy: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
parentSessionId?: string;
|
||||||
|
parentSessionName?: string;
|
||||||
|
mode?: string;
|
||||||
|
owner?: string;
|
||||||
|
}): AgentCaseMarker {
|
||||||
|
const marker: AgentCaseMarker = {
|
||||||
|
version: AGENT_CASE_MARKER_VERSION,
|
||||||
|
createdAt: (input.createdAt ?? new Date()).toISOString(),
|
||||||
|
createdBy: normalizeAgentOrigin(input.createdBy) ?? AGENT_ORIGIN_SPAWNED_BY_SESSION,
|
||||||
|
};
|
||||||
|
const parentSessionId = normalizeField(input.parentSessionId);
|
||||||
|
const parentSessionName = normalizeField(input.parentSessionName);
|
||||||
|
const mode = normalizeField(input.mode);
|
||||||
|
const owner = normalizeField(input.owner);
|
||||||
|
if (parentSessionId) marker.parentSessionId = parentSessionId;
|
||||||
|
if (parentSessionName) marker.parentSessionName = parentSessionName;
|
||||||
|
if (mode) marker.mode = mode;
|
||||||
|
if (owner) marker.owner = owner;
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse marker JSON. Returns `null` for anything that is not a well-formed version-1
|
||||||
|
* marker, including a valid-JSON object of the wrong shape — see the strictness note
|
||||||
|
* in the file header.
|
||||||
|
*/
|
||||||
|
export function parseAgentCaseMarker(raw: string): AgentCaseMarker | null {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (record.version !== AGENT_CASE_MARKER_VERSION) return null;
|
||||||
|
|
||||||
|
const createdAt = normalizeField(record.createdAt);
|
||||||
|
const createdBy = normalizeAgentOrigin(record.createdBy);
|
||||||
|
if (!createdAt || !createdBy || Number.isNaN(Date.parse(createdAt))) return null;
|
||||||
|
|
||||||
|
return buildAgentCaseMarker({
|
||||||
|
createdBy,
|
||||||
|
createdAt: new Date(createdAt),
|
||||||
|
parentSessionId: normalizeField(record.parentSessionId),
|
||||||
|
parentSessionName: normalizeField(record.parentSessionName),
|
||||||
|
mode: normalizeField(record.mode),
|
||||||
|
owner: normalizeField(record.owner),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the marker into `casePath`. Best-effort by design: the marker is metadata for
|
||||||
|
* a later cleanup, and a failed write must never fail the worker spawn that is the
|
||||||
|
* point of the request. Returns whether it landed.
|
||||||
|
*/
|
||||||
|
export async function writeAgentCaseMarker(casePath: string, marker: AgentCaseMarker): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const body = JSON.stringify({ ...marker, note: MARKER_NOTE }, null, 2);
|
||||||
|
await writeFile(join(casePath, AGENT_CASE_MARKER_FILE), `${body}\n`, 'utf-8');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the marker out of `casePath`, or `null` if there isn't a valid one. */
|
||||||
|
export async function readAgentCaseMarker(casePath: string): Promise<AgentCaseMarker | null> {
|
||||||
|
try {
|
||||||
|
return parseAgentCaseMarker(await readFile(join(casePath, AGENT_CASE_MARKER_FILE), 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-3
@@ -1066,9 +1066,80 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
|||||||
*/
|
*/
|
||||||
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
|
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||||
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
|
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
|
||||||
const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
await mkdir(agentPreambleCacheDir(), { recursive: true });
|
||||||
await mkdir(cacheDir, { recursive: true });
|
await writeFile(agentPreamblePath(sessionId), content, { mode: 0o600 });
|
||||||
await writeFile(join(cacheDir, `codeman-agent-${sessionId}.sh`), content, { mode: 0o600 });
|
}
|
||||||
|
|
||||||
|
/** Where the preamble caches live. One formula, shared by seed / remove / prune. */
|
||||||
|
function agentPreambleCacheDir(): string {
|
||||||
|
return process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `codeman-agent-<sessionId>.sh` in that directory. */
|
||||||
|
function agentPreamblePath(sessionId: string): string {
|
||||||
|
return join(agentPreambleCacheDir(), `codeman-agent-${sessionId}.sh`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches exactly what seedAgentSessionPreamble writes, and nothing else in ~/.cache. */
|
||||||
|
const AGENT_PREAMBLE_FILE_PATTERN = /^codeman-agent-(.+)\.sh$/;
|
||||||
|
|
||||||
|
/** How long a preamble cache with no live session behind it is kept before the sweep takes it. */
|
||||||
|
export const AGENT_PREAMBLE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop one session's preamble cache. Called when a session is deleted, which is the
|
||||||
|
* precise counterpart to seeding it at create: one file per claude session was being
|
||||||
|
* written and nothing ever removed them (236 leftovers measured on a working machine,
|
||||||
|
* the oldest three weeks old). Best-effort — a file that will not delete is litter,
|
||||||
|
* never a reason to fail a teardown.
|
||||||
|
*/
|
||||||
|
export async function removeAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||||
|
await unlink(agentPreamblePath(sessionId)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep preamble caches left by sessions that are gone: the delete path above covers
|
||||||
|
* an orderly teardown, and this covers everything else (a crash, a killed server, a
|
||||||
|
* session deleted by an older build, another instance's leftovers).
|
||||||
|
*
|
||||||
|
* ⚠️ Two guards, and both matter: a file whose session is in `keepSessionIds` is never
|
||||||
|
* touched however old it is, and everything else needs `maxAgeMs` of age on top. A live
|
||||||
|
* session's cache is load-bearing — remove it and the skill's two-line loader fails its
|
||||||
|
* version check mid-run — and the age floor is what keeps a session belonging to
|
||||||
|
* ANOTHER instance (whose ids this process cannot see) out of the blast radius. Losing
|
||||||
|
* one is degradation rather than breakage: the §0 fallback block rewrites it.
|
||||||
|
*
|
||||||
|
* Returns how many it removed. Best-effort throughout; a missing cache dir is 0.
|
||||||
|
*/
|
||||||
|
export async function pruneAgentSessionPreambles(
|
||||||
|
keepSessionIds: Iterable<string>,
|
||||||
|
maxAgeMs: number = AGENT_PREAMBLE_MAX_AGE_MS
|
||||||
|
): Promise<number> {
|
||||||
|
const cacheDir = agentPreambleCacheDir();
|
||||||
|
const keep = new Set(keepSessionIds);
|
||||||
|
const cutoff = Date.now() - maxAgeMs;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(cacheDir);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const sessionId = AGENT_PREAMBLE_FILE_PATTERN.exec(entry)?.[1];
|
||||||
|
if (!sessionId || keep.has(sessionId)) continue;
|
||||||
|
const path = join(cacheDir, entry);
|
||||||
|
try {
|
||||||
|
if ((await lstat(path)).mtimeMs > cutoff) continue;
|
||||||
|
await unlink(path);
|
||||||
|
removed++;
|
||||||
|
} catch {
|
||||||
|
/* best-effort — a vanished or unreadable file is not our problem */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -157,6 +157,20 @@ export interface CaseInfo {
|
|||||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||||
/** Whether this is a linked local folder */
|
/** Whether this is a linked local folder */
|
||||||
linked?: boolean;
|
linked?: boolean;
|
||||||
|
/**
|
||||||
|
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
|
||||||
|
* (the packaged skill's workers, or any spawn naming a parent session), read back
|
||||||
|
* from the case's own marker file — see `src/agent-case-marker.ts`. Absent for every
|
||||||
|
* case a human created, linked or cloned, which is what makes it usable as the
|
||||||
|
* "safe to clean up" signal in the Manage tab.
|
||||||
|
*/
|
||||||
|
agentCreated?: {
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
parentSessionName?: string;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
/** Remote case metadata for display and session creation */
|
/** Remote case metadata for display and session creation */
|
||||||
remote?: {
|
remote?: {
|
||||||
hostId: string;
|
hostId: string;
|
||||||
@@ -192,6 +206,25 @@ export interface CaseInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One agent-created case as `GET /api/cases/agent-created` reports it: the cleanup
|
||||||
|
* view over `CaseInfo.agentCreated`, with the two facts a human needs before deleting
|
||||||
|
* a directory — whether an agent is still working in it, and when it was last touched.
|
||||||
|
*/
|
||||||
|
export interface AgentCaseSummary {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
parentSessionName?: string;
|
||||||
|
mode?: string;
|
||||||
|
/** A live session's working directory is this case — deleting it would pull the rug. */
|
||||||
|
inUse: boolean;
|
||||||
|
/** Directory mtime, so "nothing has touched this in a week" is answerable. */
|
||||||
|
modifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Error Handling Utilities ==========
|
// ========== Error Handling Utilities ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3595,17 +3595,33 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
||||||
|
// A long orchestration leaves one scratch directory per worker behind, so they get
|
||||||
|
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
||||||
|
const agentCases = cases.filter(c => c.agentCreated);
|
||||||
|
let html = agentCases.length > 0
|
||||||
|
? `<div class="case-manage-agent-bar">
|
||||||
|
<span class="case-manage-agent-count">${agentCases.length} case${agentCases.length === 1 ? '' : 's'} created by agent workers</span>
|
||||||
|
<button class="case-manage-btn case-manage-btn-cleanup" onclick="app.cleanupAgentCases()"
|
||||||
|
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
cases.forEach((c, idx) => {
|
cases.forEach((c, idx) => {
|
||||||
const isFirst = idx === 0;
|
const isFirst = idx === 0;
|
||||||
const isLast = idx === cases.length - 1;
|
const isLast = idx === cases.length - 1;
|
||||||
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
||||||
// case path on a Linux host rendered in full, unabbreviated.
|
// case path on a Linux host rendered in full, unabbreviated.
|
||||||
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
||||||
|
const agentTitle = c.agentCreated
|
||||||
|
? `Created by an agent worker${c.agentCreated.parentSessionName ? ` from ${c.agentCreated.parentSessionName}` : ''}` +
|
||||||
|
` (${c.agentCreated.createdBy})${c.agentCreated.createdAt ? ` on ${new Date(c.agentCreated.createdAt).toLocaleString()}` : ''}`
|
||||||
|
: '';
|
||||||
html += `
|
html += `
|
||||||
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
||||||
<div class="case-manage-info">
|
<div class="case-manage-info">
|
||||||
<span class="case-manage-name">${escapeHtml(c.name)}</span>
|
<span class="case-manage-name">${escapeHtml(c.name)}${
|
||||||
|
c.agentCreated ? `<span class="case-manage-tag-agent" title="${escapeHtml(agentTitle)}" data-i18n-skip>agent</span>` : ''
|
||||||
|
}</span>
|
||||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-manage-actions">
|
<div class="case-manage-actions">
|
||||||
@@ -3683,6 +3699,80 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review-then-delete the scratch cases agent workers left behind.
|
||||||
|
*
|
||||||
|
* ⚠️ Never silently bulk-deletes: the confirm names every directory, and a case a
|
||||||
|
* LIVE session is still working in is excluded outright rather than confirmed away
|
||||||
|
* (`inUse` from the server, which knows every session's working directory). Removal
|
||||||
|
* reuses `DELETE /api/cases/:name` one name at a time, so there is no second
|
||||||
|
* recursive-delete path to keep in step with the first.
|
||||||
|
*/
|
||||||
|
async cleanupAgentCases() {
|
||||||
|
let agentCases;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cases/agent-created');
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body.success) {
|
||||||
|
this.showToast(body.error || 'Failed to list agent cases', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
agentCases = body.data.cases || [];
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast('Failed to list agent cases: ' + err.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = agentCases.filter(c => c.inUse);
|
||||||
|
const removable = agentCases.filter(c => !c.inUse);
|
||||||
|
if (removable.length === 0) {
|
||||||
|
this.showToast(
|
||||||
|
busy.length > 0
|
||||||
|
? `All ${busy.length} agent case(s) are still in use by a running session`
|
||||||
|
: 'No agent-created cases to clean up',
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = removable.map(c => ` ${c.name}`).join('\n');
|
||||||
|
const busyNote = busy.length > 0 ? `\n\nSkipping ${busy.length} case(s) still in use by a running session.` : '';
|
||||||
|
if (!confirm(`Permanently delete ${removable.length} agent-created case folder(s) and everything in them?\n\n${names}${busyNote}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
const failed = [];
|
||||||
|
for (const item of removable) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cases/${encodeURIComponent(item.name)}`, { method: 'DELETE' });
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.success) deleted++;
|
||||||
|
else failed.push(item.name);
|
||||||
|
} catch {
|
||||||
|
failed.push(item.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showToast(
|
||||||
|
failed.length === 0
|
||||||
|
? `Deleted ${deleted} agent case(s)`
|
||||||
|
: `Deleted ${deleted}, failed: ${failed.join(', ')}`,
|
||||||
|
failed.length === 0 ? 'success' : 'error'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh the picker (its selected case may be one we just deleted) and the list.
|
||||||
|
const select = document.getElementById('quickStartCase');
|
||||||
|
const currentCase = select?.value;
|
||||||
|
const currentDeleted = removable.some(c => c.name === currentCase);
|
||||||
|
if (currentDeleted) select?.blur?.();
|
||||||
|
await this.loadQuickStartCases(currentDeleted ? null : currentCase);
|
||||||
|
if (currentDeleted) {
|
||||||
|
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
|
||||||
|
}
|
||||||
|
this.renderCaseManageList();
|
||||||
|
},
|
||||||
|
|
||||||
async saveCaseOrder(order) {
|
async saveCaseOrder(order) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/cases/order', {
|
await fetch('/api/cases/order', {
|
||||||
|
|||||||
@@ -5941,6 +5941,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent-created cases: the badge on a scratch case, and the bulk cleanup bar above
|
||||||
|
the list. Tokens only (no hardcoded ink), so the light skins repaint with the rest. */
|
||||||
|
.case-manage-tag-agent {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--control-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-agent-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
/* Sticky, and therefore OPAQUE: it is the first child of the scrolling list
|
||||||
|
(.case-manage-list is a 320px-tall flex scroller), so a translucent bar would
|
||||||
|
have case rows sliding visibly under it, and a static one would put the cleanup
|
||||||
|
button out of reach the moment a long case list is scrolled. */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-agent-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The shared .case-manage-btn is a 26px icon square; this one carries a word. */
|
||||||
|
.case-manage-btn-cleanup {
|
||||||
|
width: auto;
|
||||||
|
padding: 0 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-input {
|
.toolbar-input {
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { dataPath } from '../config/instance.js';
|
|||||||
import { getCasesDir } from '../config/cases-dir.js';
|
import { getCasesDir } from '../config/cases-dir.js';
|
||||||
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
|
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
|
||||||
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
||||||
|
import { AGENT_ORIGIN_SPAWNED_BY_SESSION, normalizeAgentOrigin } from '../agent-case-marker.js';
|
||||||
|
|
||||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||||
@@ -361,6 +362,33 @@ export function resolveParentSessionId(
|
|||||||
return parent.id;
|
return parent.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve "an agent asked for this", the signal that labels a case directory
|
||||||
|
* Codeman is about to CREATE as an agent scratch workspace (see agent-case-marker.ts).
|
||||||
|
*
|
||||||
|
* Two signals, in order:
|
||||||
|
* 1. an explicit `agentOrigin` body field, or the `X-Codeman-Agent-Origin` header the
|
||||||
|
* packaged skill sets once on its shared curl invocation, so every spawn recipe
|
||||||
|
* carries it without a per-recipe edit. The body wins, mirroring parentSessionId;
|
||||||
|
* 2. failing that, an already-RESOLVED parent session id. A create request that names
|
||||||
|
* the session that spawned it came from an agent by construction: nothing in the
|
||||||
|
* browser UI sets lineage. This is what still labels workers spawned by a stale
|
||||||
|
* skill copy or by hand-rolled curl that only carries the lineage header.
|
||||||
|
*
|
||||||
|
* ⚠️ Decoration, like parentSessionId: never an ownership or permission signal, and
|
||||||
|
* never a reason to fail a spawn. An unrecognised origin token is dropped by
|
||||||
|
* `normalizeAgentOrigin` rather than rejected.
|
||||||
|
*/
|
||||||
|
export function resolveAgentCaseOrigin(
|
||||||
|
req: FastifyRequest,
|
||||||
|
bodyValue: string | undefined,
|
||||||
|
resolvedParentSessionId: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
const header = req.headers['x-codeman-agent-origin'];
|
||||||
|
const raw = bodyValue ?? (Array.isArray(header) ? header[0] : header);
|
||||||
|
return normalizeAgentOrigin(raw) ?? (resolvedParentSessionId ? AGENT_ORIGIN_SPAWNED_BY_SESSION : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
|
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
|
||||||
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
|
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ import fs from 'node:fs/promises';
|
|||||||
import { join, resolve, basename } from 'node:path';
|
import { join, resolve, basename } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker, SessionMode } from '../../types.js';
|
import type {
|
||||||
|
AgentCaseSummary,
|
||||||
|
ApiResponse,
|
||||||
|
CaseInfo,
|
||||||
|
DockerHost,
|
||||||
|
RemoteSessionInfo,
|
||||||
|
SessionDocker,
|
||||||
|
SessionMode,
|
||||||
|
} from '../../types.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import {
|
import {
|
||||||
CreateCaseSchema,
|
CreateCaseSchema,
|
||||||
@@ -42,6 +50,7 @@ import {
|
|||||||
} from '../../git-clone.js';
|
} from '../../git-clone.js';
|
||||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
|
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
|
||||||
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
||||||
import {
|
import {
|
||||||
canAccessOwned,
|
canAccessOwned,
|
||||||
@@ -144,6 +153,21 @@ function repoShipsClaudeSettings(casePath: string): boolean {
|
|||||||
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
|
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a case's marker onto the wire shape `CaseInfo.agentCreated` carries.
|
||||||
|
* `owner` stays server-side: the listings are already owner-scoped, and it is not
|
||||||
|
* something the case list needs to publish.
|
||||||
|
*/
|
||||||
|
function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentCreated']> {
|
||||||
|
return {
|
||||||
|
createdAt: marker.createdAt,
|
||||||
|
createdBy: marker.createdBy,
|
||||||
|
...(marker.parentSessionId ? { parentSessionId: marker.parentSessionId } : {}),
|
||||||
|
...(marker.parentSessionName ? { parentSessionName: marker.parentSessionName } : {}),
|
||||||
|
...(marker.mode ? { mode: marker.mode } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||||
@@ -222,11 +246,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
const entries = await fs.readdir(listBase, { withFileTypes: true });
|
const entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||||
|
const casePath = join(listBase, e.name);
|
||||||
|
// Only a directory Codeman scaffolded for an agent spawn carries a marker,
|
||||||
|
// so this stays absent for every human-created, linked or cloned case.
|
||||||
|
const marker = await readAgentCaseMarker(casePath);
|
||||||
cases.push({
|
cases.push({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
path: join(listBase, e.name),
|
path: casePath,
|
||||||
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
|
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
||||||
location: 'local',
|
location: 'local',
|
||||||
|
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,6 +355,61 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return cases;
|
return cases;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Agent-created cases (cleanup listing) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scratch workspaces agent workers left behind, newest first.
|
||||||
|
*
|
||||||
|
* A long orchestration creates one case directory per worker, and deleting the
|
||||||
|
* sessions does not remove them, so without this the only way to tell an agent's
|
||||||
|
* `alpha`/`beta` from a real project was to remember which was which. Reads the same
|
||||||
|
* marker `GET /api/cases` exposes and adds the two facts a human needs before
|
||||||
|
* deleting a directory: whether a live session is still working in it, and when it
|
||||||
|
* was last touched.
|
||||||
|
*
|
||||||
|
* ⚠️ Read-only on purpose: removal goes through the existing `DELETE /api/cases/:name`,
|
||||||
|
* one name at a time, so this file keeps exactly one recursive-delete path. Scoped by
|
||||||
|
* construction — it only ever walks the caller's own case space.
|
||||||
|
*/
|
||||||
|
app.get('/api/cases/agent-created', async (req): Promise<ApiResponse<{ cases: AgentCaseSummary[] }>> => {
|
||||||
|
const user = getAuthUser(req);
|
||||||
|
const listBase = resolveCasesDir(user);
|
||||||
|
const inUsePaths = new Set(
|
||||||
|
Array.from(ctx.sessions.values())
|
||||||
|
.filter((session) => canAccessOwned(user, session.owner))
|
||||||
|
.map((session) => session.workingDir)
|
||||||
|
);
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return { success: true, data: { cases: [] } }; // case space not created yet
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries: AgentCaseSummary[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !SAFE_CASE_NAME.test(entry.name)) continue;
|
||||||
|
const casePath = join(listBase, entry.name);
|
||||||
|
const marker = await readAgentCaseMarker(casePath);
|
||||||
|
if (!marker) continue;
|
||||||
|
const modifiedAt = await fs
|
||||||
|
.stat(casePath)
|
||||||
|
.then((stat) => stat.mtime.toISOString())
|
||||||
|
.catch(() => undefined);
|
||||||
|
summaries.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: casePath,
|
||||||
|
...agentCreatedInfo(marker),
|
||||||
|
inUse: inUsePaths.has(casePath),
|
||||||
|
...(modifiedAt ? { modifiedAt } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
return { success: true, data: { cases: summaries } };
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +76,14 @@ import {
|
|||||||
ownerFor,
|
ownerFor,
|
||||||
parseBody,
|
parseBody,
|
||||||
persistAndBroadcastSession,
|
persistAndBroadcastSession,
|
||||||
|
resolveAgentCaseOrigin,
|
||||||
resolveCasesDir,
|
resolveCasesDir,
|
||||||
resolveParentSessionId,
|
resolveParentSessionId,
|
||||||
sessionCapacityMessage,
|
sessionCapacityMessage,
|
||||||
SETTINGS_PATH,
|
SETTINGS_PATH,
|
||||||
validatePathWithinBase,
|
validatePathWithinBase,
|
||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
|
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
|
||||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||||
@@ -2973,8 +2975,13 @@ export function registerSessionRoutes(
|
|||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
parentSessionId,
|
parentSessionId,
|
||||||
|
agentOrigin,
|
||||||
} = parseBody(QuickStartSchema, req.body);
|
} = parseBody(QuickStartSchema, req.body);
|
||||||
|
|
||||||
|
// Resolved ONCE here: the same value labels a case directory this request creates
|
||||||
|
// (agent-case-marker.ts) and draws the tab lineage line on the session below.
|
||||||
|
const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
|
||||||
|
|
||||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||||||
if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) {
|
if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||||
@@ -3220,6 +3227,26 @@ export function registerSessionRoutes(
|
|||||||
await writeHooksConfig(resolvedCasePath);
|
await writeHooksConfig(resolvedCasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Label a directory an AGENT asked us to create, so the scratch workspaces a
|
||||||
|
// long orchestration leaves behind can be told apart from the user's real
|
||||||
|
// projects later (see agent-case-marker.ts). This is the only branch that may
|
||||||
|
// write it: it is the only one that creates the directory, and a pre-existing
|
||||||
|
// case must never be labelled. Best-effort — a failed marker must not fail the
|
||||||
|
// spawn it decorates.
|
||||||
|
const qsAgentOrigin = resolveAgentCaseOrigin(req, agentOrigin, qsParentSessionId);
|
||||||
|
if (qsAgentOrigin) {
|
||||||
|
await writeAgentCaseMarker(
|
||||||
|
resolvedCasePath,
|
||||||
|
buildAgentCaseMarker({
|
||||||
|
createdBy: qsAgentOrigin,
|
||||||
|
parentSessionId: qsParentSessionId,
|
||||||
|
parentSessionName: qsParentSessionId ? ctx.sessions.get(qsParentSessionId)?.name : undefined,
|
||||||
|
mode,
|
||||||
|
owner,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
|
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||||
@@ -3353,7 +3380,7 @@ export function registerSessionRoutes(
|
|||||||
docker,
|
docker,
|
||||||
resumeSessionId: dockerResumeId,
|
resumeSessionId: dockerResumeId,
|
||||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||||
parentSessionId: resolveParentSessionId(ctx, req, parentSessionId, owner),
|
parentSessionId: qsParentSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||||
|
|||||||
@@ -1025,6 +1025,16 @@ export const QuickStartSchema = z.object({
|
|||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort: effortLevelSchema,
|
effort: effortLevelSchema,
|
||||||
|
/**
|
||||||
|
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
|
||||||
|
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
|
||||||
|
* present. Used ONLY to label a case directory this request CREATES as an agent
|
||||||
|
* scratch workspace, so it can be found and cleaned up later — see
|
||||||
|
* `src/agent-case-marker.ts`. Never a permission signal, and an unrecognised token
|
||||||
|
* is dropped rather than rejected. `POST /api/sessions` has no equivalent field
|
||||||
|
* because it takes an existing `workingDir` and so never creates a directory to label.
|
||||||
|
*/
|
||||||
|
agentOrigin: z.string().max(64).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Hook Events ==========
|
// ========== Hook Events ==========
|
||||||
|
|||||||
+20
-1
@@ -81,7 +81,7 @@ import { RunSummaryTracker } from '../run-summary.js';
|
|||||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||||
import { applyWorkspaceHooks } from '../hooks-config.js';
|
import { applyWorkspaceHooks, pruneAgentSessionPreambles, removeAgentSessionPreamble } from '../hooks-config.js';
|
||||||
import { PushSubscriptionStore } from '../push-store.js';
|
import { PushSubscriptionStore } from '../push-store.js';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { SseStreamManager } from './sse-stream-manager.js';
|
import { SseStreamManager } from './sse-stream-manager.js';
|
||||||
@@ -1370,6 +1370,12 @@ export class WebServer extends EventEmitter {
|
|||||||
// Best-effort cleanup
|
// Best-effort cleanup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Drop the agent skill's preamble cache for this session (seeded at create).
|
||||||
|
// killMux only: a detach leaves the session recoverable, and its agent would
|
||||||
|
// come back to a loader whose file we deleted.
|
||||||
|
if (killMux) {
|
||||||
|
void removeAgentSessionPreamble(sessionId);
|
||||||
|
}
|
||||||
await session.stop(killMux);
|
await session.stop(killMux);
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
// Only remove from state.json if we're also killing the mux session.
|
// Only remove from state.json if we're also killing the mux session.
|
||||||
@@ -2514,6 +2520,19 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sweep agent preamble caches whose sessions are gone (see
|
||||||
|
// pruneAgentSessionPreambles). Once per boot, after restore, so every session this
|
||||||
|
// instance owns is in the keep set. Best-effort and off the startup critical path.
|
||||||
|
if (!this.testMode) {
|
||||||
|
void pruneAgentSessionPreambles(this.sessions.keys())
|
||||||
|
.then((removed) => {
|
||||||
|
if (removed > 0) console.log(`[agent-skill] pruned ${removed} stale preamble cache file(s)`);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* best-effort */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
|
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
|
||||||
// older than 7 days from each live session's .claude-images/ hourly.
|
// older than 7 days from each live session's .claude-images/ hourly.
|
||||||
if (!this.testMode) {
|
if (!this.testMode) {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The agent-case marker: the label that tells a scratch worker workspace
|
||||||
|
* apart from the user's real projects.
|
||||||
|
*
|
||||||
|
* The rules under test are the ones that keep a cleanup affordance safe: reading is
|
||||||
|
* total (anything that is not a well-formed version-1 marker reads as "not
|
||||||
|
* agent-created", never as a half-trusted entry), the origin token is allowlisted
|
||||||
|
* rather than escaped at each use, and writing never throws — a failed marker must not
|
||||||
|
* fail the worker spawn it decorates.
|
||||||
|
*
|
||||||
|
* Port: N/A (pure + a temp dir).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
AGENT_CASE_MARKER_FILE,
|
||||||
|
AGENT_ORIGIN_CODEMAN_SKILL,
|
||||||
|
AGENT_ORIGIN_SPAWNED_BY_SESSION,
|
||||||
|
buildAgentCaseMarker,
|
||||||
|
normalizeAgentOrigin,
|
||||||
|
parseAgentCaseMarker,
|
||||||
|
readAgentCaseMarker,
|
||||||
|
writeAgentCaseMarker,
|
||||||
|
} from '../src/agent-case-marker.js';
|
||||||
|
|
||||||
|
describe('normalizeAgentOrigin', () => {
|
||||||
|
it('accepts a short lowercase token', () => {
|
||||||
|
expect(normalizeAgentOrigin('codeman-skill')).toBe('codeman-skill');
|
||||||
|
expect(normalizeAgentOrigin(' Codeman-Skill ')).toBe('codeman-skill');
|
||||||
|
expect(normalizeAgentOrigin('agent.v2_1')).toBe('agent.v2_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops anything that is not one', () => {
|
||||||
|
// The value reaches a JSON file and the case-manage UI, so it is allowlisted at
|
||||||
|
// the boundary instead of escaped at every use site.
|
||||||
|
expect(normalizeAgentOrigin('<script>')).toBeUndefined();
|
||||||
|
expect(normalizeAgentOrigin('has space')).toBeUndefined();
|
||||||
|
expect(normalizeAgentOrigin('-leading-dash')).toBeUndefined();
|
||||||
|
expect(normalizeAgentOrigin('x'.repeat(33))).toBeUndefined();
|
||||||
|
expect(normalizeAgentOrigin('')).toBeUndefined();
|
||||||
|
expect(normalizeAgentOrigin(undefined)).toBeUndefined();
|
||||||
|
expect(normalizeAgentOrigin(42)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAgentCaseMarker', () => {
|
||||||
|
it('keeps only the fields that were supplied', () => {
|
||||||
|
const marker = buildAgentCaseMarker({ createdBy: AGENT_ORIGIN_CODEMAN_SKILL });
|
||||||
|
expect(marker.version).toBe(1);
|
||||||
|
expect(marker.createdBy).toBe(AGENT_ORIGIN_CODEMAN_SKILL);
|
||||||
|
expect(Date.parse(marker.createdAt)).not.toBeNaN();
|
||||||
|
expect('parentSessionId' in marker).toBe(false);
|
||||||
|
expect('mode' in marker).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the spawned-by-session origin rather than storing junk', () => {
|
||||||
|
expect(buildAgentCaseMarker({ createdBy: 'not a token' }).createdBy).toBe(AGENT_ORIGIN_SPAWNED_BY_SESSION);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAgentCaseMarker', () => {
|
||||||
|
const valid = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-09-07T10:00:00.000Z',
|
||||||
|
createdBy: 'codeman-skill',
|
||||||
|
parentSessionId: 'sess-1',
|
||||||
|
parentSessionName: 'w1-claudeman',
|
||||||
|
mode: 'claude',
|
||||||
|
note: 'ignored',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a well-formed marker and drops unknown fields', () => {
|
||||||
|
const marker = parseAgentCaseMarker(valid);
|
||||||
|
expect(marker).toEqual({
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-09-07T10:00:00.000Z',
|
||||||
|
createdBy: 'codeman-skill',
|
||||||
|
parentSessionId: 'sess-1',
|
||||||
|
parentSessionName: 'w1-claudeman',
|
||||||
|
mode: 'claude',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads anything malformed as absent', () => {
|
||||||
|
// Each of these must mean "not an agent case", because the answer drives a
|
||||||
|
// recursive-delete affordance in the UI.
|
||||||
|
expect(parseAgentCaseMarker('not json')).toBeNull();
|
||||||
|
expect(parseAgentCaseMarker('[]')).toBeNull();
|
||||||
|
expect(parseAgentCaseMarker('null')).toBeNull();
|
||||||
|
expect(parseAgentCaseMarker(JSON.stringify({ version: 2, createdAt: '2026-09-07', createdBy: 'x' }))).toBeNull();
|
||||||
|
expect(parseAgentCaseMarker(JSON.stringify({ version: 1, createdBy: 'x' }))).toBeNull();
|
||||||
|
expect(parseAgentCaseMarker(JSON.stringify({ version: 1, createdAt: 'whenever', createdBy: 'x' }))).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseAgentCaseMarker(JSON.stringify({ version: 1, createdAt: '2026-09-07T10:00:00Z', createdBy: 'bad token' }))
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeAgentCaseMarker / readAgentCaseMarker', () => {
|
||||||
|
let caseDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
caseDir = await mkdtemp(join(tmpdir(), 'codeman-agent-case-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(caseDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through the case directory', async () => {
|
||||||
|
const marker = buildAgentCaseMarker({
|
||||||
|
createdBy: AGENT_ORIGIN_CODEMAN_SKILL,
|
||||||
|
parentSessionId: 'sess-1',
|
||||||
|
mode: 'claude',
|
||||||
|
});
|
||||||
|
expect(await writeAgentCaseMarker(caseDir, marker)).toBe(true);
|
||||||
|
expect(await readAgentCaseMarker(caseDir)).toEqual(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a note explaining the file to whoever finds it', async () => {
|
||||||
|
await writeAgentCaseMarker(caseDir, buildAgentCaseMarker({ createdBy: AGENT_ORIGIN_CODEMAN_SKILL }));
|
||||||
|
const raw = JSON.parse(await readFile(join(caseDir, AGENT_CASE_MARKER_FILE), 'utf-8'));
|
||||||
|
expect(raw.note).toContain('Delete this file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports failure instead of throwing when the directory is missing', async () => {
|
||||||
|
// Best-effort by design: a marker that cannot be written must not fail the spawn.
|
||||||
|
const written = await writeAgentCaseMarker(join(caseDir, 'nope'), buildAgentCaseMarker({ createdBy: 'x-agent' }));
|
||||||
|
expect(written).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads an absent or corrupt marker as not-agent-created', async () => {
|
||||||
|
expect(await readAgentCaseMarker(caseDir)).toBeNull();
|
||||||
|
await writeFile(join(caseDir, AGENT_CASE_MARKER_FILE), '{ truncated', 'utf-8');
|
||||||
|
expect(await readAgentCaseMarker(caseDir)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat } from 'node:fs/promises';
|
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir, homedir } from 'node:os';
|
import { tmpdir, homedir } from 'node:os';
|
||||||
@@ -21,10 +21,25 @@ import {
|
|||||||
removeAgentSkillFrom,
|
removeAgentSkillFrom,
|
||||||
refreshUserAgentSkill,
|
refreshUserAgentSkill,
|
||||||
seedAgentSessionPreamble,
|
seedAgentSessionPreamble,
|
||||||
|
removeAgentSessionPreamble,
|
||||||
|
pruneAgentSessionPreambles,
|
||||||
} from '../src/hooks-config.js';
|
} from '../src/hooks-config.js';
|
||||||
|
|
||||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||||
|
|
||||||
|
/** Run `fn` against a throwaway XDG cache dir, restoring the env afterwards. */
|
||||||
|
async function withCacheDir(fn: (cacheDir: string) => Promise<void>): Promise<void> {
|
||||||
|
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||||
|
const cacheDir = join(casePath, 'xdg-cache');
|
||||||
|
process.env.XDG_CACHE_HOME = cacheDir;
|
||||||
|
try {
|
||||||
|
await fn(cacheDir);
|
||||||
|
} finally {
|
||||||
|
if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME;
|
||||||
|
else process.env.XDG_CACHE_HOME = prevXdg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let casePath: string;
|
let casePath: string;
|
||||||
const skillDir = () => join(casePath, '.claude', 'skills', 'codeman');
|
const skillDir = () => join(casePath, '.claude', 'skills', 'codeman');
|
||||||
|
|
||||||
@@ -165,6 +180,57 @@ describe('preamble single-source (seed + §0 heredoc parity)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removeAgentSessionPreamble drops one session cache and shrugs at a missing one', async () => {
|
||||||
|
// Seeding writes one file per claude session and nothing used to remove them
|
||||||
|
// (236 leftovers measured on a working machine); session teardown calls this.
|
||||||
|
await withCacheDir(async (cacheDir) => {
|
||||||
|
await seedAgentSessionPreamble('gone-session');
|
||||||
|
expect(existsSync(join(cacheDir, 'codeman-agent-gone-session.sh'))).toBe(true);
|
||||||
|
|
||||||
|
await removeAgentSessionPreamble('gone-session');
|
||||||
|
expect(existsSync(join(cacheDir, 'codeman-agent-gone-session.sh'))).toBe(false);
|
||||||
|
|
||||||
|
await expect(removeAgentSessionPreamble('never-existed')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pruneAgentSessionPreambles takes only aged caches with no live session behind them', async () => {
|
||||||
|
await withCacheDir(async (cacheDir) => {
|
||||||
|
const aged = (name: string) => join(cacheDir, name);
|
||||||
|
for (const id of ['live-old', 'dead-old', 'dead-fresh']) {
|
||||||
|
await seedAgentSessionPreamble(id);
|
||||||
|
}
|
||||||
|
// Age two of them past the cutoff; `dead-fresh` stays new.
|
||||||
|
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
await utimes(aged('codeman-agent-live-old.sh'), old, old);
|
||||||
|
await utimes(aged('codeman-agent-dead-old.sh'), old, old);
|
||||||
|
// An unrelated file in the same cache dir must be invisible to the sweep.
|
||||||
|
await writeFile(aged('someone-elses-file.sh'), 'not ours\n');
|
||||||
|
|
||||||
|
const removed = await pruneAgentSessionPreambles(['live-old']);
|
||||||
|
|
||||||
|
expect(removed).toBe(1);
|
||||||
|
expect(existsSync(aged('codeman-agent-dead-old.sh'))).toBe(false);
|
||||||
|
// A live session's cache is load-bearing: the skill's two-line loader reads it
|
||||||
|
// mid-run, so age alone must never take it.
|
||||||
|
expect(existsSync(aged('codeman-agent-live-old.sh'))).toBe(true);
|
||||||
|
// And a recently-seeded one belongs to a session this process may not know about.
|
||||||
|
expect(existsSync(aged('codeman-agent-dead-fresh.sh'))).toBe(true);
|
||||||
|
expect(existsSync(aged('someone-elses-file.sh'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pruneAgentSessionPreambles reports 0 rather than throwing when there is no cache dir', async () => {
|
||||||
|
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||||
|
process.env.XDG_CACHE_HOME = join(casePath, 'no-such-cache');
|
||||||
|
try {
|
||||||
|
expect(await pruneAgentSessionPreambles([])).toBe(0);
|
||||||
|
} finally {
|
||||||
|
if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME;
|
||||||
|
else process.env.XDG_CACHE_HOME = prevXdg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('seedAgentSessionPreamble falls back to ~/.cache when XDG_CACHE_HOME is unset', async () => {
|
it('seedAgentSessionPreamble falls back to ~/.cache when XDG_CACHE_HOME is unset', async () => {
|
||||||
const prevXdg = process.env.XDG_CACHE_HOME;
|
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||||
delete process.env.XDG_CACHE_HOME;
|
delete process.env.XDG_CACHE_HOME;
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview End-to-end wiring of the agent-case label: quick-start writes the
|
||||||
|
* marker, the case list publishes it, and `GET /api/cases/agent-created` reports it
|
||||||
|
* for cleanup.
|
||||||
|
*
|
||||||
|
* The rules under test are the ones that decide whether the cleanup list can be
|
||||||
|
* trusted: only a directory quick-start CREATES is ever labelled (a pre-existing
|
||||||
|
* case — a linked repo, a real project — never is), a spawn with no agent signal at
|
||||||
|
* all leaves no marker, the lineage header alone is enough to label one (that is how
|
||||||
|
* a stale skill copy still gets swept up), and a case a live session is working in is
|
||||||
|
* reported as `inUse` rather than silently offered up for deletion.
|
||||||
|
*
|
||||||
|
* Real filesystem against the per-file temp HOME from test/setup.ts, so the marker is
|
||||||
|
* asserted as bytes on disk rather than through a mock.
|
||||||
|
*
|
||||||
|
* Port: N/A (app.inject()).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { mkdir, rm, readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||||
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||||
|
import { getCasesDir } from '../../src/config/cases-dir.js';
|
||||||
|
import { AGENT_CASE_MARKER_FILE } from '../../src/agent-case-marker.js';
|
||||||
|
import type { AgentCaseSummary, CaseInfo } from '../../src/types.js';
|
||||||
|
|
||||||
|
const PARENT_ID = 'test-session-1'; // the id the mock context pre-populates
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
app: FastifyInstance;
|
||||||
|
ctx: MockRouteContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createHarness(): Promise<Harness> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
const ctx = createMockRouteContext();
|
||||||
|
registerSessionRoutes(app, ctx);
|
||||||
|
registerCaseRoutes(app, ctx);
|
||||||
|
installRouteErrorHandler(app);
|
||||||
|
await app.ready();
|
||||||
|
return { app, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('agent-created case marker', () => {
|
||||||
|
let harness: Harness;
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
/** Spawn a worker through quick-start, the skill's usual route. */
|
||||||
|
async function quickStart(caseName: string, opts: { headers?: Record<string, string>; payload?: object } = {}) {
|
||||||
|
created.push(caseName);
|
||||||
|
return harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/quick-start',
|
||||||
|
headers: opts.headers,
|
||||||
|
payload: { caseName, mode: 'claude', ...(opts.payload ?? {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerPath = (caseName: string) => join(getCasesDir(), caseName, AGENT_CASE_MARKER_FILE);
|
||||||
|
|
||||||
|
async function readMarker(caseName: string): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(markerPath(caseName), 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCases(): Promise<CaseInfo[]> {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
return (body.data ?? body) as CaseInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAgentCases(): Promise<AgentCaseSummary[]> {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/agent-created' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return JSON.parse(res.body).data.cases as AgentCaseSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
for (const name of created.splice(0)) {
|
||||||
|
await rm(join(getCasesDir(), name), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels a case directory created for a spawn carrying the skill origin header', async () => {
|
||||||
|
const res = await quickStart('agentcase1', {
|
||||||
|
headers: { 'x-codeman-agent-origin': 'codeman-skill', 'x-codeman-parent-session': PARENT_ID },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const marker = await readMarker('agentcase1');
|
||||||
|
expect(marker).toMatchObject({
|
||||||
|
version: 1,
|
||||||
|
createdBy: 'codeman-skill',
|
||||||
|
parentSessionId: PARENT_ID,
|
||||||
|
mode: 'claude',
|
||||||
|
});
|
||||||
|
expect(Date.parse(String(marker?.createdAt))).not.toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the origin as a body field too, with the body winning', async () => {
|
||||||
|
await quickStart('agentcase2', {
|
||||||
|
headers: { 'x-codeman-agent-origin': 'codeman-skill' },
|
||||||
|
payload: { agentOrigin: 'my-orchestrator' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await readMarker('agentcase2')).toMatchObject({ createdBy: 'my-orchestrator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels a spawn that carries only the lineage header, which is how an older skill copy still gets swept up', async () => {
|
||||||
|
await quickStart('agentcase3', { headers: { 'x-codeman-parent-session': PARENT_ID } });
|
||||||
|
|
||||||
|
expect(await readMarker('agentcase3')).toMatchObject({
|
||||||
|
createdBy: 'agent-session',
|
||||||
|
parentSessionId: PARENT_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes NO marker for a spawn with no agent signal at all', async () => {
|
||||||
|
// A human clicking Run in the browser sets neither header, and their case must
|
||||||
|
// not turn up in a cleanup list.
|
||||||
|
await quickStart('humancase1');
|
||||||
|
|
||||||
|
expect(await readMarker('humancase1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never labels a directory that already existed', async () => {
|
||||||
|
// The linchpin: a linked case or a real repo is not ours to offer for deletion,
|
||||||
|
// and the create branch is the only place the marker may be written.
|
||||||
|
const name = 'preexisting1';
|
||||||
|
created.push(name);
|
||||||
|
await mkdir(join(getCasesDir(), name), { recursive: true });
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/quick-start',
|
||||||
|
headers: { 'x-codeman-agent-origin': 'codeman-skill' },
|
||||||
|
payload: { caseName: name, mode: 'claude' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(await readMarker(name)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an unrecognised origin token rather than storing it', async () => {
|
||||||
|
await quickStart('agentcase4', { payload: { agentOrigin: '<script>alert(1)</script>' } });
|
||||||
|
|
||||||
|
// No parent either, so nothing labels this one at all.
|
||||||
|
expect(await readMarker('agentcase4')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still spawns the worker when the origin is bogus', async () => {
|
||||||
|
const res = await quickStart('agentcase5', { payload: { agentOrigin: 'not a token' } });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).sessionId ?? JSON.parse(res.body).data?.sessionId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes the label on GET /api/cases and hides it from cases without one', async () => {
|
||||||
|
await quickStart('agentcase6', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
|
||||||
|
await quickStart('humancase2');
|
||||||
|
|
||||||
|
const cases = await listCases();
|
||||||
|
expect(cases.find((c) => c.name === 'agentcase6')?.agentCreated).toMatchObject({ createdBy: 'codeman-skill' });
|
||||||
|
expect(cases.find((c) => c.name === 'humancase2')?.agentCreated).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists only agent cases in the cleanup listing, newest first', async () => {
|
||||||
|
await quickStart('agentcase7', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
|
||||||
|
await quickStart('humancase3');
|
||||||
|
|
||||||
|
const listed = await listAgentCases();
|
||||||
|
expect(listed.map((c) => c.name)).toContain('agentcase7');
|
||||||
|
expect(listed.map((c) => c.name)).not.toContain('humancase3');
|
||||||
|
const sorted = [...listed].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
expect(listed.map((c) => c.name)).toEqual(sorted.map((c) => c.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a case a live session is still working in as inUse', async () => {
|
||||||
|
// Deleting one of these would pull the rug out from under a running worker, so
|
||||||
|
// the UI excludes it rather than confirming it away.
|
||||||
|
await quickStart('agentcase8', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
|
||||||
|
const busyPath = join(getCasesDir(), 'agentcase8');
|
||||||
|
const busy = createMockSession('busy-session') as unknown as { workingDir: string };
|
||||||
|
busy.workingDir = busyPath;
|
||||||
|
harness.ctx.sessions.set('busy-session', busy as never);
|
||||||
|
|
||||||
|
const entry = (await listAgentCases()).find((c) => c.name === 'agentcase8');
|
||||||
|
expect(entry?.inUse).toBe(true);
|
||||||
|
expect(entry?.path).toBe(busyPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a marker-less case space as an empty list rather than failing', async () => {
|
||||||
|
expect(await listAgentCases()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user