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
+87 -3
View File
@@ -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<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. */
async function readLinkedCases(): Promise<Record<string, string>> {
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 });
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<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 } }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
+28 -1
View File
@@ -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