diff --git a/CLAUDE.md b/CLAUDE.md index 6ba242f7..85845712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ]` / `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-.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-.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`. ⚠️ **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 -~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` 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`). diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 231cbdb8..9a4ae5f3 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${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 @@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # 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. -grep -qs '^CODEMAN_PREAMBLE=1.21.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) ---- +grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- 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}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # 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; # 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. -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 # 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 # 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. -CODEMAN_PREAMBLE=1.21.0 +CODEMAN_PREAMBLE=1.22.0 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 @@ -376,7 +379,7 @@ and no per-call body to hand-build. ```bash . "${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 # (a name may carry a mode: `beta:deepseek`, see below) 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 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. -- 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 @@ -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) | | 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) | -| 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 @@ -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.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.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 diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 3bd024e5..cadc4a78 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -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}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # 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; # 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. -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 # 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 # 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. -CODEMAN_PREAMBLE=1.21.0 +CODEMAN_PREAMBLE=1.22.0 diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index ac37a9fe..fa56ce95 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -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 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 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 diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index c444163c..94e0264f 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${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 diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 947253e1..1f0dceea 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -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 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` (that one folds in transcript history from the whole machine and will keep showing your worker forever). diff --git a/src/agent-case-marker.ts b/src/agent-case-marker.ts new file mode 100644 index 00000000..045f303b --- /dev/null +++ b/src/agent-case-marker.ts @@ -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; + 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 { + 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 { + try { + return parseAgentCaseMarker(await readFile(join(casePath, AGENT_CASE_MARKER_FILE), 'utf-8')); + } catch { + return null; + } +} diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 687ee36d..d8a8a38b 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1066,9 +1066,80 @@ export async function installAgentSkillInto(skillDir: string): Promise { const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8'); - const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), '.cache'); - await mkdir(cacheDir, { recursive: true }); - await writeFile(join(cacheDir, `codeman-agent-${sessionId}.sh`), content, { mode: 0o600 }); + await mkdir(agentPreambleCacheDir(), { recursive: true }); + await writeFile(agentPreamblePath(sessionId), 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-.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 { + 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, + maxAgeMs: number = AGENT_PREAMBLE_MAX_AGE_MS +): Promise { + 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; } /** diff --git a/src/types/api.ts b/src/types/api.ts index 178bf9c3..7adf4272 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -157,6 +157,20 @@ export interface CaseInfo { location?: 'local' | 'linked-local' | 'remote' | 'docker'; /** Whether this is a linked local folder */ 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?: { 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 ========== /** diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index c970b633..8e6f192c 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3595,17 +3595,33 @@ Object.assign(CodemanApp.prototype, { 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 + ? `
+ ${agentCases.length} case${agentCases.length === 1 ? '' : 's'} created by agent workers + +
` + : ''; cases.forEach((c, idx) => { const isFirst = idx === 0; const isLast = idx === cases.length - 1; // Was `/Users/` only, the mirror image of the Run menu's bug: every // case path on a Linux host rendered in full, unabbreviated. 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 += `
- ${escapeHtml(c.name)} + ${escapeHtml(c.name)}${ + c.agentCreated ? `agent` : '' + } ${escapeHtml(pathDisplay)}
@@ -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) { try { await fetch('/api/cases/order', { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 41427fdb..88f3e856 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -5941,6 +5941,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { 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 { padding: 0.4rem 0.5rem; background: var(--bg-input); diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 50c36d94..3e329b26 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -23,6 +23,7 @@ import { dataPath } from '../config/instance.js'; import { getCasesDir } from '../config/cases-dir.js'; import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.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) // stays shared across instances; SETTINGS_PATH is per-instance runtime state. @@ -361,6 +362,33 @@ export function resolveParentSessionId( 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. * Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`. diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index ca25fbb7..d202c8bc 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -13,7 +13,15 @@ import fs from 'node:fs/promises'; import { join, resolve, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; 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 { CreateCaseSchema, @@ -42,6 +50,7 @@ import { } from '../../git-clone.js'; import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.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 { canAccessOwned, @@ -144,6 +153,21 @@ function repoShipsClaudeSettings(casePath: string): boolean { 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 { + 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. */ async function readLinkedCases(): Promise> { return readJsonConfig>(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 }); for (const e of entries) { 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({ name: e.name, - path: join(listBase, e.name), - hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')), + path: casePath, + hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')), location: 'local', + ...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}), }); } } @@ -326,6 +355,61 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config 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> => { + 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> => { const { name, description } = parseBody(CreateCaseSchema, req.body); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index d6ba3d30..8b30ba5b 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -76,12 +76,14 @@ import { ownerFor, parseBody, persistAndBroadcastSession, + resolveAgentCaseOrigin, resolveCasesDir, resolveParentSessionId, sessionCapacityMessage, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js'; +import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; @@ -2973,8 +2975,13 @@ export function registerSessionRoutes( envOverrides, effort, parentSessionId, + agentOrigin, } = 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. // 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))) { @@ -3220,6 +3227,26 @@ export function registerSessionRoutes( 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 }); } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); @@ -3353,7 +3380,7 @@ export function registerSessionRoutes( docker, resumeSessionId: dockerResumeId, tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit, - parentSessionId: resolveParentSessionId(ctx, req, parentSessionId, owner), + parentSessionId: qsParentSessionId, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting diff --git a/src/web/schemas.ts b/src/web/schemas.ts index cb4b1c51..3ae465c2 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1025,6 +1025,16 @@ export const QuickStartSchema = z.object({ envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ 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 ========== diff --git a/src/web/server.ts b/src/web/server.ts index 305bd264..b554ada5 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -81,7 +81,7 @@ import { RunSummaryTracker } from '../run-summary.js'; import { PlanOrchestrator } from '../plan-orchestrator.js'; import { OrchestratorLoop } from '../orchestrator-loop.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 webpush from 'web-push'; import { SseStreamManager } from './sse-stream-manager.js'; @@ -1370,6 +1370,12 @@ export class WebServer extends EventEmitter { // 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); this.sessions.delete(sessionId); // 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 // older than 7 days from each live session's .claude-images/ hourly. if (!this.testMode) { diff --git a/test/agent-case-marker.test.ts b/test/agent-case-marker.test.ts new file mode 100644 index 00000000..a01c8310 --- /dev/null +++ b/test/agent-case-marker.test.ts @@ -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('