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:
Codeman maintainer
2026-09-07 19:09:24 +02:00
parent 61d22eee1c
commit 8ee7926e27
19 changed files with 1072 additions and 26 deletions
+28
View File
@@ -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(...)`.