@agentplate/cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to Agentplate are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and the project aims to adhere to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.4.0] — 2026-06-02
8
+
9
+ ### Added
10
+
11
+ - **Full purge of reaped agents** (`agentplate reap --purge`, `agentplate stop
12
+ <agent> --purge`) — beyond stopping an agent and removing its worktree, purge
13
+ erases every trace it left behind: mail, events, queued merges, the on-disk
14
+ `.agentplate/agents/<name>/` state dir, the task spec (only once no sibling
15
+ session still references it), and the session row itself. A `PurgeReport` records
16
+ what was removed.
17
+ - **`agents.purgeOnReap`** (default `false`) — when set, the `agentplate serve`
18
+ idle-reaper loop purges idle agents automatically (not just marks them stopped).
19
+ Surfaced as a gated toggle in `agentplate setup`.
20
+ - **Store deletion APIs** backing the purge: `SessionStore.deleteSession`,
21
+ `EventStore.deleteByAgent`, and `MergeQueue.deleteByAgent`.
22
+
23
+ ### Changed
24
+
25
+ - Plain `reap` / `serve` behavior is unchanged — reaping still keeps records for
26
+ history by default. Full erasure is strictly opt-in via `--purge` / `purgeOnReap`.
27
+
7
28
  ## [1.3.0] — 2026-06-02
8
29
 
9
30
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentplate/cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -6,6 +6,11 @@
6
6
  * live process, and removes its worktree + branch. The coordinator is never
7
7
  * reaped. Run it manually or on a cron; `agentplate serve` also reaps on its own
8
8
  * loop. Use `--dry-run` to preview and `--keep-worktrees` to leave worktrees.
9
+ *
10
+ * `--purge` goes further than a normal reap: it erases every trace of each reaped
11
+ * agent — mail, events, queued merges, the on-disk `.agentplate/agents/<name>/`
12
+ * state dir, and the session row itself — so the workspace is fully cleared. The
13
+ * plain reap keeps those records for history; purge is the opt-in clean slate.
9
14
  */
10
15
 
11
16
  import { Command } from "commander";
@@ -22,11 +27,21 @@ export function createReapCommand(): Command {
22
27
  .description("Terminate agents idle past the timeout (stop + kill + remove worktree)")
23
28
  .option("--minutes <n>", "idle timeout in minutes (default: config.agents.idleTimeoutMinutes)")
24
29
  .option("--keep-worktrees", "mark stopped + kill process but keep worktrees/branches")
30
+ .option(
31
+ "--purge",
32
+ "fully erase each reaped agent (mail, events, merges, state dir, session row)",
33
+ )
25
34
  .option("--dry-run", "list which agents would be reaped without changing anything")
26
35
  .option("--json", "output JSON")
27
36
  .action(
28
37
  async (
29
- opts: { minutes?: string; keepWorktrees?: boolean; dryRun?: boolean; json?: boolean },
38
+ opts: {
39
+ minutes?: string;
40
+ keepWorktrees?: boolean;
41
+ purge?: boolean;
42
+ dryRun?: boolean;
43
+ json?: boolean;
44
+ },
30
45
  command: Command,
31
46
  ) => {
32
47
  const useJson = command.optsWithGlobals().json === true;
@@ -51,11 +66,12 @@ export function createReapCommand(): Command {
51
66
  now: Date.now(),
52
67
  }).map((s) => ({ agent: s.agentName, capability: s.capability, state: s.state }));
53
68
  if (useJson) {
54
- jsonOutput({ dryRun: true, minutes, candidates });
69
+ jsonOutput({ dryRun: true, minutes, purge: opts.purge === true, candidates });
55
70
  } else if (candidates.length === 0) {
56
71
  printInfo(`No agents idle longer than ${minutes}m.`);
57
72
  } else {
58
- printInfo(`Would reap ${candidates.length} agent(s) idle >${minutes}m:`);
73
+ const how = opts.purge ? "reap + purge" : "reap";
74
+ printInfo(`Would ${how} ${candidates.length} agent(s) idle >${minutes}m:`);
59
75
  for (const c of candidates) {
60
76
  process.stdout.write(` ${c.agent} ${muted(`(${c.capability}, ${c.state})`)}\n`);
61
77
  }
@@ -66,17 +82,24 @@ export function createReapCommand(): Command {
66
82
  const reaped = await reapIdleSessions(store, root, {
67
83
  idleMs,
68
84
  removeWorktrees: opts.keepWorktrees !== true,
85
+ purge: opts.purge === true,
69
86
  });
70
87
 
71
88
  if (useJson) {
72
- jsonOutput({ minutes, reapedCount: reaped.length, reaped });
89
+ jsonOutput({ minutes, purge: opts.purge === true, reapedCount: reaped.length, reaped });
73
90
  } else if (reaped.length === 0) {
74
91
  printInfo(`No agents idle longer than ${minutes}m.`);
75
92
  } else {
76
- printSuccess(`Reaped ${reaped.length} idle agent(s) (>${minutes}m):`);
93
+ const verb = opts.purge ? "Reaped + purged" : "Reaped";
94
+ printSuccess(`${verb} ${reaped.length} idle agent(s) (>${minutes}m):`);
77
95
  for (const r of reaped) {
78
96
  const wt = r.worktreeRemoved ? "worktree removed" : "worktree kept";
79
- process.stdout.write(` ${r.agentName} ${muted(`(${r.capability}, ${wt})`)}\n`);
97
+ const extra = r.purged
98
+ ? `, purged ${r.purged.mailDeleted} mail/${r.purged.eventsDeleted} events/${r.purged.mergeDeleted} merges`
99
+ : "";
100
+ process.stdout.write(
101
+ ` ${r.agentName} ${muted(`(${r.capability}, ${wt}${extra})`)}\n`,
102
+ );
80
103
  }
81
104
  }
82
105
  } finally {
@@ -7,22 +7,30 @@
7
7
  import { Command } from "commander";
8
8
  import { findProjectRoot, isInitialized } from "../config.ts";
9
9
  import { NotFoundError, ValidationError } from "../errors.ts";
10
+ import { createEventStore } from "../events/store.ts";
10
11
  import { jsonOutput } from "../json.ts";
11
12
  import { printSuccess, printWarning } from "../logging/color.ts";
12
- import { sessionsDbPath } from "../paths.ts";
13
+ import { createMailStore } from "../mail/store.ts";
14
+ import { createMergeQueue } from "../merge/queue.ts";
15
+ import { eventsDbPath, mailDbPath, mergeDbPath, sessionsDbPath } from "../paths.ts";
16
+ import { type PurgeReport, purgeAgentData } from "../sessions/purge.ts";
13
17
  import { createSessionStore } from "../sessions/store.ts";
14
- import { removeWorktree } from "../worktree/manager.ts";
18
+ import { deleteBranch, removeWorktree } from "../worktree/manager.ts";
15
19
 
16
20
  export function createStopCommand(): Command {
17
21
  return new Command("stop")
18
22
  .description("Terminate an agent session")
19
23
  .argument("<agent>", "agent name")
20
24
  .option("--clean-worktree", "also remove the agent's worktree")
25
+ .option(
26
+ "--purge",
27
+ "fully erase the agent (worktree + mail, events, merges, state dir, session row)",
28
+ )
21
29
  .option("--json", "output JSON")
22
30
  .action(
23
31
  async (
24
32
  agent: string,
25
- opts: { cleanWorktree?: boolean; json?: boolean },
33
+ opts: { cleanWorktree?: boolean; purge?: boolean; json?: boolean },
26
34
  command: Command,
27
35
  ) => {
28
36
  const useJson = command.optsWithGlobals().json === true;
@@ -30,6 +38,8 @@ export function createStopCommand(): Command {
30
38
  if (!isInitialized(root)) {
31
39
  throw new ValidationError("Not initialized. Run `agentplate setup` first.");
32
40
  }
41
+ // --purge implies removing the worktree: a full wipe leaves no worktree.
42
+ const cleanWorktree = opts.cleanWorktree || opts.purge === true;
33
43
  const store = createSessionStore(sessionsDbPath(root));
34
44
  try {
35
45
  const session = store.getSessionByAgent(agent);
@@ -37,17 +47,44 @@ export function createStopCommand(): Command {
37
47
  store.updateSessionState(session.id, "stopped");
38
48
 
39
49
  let worktreeRemoved = false;
40
- if (opts.cleanWorktree) {
50
+ if (cleanWorktree) {
41
51
  try {
42
52
  await removeWorktree(root, session.worktreePath, { force: true });
43
53
  worktreeRemoved = true;
54
+ // Best-effort branch cleanup (may be merged/shared — leave it then).
55
+ try {
56
+ await deleteBranch(root, session.branchName);
57
+ } catch {
58
+ // Branch kept; not fatal.
59
+ }
44
60
  } catch (error) {
45
61
  printWarning(`Could not remove worktree: ${(error as Error).message}`);
46
62
  }
47
63
  }
48
64
 
49
- if (useJson) jsonOutput({ agent, stopped: true, worktreeRemoved });
50
- else printSuccess(`Stopped ${agent}${worktreeRemoved ? " (worktree removed)" : ""}`);
65
+ let purged: PurgeReport | null = null;
66
+ if (opts.purge) {
67
+ const events = createEventStore(eventsDbPath(root));
68
+ const merge = createMergeQueue(mergeDbPath(root));
69
+ const mail = createMailStore(mailDbPath(root));
70
+ try {
71
+ purged = purgeAgentData(root, session, { sessions: store, events, merge, mail });
72
+ } finally {
73
+ events.close();
74
+ merge.close();
75
+ mail.close();
76
+ }
77
+ }
78
+
79
+ if (useJson) {
80
+ jsonOutput({ agent, stopped: true, worktreeRemoved, purged });
81
+ } else if (purged) {
82
+ printSuccess(
83
+ `Purged ${agent} (worktree removed, ${purged.mailDeleted} mail/${purged.eventsDeleted} events/${purged.mergeDeleted} merges erased)`,
84
+ );
85
+ } else {
86
+ printSuccess(`Stopped ${agent}${worktreeRemoved ? " (worktree removed)" : ""}`);
87
+ }
51
88
  } finally {
52
89
  store.close();
53
90
  }
package/src/config.ts CHANGED
@@ -48,6 +48,7 @@ export const DEFAULT_CONFIG: AgentplateConfig = {
48
48
  skipReview: false,
49
49
  skipGates: false,
50
50
  skipSkills: false,
51
+ purgeOnReap: false,
51
52
  },
52
53
  merge: {
53
54
  aiResolveEnabled: true,
@@ -180,4 +180,26 @@ describe("createEventStore", () => {
180
180
  // Newest two builder events.
181
181
  expect(limited.map((e) => e.type)).toEqual(["c", "b"]);
182
182
  });
183
+
184
+ test("deleteByAgent removes only that agent's rows and returns the count", () => {
185
+ store.record({ agentName: "builder", type: "a" });
186
+ store.record({ agentName: "builder", type: "b" });
187
+ store.record({ agentName: "scout", type: "a" });
188
+
189
+ expect(store.deleteByAgent("builder")).toBe(2);
190
+ expect(store.list({ agentName: "builder" })).toEqual([]);
191
+ expect(store.list({ agentName: "scout" }).length).toBe(1);
192
+ });
193
+
194
+ test("deleteByAgent scoped to a run leaves the agent's other runs intact", () => {
195
+ store.record({ agentName: "builder", runId: "r1", type: "a" });
196
+ store.record({ agentName: "builder", runId: "r2", type: "b" });
197
+
198
+ expect(store.deleteByAgent("builder", "r1")).toBe(1);
199
+ expect(store.list({ agentName: "builder" }).map((e) => e.runId)).toEqual(["r2"]);
200
+ });
201
+
202
+ test("deleteByAgent on an unknown agent returns 0", () => {
203
+ expect(store.deleteByAgent("nobody")).toBe(0);
204
+ });
183
205
  });
@@ -42,6 +42,12 @@ export interface ListEventsFilter {
42
42
  export interface EventStore {
43
43
  record(event: RecordEventInput): EventRecord;
44
44
  list(filter?: ListEventsFilter): EventRecord[];
45
+ /**
46
+ * Delete every event logged by `agentName` (optionally scoped to one run);
47
+ * returns the number of rows removed. Used when fully purging a reaped agent's
48
+ * data. The log is otherwise append-only — this is the sole deletion path.
49
+ */
50
+ deleteByAgent(agentName: string, runId?: string): number;
45
51
  close(): void;
46
52
  }
47
53
 
@@ -193,9 +199,26 @@ export function createEventStore(dbPath: string): EventStore {
193
199
  return rows.map(rowToRecord);
194
200
  }
195
201
 
202
+ function deleteByAgent(agentName: string, runId?: string): number {
203
+ // Count first so we can report how many rows were removed (bun:sqlite's
204
+ // run() does not surface a changes count through this query API uniformly).
205
+ const where =
206
+ runId !== undefined
207
+ ? "agent_name = $agent_name AND run_id = $run_id"
208
+ : "agent_name = $agent_name";
209
+ const params: Record<string, string> = { $agent_name: agentName };
210
+ if (runId !== undefined) params.$run_id = runId;
211
+ const countRow = db.query(`SELECT COUNT(*) AS n FROM events WHERE ${where}`).get(params) as {
212
+ n: number;
213
+ } | null;
214
+ const n = countRow?.n ?? 0;
215
+ if (n > 0) db.query(`DELETE FROM events WHERE ${where}`).run(params);
216
+ return n;
217
+ }
218
+
196
219
  function close(): void {
197
220
  db.close();
198
221
  }
199
222
 
200
- return { record, list, close };
223
+ return { record, list, deleteByAgent, close };
201
224
  }
@@ -133,4 +133,19 @@ describe("createMergeQueue", () => {
133
133
  queue = createMergeQueue(join(dir, "merge-queue.db"));
134
134
  }
135
135
  });
136
+
137
+ test("deleteByAgent removes every entry for the agent and returns the count", () => {
138
+ add({ agentName: "builder-1", branchName: "agent/a" });
139
+ add({ agentName: "builder-1", branchName: "agent/b" });
140
+ add({ agentName: "builder-2", branchName: "agent/c" });
141
+
142
+ expect(queue.deleteByAgent("builder-1")).toBe(2);
143
+ const pending = queue.listPending();
144
+ expect(pending).toHaveLength(1);
145
+ expect(pending[0]?.agentName).toBe("builder-2");
146
+ });
147
+
148
+ test("deleteByAgent on an unknown agent returns 0", () => {
149
+ expect(queue.deleteByAgent("nobody")).toBe(0);
150
+ });
136
151
  });
@@ -36,6 +36,12 @@ export interface MergeQueue {
36
36
  * The returned object reflects its pre-removal `status` ("pending").
37
37
  */
38
38
  dequeue(): MergeEntry | null;
39
+ /**
40
+ * Delete every queue entry belonging to `agentName` regardless of status;
41
+ * returns the number removed. Used when fully purging a reaped agent's data so
42
+ * no stale pending merge survives the agent it came from.
43
+ */
44
+ deleteByAgent(agentName: string): number;
39
45
  /** Close the underlying database connection. */
40
46
  close(): void;
41
47
  }
@@ -131,6 +137,14 @@ export function createMergeQueue(dbPath: string): MergeQueue {
131
137
  "DELETE FROM merge_queue WHERE seq = $seq",
132
138
  );
133
139
 
140
+ const countByAgentStmt = db.query<{ n: number }, { $agent: string }>(
141
+ "SELECT COUNT(*) AS n FROM merge_queue WHERE agent_name = $agent",
142
+ );
143
+
144
+ const deleteByAgentStmt = db.query<void, { $agent: string }>(
145
+ "DELETE FROM merge_queue WHERE agent_name = $agent",
146
+ );
147
+
134
148
  return {
135
149
  enqueue(entry): MergeEntry {
136
150
  const row = insertStmt.get({
@@ -170,6 +184,12 @@ export function createMergeQueue(dbPath: string): MergeQueue {
170
184
  return rowToEntry(row);
171
185
  },
172
186
 
187
+ deleteByAgent(agentName): number {
188
+ const n = countByAgentStmt.get({ $agent: agentName })?.n ?? 0;
189
+ if (n > 0) deleteByAgentStmt.run({ $agent: agentName });
190
+ return n;
191
+ },
192
+
173
193
  close(): void {
174
194
  db.close();
175
195
  },
@@ -221,23 +221,25 @@ export function startServer(opts: ServeOptions): ServeHandle {
221
221
  // Idle-agent reaper: terminate workers idle past the configured timeout. Runs
222
222
  // independently of connected clients (serve running = reaping active). Disabled
223
223
  // when idleTimeoutMinutes is 0. Config is read once at startup.
224
- const idleMinutes = (() => {
224
+ const { idleMinutes, purgeOnReap } = (() => {
225
225
  try {
226
- return loadConfig(opts.root).agents.idleTimeoutMinutes;
226
+ const agents = loadConfig(opts.root).agents;
227
+ return { idleMinutes: agents.idleTimeoutMinutes, purgeOnReap: agents.purgeOnReap };
227
228
  } catch {
228
- return 0;
229
+ return { idleMinutes: 0, purgeOnReap: false };
229
230
  }
230
231
  })();
231
232
  const reapTimer =
232
233
  idleMinutes > 0
233
234
  ? setInterval(() => {
234
235
  const store = createSessionStore(sessionsDbPath(opts.root));
235
- reapIdleSessions(store, opts.root, { idleMs: idleMinutes * 60_000 })
236
+ reapIdleSessions(store, opts.root, { idleMs: idleMinutes * 60_000, purge: purgeOnReap })
236
237
  .then((reaped) => {
237
238
  if (reaped.length > 0) {
238
239
  const names = reaped.map((r) => r.agentName).join(", ");
240
+ const how = purgeOnReap ? "reaped + purged" : "reaped";
239
241
  console.error(
240
- `[agentplate] reaped ${reaped.length} idle agent(s) (>${idleMinutes}m): ${names}`,
242
+ `[agentplate] ${how} ${reaped.length} idle agent(s) (>${idleMinutes}m): ${names}`,
241
243
  );
242
244
  }
243
245
  })
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Tests for the full agent-data purge ("clear the Office").
3
+ *
4
+ * Per project policy nothing is mocked: a real temp project root holds real
5
+ * SQLite stores (sessions/events/merge/mail) at their canonical paths, plus a
6
+ * real on-disk agent state dir and spec file. We seed each store, run
7
+ * `purgeAgentData`, and assert that every trace of the agent is gone while a
8
+ * second agent's data is untouched.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { createEventStore } from "../events/store.ts";
16
+ import { createMailStore } from "../mail/store.ts";
17
+ import { createMergeQueue } from "../merge/queue.ts";
18
+ import {
19
+ agentStateDir,
20
+ eventsDbPath,
21
+ mailDbPath,
22
+ mergeDbPath,
23
+ sessionsDbPath,
24
+ specPath,
25
+ } from "../paths.ts";
26
+ import type { AgentSession } from "../types.ts";
27
+ import { type PurgeStores, purgeAgentData } from "./purge.ts";
28
+ import { createSessionStore } from "./store.ts";
29
+
30
+ function mk(root: string, overrides: Partial<AgentSession>): AgentSession {
31
+ const now = new Date().toISOString();
32
+ return {
33
+ id: `s-${overrides.agentName ?? "a"}`,
34
+ agentName: "a",
35
+ capability: "builder",
36
+ taskId: "task-1",
37
+ runId: "run-1",
38
+ worktreePath: join(root, ".agentplate", "worktrees", overrides.agentName ?? "a"),
39
+ branchName: "agentplate/a",
40
+ state: "stopped",
41
+ parentAgent: null,
42
+ depth: 1,
43
+ pid: null,
44
+ runtimeSessionId: null,
45
+ startedAt: now,
46
+ lastActivity: now,
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ describe("purgeAgentData", () => {
52
+ let root: string;
53
+ let stores: PurgeStores;
54
+
55
+ beforeEach(() => {
56
+ root = mkdtempSync(join(tmpdir(), "ap-purge-"));
57
+ stores = {
58
+ sessions: createSessionStore(sessionsDbPath(root)),
59
+ events: createEventStore(eventsDbPath(root)),
60
+ merge: createMergeQueue(mergeDbPath(root)),
61
+ mail: createMailStore(mailDbPath(root)),
62
+ };
63
+ });
64
+
65
+ afterEach(() => {
66
+ stores.sessions.close();
67
+ stores.events.close();
68
+ stores.merge.close();
69
+ stores.mail.close();
70
+ rmSync(root, { recursive: true, force: true });
71
+ });
72
+
73
+ test("erases every trace of the agent and reports the counts", () => {
74
+ const session = mk(root, { id: "s-doomed", agentName: "doomed" });
75
+ stores.sessions.upsertSession(session);
76
+
77
+ // Seed each store + on-disk artifact for the doomed agent.
78
+ stores.events.record({ agentName: "doomed", runId: "run-1", type: "tool-start" });
79
+ stores.events.record({ agentName: "doomed", runId: "run-1", type: "tool-end" });
80
+ stores.mail.send({ from: "doomed", to: "lead", subject: "s", body: "b", type: "status" });
81
+ stores.mail.send({ from: "lead", to: "doomed", subject: "s", body: "b", type: "dispatch" });
82
+ stores.merge.enqueue({
83
+ branchName: "agentplate/doomed",
84
+ agentName: "doomed",
85
+ taskId: "task-1",
86
+ targetBranch: "main",
87
+ });
88
+ mkdirSync(agentStateDir(root, "doomed"), { recursive: true });
89
+ writeFileSync(join(agentStateDir(root, "doomed"), "identity.yaml"), "sessions: 1\n");
90
+ mkdirSync(join(root, ".agentplate", "specs"), { recursive: true });
91
+ writeFileSync(specPath(root, "task-1"), "# spec\n");
92
+
93
+ const report = purgeAgentData(root, session, stores);
94
+
95
+ expect(report).toEqual({
96
+ mailDeleted: 2,
97
+ eventsDeleted: 2,
98
+ mergeDeleted: 1,
99
+ stateDirRemoved: true,
100
+ specRemoved: true,
101
+ sessionDeleted: true,
102
+ });
103
+ // Nothing of the agent remains.
104
+ expect(stores.sessions.getSession("s-doomed")).toBeNull();
105
+ expect(stores.events.list({ agentName: "doomed" })).toEqual([]);
106
+ expect(stores.mail.list({ to: "doomed" })).toEqual([]);
107
+ expect(stores.mail.list({ from: "doomed" })).toEqual([]);
108
+ expect(stores.merge.listPending()).toEqual([]);
109
+ expect(existsSync(agentStateDir(root, "doomed"))).toBe(false);
110
+ expect(existsSync(specPath(root, "task-1"))).toBe(false);
111
+ });
112
+
113
+ test("leaves a second agent's data untouched", () => {
114
+ const doomed = mk(root, { id: "s-doomed", agentName: "doomed", taskId: "task-1" });
115
+ const keeper = mk(root, { id: "s-keeper", agentName: "keeper", taskId: "task-2" });
116
+ stores.sessions.upsertSession(doomed);
117
+ stores.sessions.upsertSession(keeper);
118
+ stores.events.record({ agentName: "keeper", runId: "run-1", type: "tool-start" });
119
+ stores.mail.send({ from: "keeper", to: "lead", subject: "s", body: "b", type: "status" });
120
+
121
+ purgeAgentData(root, doomed, stores);
122
+
123
+ expect(stores.sessions.getSession("s-keeper")).not.toBeNull();
124
+ expect(stores.events.list({ agentName: "keeper" }).length).toBe(1);
125
+ expect(stores.mail.list({ from: "keeper" }).length).toBe(1);
126
+ });
127
+
128
+ test("keeps the spec file while another session still references the task", () => {
129
+ const a = mk(root, { id: "s-a", agentName: "agent-a", taskId: "shared" });
130
+ const b = mk(root, { id: "s-b", agentName: "agent-b", taskId: "shared" });
131
+ stores.sessions.upsertSession(a);
132
+ stores.sessions.upsertSession(b);
133
+ mkdirSync(join(root, ".agentplate", "specs"), { recursive: true });
134
+ writeFileSync(specPath(root, "shared"), "# shared spec\n");
135
+
136
+ // Purging the first sibling keeps the spec (b still uses it).
137
+ const first = purgeAgentData(root, a, stores);
138
+ expect(first.specRemoved).toBe(false);
139
+ expect(existsSync(specPath(root, "shared"))).toBe(true);
140
+
141
+ // Purging the last sibling finally removes the spec.
142
+ const second = purgeAgentData(root, b, stores);
143
+ expect(second.specRemoved).toBe(true);
144
+ expect(existsSync(specPath(root, "shared"))).toBe(false);
145
+ });
146
+
147
+ test("is resilient when there is nothing to purge", () => {
148
+ const session = mk(root, { id: "s-empty", agentName: "empty" });
149
+ stores.sessions.upsertSession(session);
150
+
151
+ const report = purgeAgentData(root, session, stores);
152
+ expect(report.mailDeleted).toBe(0);
153
+ expect(report.eventsDeleted).toBe(0);
154
+ expect(report.mergeDeleted).toBe(0);
155
+ expect(report.stateDirRemoved).toBe(false); // dir never existed
156
+ expect(report.sessionDeleted).toBe(true);
157
+ expect(stores.sessions.getSession("s-empty")).toBeNull();
158
+ });
159
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Full data purge for a terminated agent — "clear the Office".
3
+ *
4
+ * Reaping (see {@link ./reaper.ts}) stops an idle agent and removes its worktree,
5
+ * but deliberately leaves its records behind for history: the session row stays
6
+ * (marked `stopped`), and its mail / events / queued merges / on-disk state dir
7
+ * all persist. That is the right default for auditability, but it accumulates —
8
+ * a long-running project fills up with the debris of agents that came and went.
9
+ *
10
+ * This module is the opt-in opposite: given a session, erase *everything* that
11
+ * agent left behind so nothing of it remains. It is intentionally thorough and
12
+ * destructive; callers gate it behind an explicit `--purge` / `purgeOnReap`.
13
+ *
14
+ * Design: the orchestrator is handed already-open stores (the reaper opens them
15
+ * once per sweep; tests pass in-memory ones) and is responsible only for the
16
+ * deletions, not store lifecycle. Every step is best-effort and isolated — a
17
+ * failure to clear one store never aborts the rest — and a {@link PurgeReport}
18
+ * records exactly what was removed.
19
+ */
20
+
21
+ import { existsSync, rmSync } from "node:fs";
22
+ import { resolve, sep } from "node:path";
23
+ import type { EventStore } from "../events/store.ts";
24
+ import type { MailStore } from "../mail/store.ts";
25
+ import type { MergeQueue } from "../merge/queue.ts";
26
+ import { agentStateDir, specPath } from "../paths.ts";
27
+ import type { AgentSession } from "../types.ts";
28
+ import type { SessionStore } from "./store.ts";
29
+
30
+ /** The open stores a purge needs. The session store is the one the reaper holds. */
31
+ export interface PurgeStores {
32
+ sessions: SessionStore;
33
+ events: EventStore;
34
+ merge: MergeQueue;
35
+ mail: MailStore;
36
+ }
37
+
38
+ /** What a single agent purge removed. Counts are best-effort (0 on failure). */
39
+ export interface PurgeReport {
40
+ /** Mail messages deleted (sent or received by the agent). */
41
+ mailDeleted: number;
42
+ /** Event-log rows deleted for the agent. */
43
+ eventsDeleted: number;
44
+ /** Merge-queue entries deleted for the agent. */
45
+ mergeDeleted: number;
46
+ /** Whether the on-disk `.agentplate/agents/<name>/` state dir was removed. */
47
+ stateDirRemoved: boolean;
48
+ /** Whether the task spec file was removed (only when no session still uses it). */
49
+ specRemoved: boolean;
50
+ /** Whether the session row itself was deleted. */
51
+ sessionDeleted: boolean;
52
+ }
53
+
54
+ /**
55
+ * Erase all data belonging to `session`'s agent. Assumes the agent is already
56
+ * terminated (pid killed, worktree removed) — this only clears records, not
57
+ * processes or git state. Returns a {@link PurgeReport} of what was removed.
58
+ *
59
+ * Ordering note: the session row is deleted last and the spec check runs against
60
+ * the *remaining* sessions, so the agent's own row never keeps its spec alive.
61
+ */
62
+ export function purgeAgentData(
63
+ root: string,
64
+ session: AgentSession,
65
+ stores: PurgeStores,
66
+ ): PurgeReport {
67
+ const report: PurgeReport = {
68
+ mailDeleted: 0,
69
+ eventsDeleted: 0,
70
+ mergeDeleted: 0,
71
+ stateDirRemoved: false,
72
+ specRemoved: false,
73
+ sessionDeleted: false,
74
+ };
75
+
76
+ // Mail: everything the agent sent or received.
77
+ try {
78
+ report.mailDeleted = stores.mail.purge({ agent: session.agentName });
79
+ } catch {
80
+ // Best-effort: a store error here must not block the rest of the purge.
81
+ }
82
+
83
+ // Events: the agent's slice of the append-only log (scoped to its run so a
84
+ // reused agent name in a later run keeps its own history).
85
+ try {
86
+ report.eventsDeleted = stores.events.deleteByAgent(session.agentName, session.runId);
87
+ } catch {
88
+ // Best-effort.
89
+ }
90
+
91
+ // Merge queue: any pending/merged/failed entries it enqueued.
92
+ try {
93
+ report.mergeDeleted = stores.merge.deleteByAgent(session.agentName);
94
+ } catch {
95
+ // Best-effort.
96
+ }
97
+
98
+ // On-disk state dir: identity CV + applied-skills.json. Guarded so we only ever
99
+ // remove a path that resolves inside `.agentplate/agents/` (defence in depth;
100
+ // the path is already derived from a constant, not user input).
101
+ try {
102
+ const dir = resolve(agentStateDir(root, session.agentName));
103
+ const agentsRoot = resolve(agentStateDir(root, ""));
104
+ // Only report a removal when a dir actually existed: rmSync with force:true
105
+ // is a silent no-op for a missing path, which would otherwise read as "removed".
106
+ if (dir.startsWith(agentsRoot + sep) && dir !== agentsRoot && existsSync(dir)) {
107
+ rmSync(dir, { recursive: true, force: true });
108
+ report.stateDirRemoved = true;
109
+ }
110
+ } catch {
111
+ // Best-effort.
112
+ }
113
+
114
+ // Session row: delete it before checking the spec so this agent's own row is
115
+ // not counted as "still using" the task.
116
+ try {
117
+ stores.sessions.deleteSession(session.id);
118
+ report.sessionDeleted = true;
119
+ } catch {
120
+ // Best-effort.
121
+ }
122
+
123
+ // Spec file: shared by task id, so only remove it once no remaining session
124
+ // references the task. (A multi-agent task keeps its spec until the last one
125
+ // is purged.)
126
+ try {
127
+ const stillUsed = stores.sessions.listSessions().some((s) => s.taskId === session.taskId);
128
+ const spec = specPath(root, session.taskId);
129
+ if (!stillUsed && existsSync(spec)) {
130
+ rmSync(spec, { force: true });
131
+ report.specRemoved = true;
132
+ }
133
+ } catch {
134
+ // Best-effort.
135
+ }
136
+
137
+ return report;
138
+ }
@@ -1,8 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { sessionsDbPath } from "../paths.ts";
5
+ import { createEventStore } from "../events/store.ts";
6
+ import { createMailStore } from "../mail/store.ts";
7
+ import { createMergeQueue } from "../merge/queue.ts";
8
+ import { agentStateDir, eventsDbPath, mailDbPath, mergeDbPath, sessionsDbPath } from "../paths.ts";
6
9
  import type { AgentSession } from "../types.ts";
7
10
  import { reapIdleSessions, selectIdleSessions } from "./reaper.ts";
8
11
  import { createSessionStore } from "./store.ts";
@@ -159,4 +162,83 @@ describe("reapIdleSessions", () => {
159
162
  store.close();
160
163
  }
161
164
  });
165
+
166
+ test("purge:true erases the reaped agent's data; default reap keeps it", async () => {
167
+ // Seed the auxiliary stores at their canonical paths, then close them so the
168
+ // reaper opens its own handles (matching production). The `.agentplate` dir
169
+ // must exist first — only the session store mkdirs it, and these open earlier.
170
+ mkdirSync(join(root, ".agentplate"), { recursive: true });
171
+ const events = createEventStore(eventsDbPath(root));
172
+ const merge = createMergeQueue(mergeDbPath(root));
173
+ const mail = createMailStore(mailDbPath(root));
174
+ // Same runId as the session (mk defaults to "r1") so the run-scoped purge matches.
175
+ events.record({ agentName: "old", runId: "r1", type: "tool-start" });
176
+ mail.send({ from: "old", to: "lead", subject: "s", body: "b", type: "status" });
177
+ merge.enqueue({
178
+ branchName: "agentplate/old",
179
+ agentName: "old",
180
+ taskId: "t",
181
+ targetBranch: "main",
182
+ });
183
+ events.close();
184
+ merge.close();
185
+ mail.close();
186
+ mkdirSync(agentStateDir(root, "old"), { recursive: true });
187
+ writeFileSync(join(agentStateDir(root, "old"), "identity.yaml"), "sessions: 1\n");
188
+
189
+ const store = createSessionStore(sessionsDbPath(root));
190
+ try {
191
+ store.upsertSession(
192
+ mk({ id: "s-old", agentName: "old", taskId: "t", lastActivity: minsAgo(30) }),
193
+ );
194
+
195
+ const reaped = await reapIdleSessions(store, root, {
196
+ idleMs: IDLE_MS,
197
+ now: NOW,
198
+ purge: true,
199
+ });
200
+
201
+ // The report carries the purge counts...
202
+ expect(reaped[0]?.purged).toEqual({
203
+ mailDeleted: 1,
204
+ eventsDeleted: 1,
205
+ mergeDeleted: 1,
206
+ stateDirRemoved: true,
207
+ specRemoved: false, // no spec file was created
208
+ sessionDeleted: true,
209
+ });
210
+ // ...and the session row is gone, not merely stopped.
211
+ expect(store.getSession("s-old")).toBeNull();
212
+ expect(existsSync(agentStateDir(root, "old"))).toBe(false);
213
+
214
+ // Verify the aux stores were actually cleared (reopen fresh handles).
215
+ const ev = createEventStore(eventsDbPath(root));
216
+ const mq = createMergeQueue(mergeDbPath(root));
217
+ const ml = createMailStore(mailDbPath(root));
218
+ try {
219
+ expect(ev.list({ agentName: "old" })).toEqual([]);
220
+ expect(mq.listPending()).toEqual([]);
221
+ expect(ml.list({ from: "old" })).toEqual([]);
222
+ } finally {
223
+ ev.close();
224
+ mq.close();
225
+ ml.close();
226
+ }
227
+ } finally {
228
+ store.close();
229
+ }
230
+ });
231
+
232
+ test("default reap (no purge) keeps the session row and reports purged: null", async () => {
233
+ const store = createSessionStore(sessionsDbPath(root));
234
+ try {
235
+ store.upsertSession(mk({ id: "s-old", agentName: "old", lastActivity: minsAgo(30) }));
236
+ const reaped = await reapIdleSessions(store, root, { idleMs: IDLE_MS, now: NOW });
237
+ expect(reaped[0]?.purged).toBeNull();
238
+ // Row kept for history, just marked stopped.
239
+ expect(store.getSession("s-old")?.state).toBe("stopped");
240
+ } finally {
241
+ store.close();
242
+ }
243
+ });
162
244
  });
@@ -15,9 +15,13 @@
15
15
  * worktree IS the project root, and it does not stream per-turn events).
16
16
  */
17
17
 
18
- import { worktreesDir } from "../paths.ts";
18
+ import { createEventStore } from "../events/store.ts";
19
+ import { createMailStore } from "../mail/store.ts";
20
+ import { createMergeQueue } from "../merge/queue.ts";
21
+ import { eventsDbPath, mailDbPath, mergeDbPath, worktreesDir } from "../paths.ts";
19
22
  import type { AgentSession, SessionState } from "../types.ts";
20
23
  import { deleteBranch, removeWorktree, worktreeExists } from "../worktree/manager.ts";
24
+ import { type PurgeReport, purgeAgentData } from "./purge.ts";
21
25
  import type { SessionStore } from "./store.ts";
22
26
 
23
27
  /** States that can be reaped — the pre-terminal (still-counted) ones. */
@@ -62,6 +66,8 @@ export interface ReapedAgent {
62
66
  idleMs: number;
63
67
  /** Whether its worktree was removed (false if kept, missing, or removal failed). */
64
68
  worktreeRemoved: boolean;
69
+ /** What a full data purge removed, or null when purge was not requested. */
70
+ purged: PurgeReport | null;
65
71
  }
66
72
 
67
73
  export interface ReapOptions {
@@ -71,6 +77,13 @@ export interface ReapOptions {
71
77
  now?: number;
72
78
  /** Remove the reaped agent's worktree + branch (default true). */
73
79
  removeWorktrees?: boolean;
80
+ /**
81
+ * Fully erase each reaped agent's data — mail, events, queued merges, on-disk
82
+ * state dir, and the session row itself — so nothing of it remains ("clear the
83
+ * Office"). Default false: reaping keeps records for history. When true, the
84
+ * auxiliary stores are opened from `root` for the sweep and closed afterward.
85
+ */
86
+ purge?: boolean;
74
87
  /** Capabilities to skip (defaults to `["coordinator"]`). */
75
88
  excludeCapabilities?: readonly string[];
76
89
  }
@@ -88,52 +101,80 @@ export async function reapIdleSessions(
88
101
  ): Promise<ReapedAgent[]> {
89
102
  const now = opts.now ?? Date.now();
90
103
  const removeWorktrees = opts.removeWorktrees !== false;
104
+ const purge = opts.purge === true;
91
105
  const stale = selectIdleSessions(store.listSessions(), {
92
106
  idleMs: opts.idleMs,
93
107
  now,
94
108
  excludeCapabilities: opts.excludeCapabilities,
95
109
  });
96
110
 
97
- const reaped: ReapedAgent[] = [];
98
- for (const s of stale) {
99
- // 1. Kill any live process (spawn-per-turn workers usually have no pid).
100
- if (s.pid != null) {
101
- try {
102
- process.kill(s.pid, "SIGTERM");
103
- } catch {
104
- // Already gone / not ours — nothing to do.
111
+ // Open the auxiliary stores once for the whole sweep, only when purging. They
112
+ // share the session store's `root` and are closed in `finally` below.
113
+ const aux = purge
114
+ ? {
115
+ events: createEventStore(eventsDbPath(root)),
116
+ merge: createMergeQueue(mergeDbPath(root)),
117
+ mail: createMailStore(mailDbPath(root)),
105
118
  }
106
- }
119
+ : null;
107
120
 
108
- // 2. Remove the worktree + branch (guarded against the project root).
109
- let worktreeRemoved = false;
110
- if (removeWorktrees && isRemovableWorktree(root, s.worktreePath)) {
111
- try {
112
- if (await worktreeExists(root, s.worktreePath)) {
113
- await removeWorktree(root, s.worktreePath, { force: true });
121
+ const reaped: ReapedAgent[] = [];
122
+ try {
123
+ for (const s of stale) {
124
+ // 1. Kill any live process (spawn-per-turn workers usually have no pid).
125
+ if (s.pid != null) {
126
+ try {
127
+ process.kill(s.pid, "SIGTERM");
128
+ } catch {
129
+ // Already gone / not ours — nothing to do.
114
130
  }
115
- worktreeRemoved = true;
116
- // Branch delete is separate and best-effort (no-op if already gone).
131
+ }
132
+
133
+ // 2. Remove the worktree + branch (guarded against the project root).
134
+ let worktreeRemoved = false;
135
+ if (removeWorktrees && isRemovableWorktree(root, s.worktreePath)) {
117
136
  try {
118
- await deleteBranch(root, s.branchName);
137
+ if (await worktreeExists(root, s.worktreePath)) {
138
+ await removeWorktree(root, s.worktreePath, { force: true });
139
+ }
140
+ worktreeRemoved = true;
141
+ // Branch delete is separate and best-effort (no-op if already gone).
142
+ try {
143
+ await deleteBranch(root, s.branchName);
144
+ } catch {
145
+ // Branch may be merged away or shared — leave it.
146
+ }
119
147
  } catch {
120
- // Branch may be merged away or shared — leave it.
148
+ worktreeRemoved = false;
121
149
  }
122
- } catch {
123
- worktreeRemoved = false;
124
150
  }
125
- }
126
151
 
127
- // 3. Mark the session terminated so it stops counting and shows as stopped.
128
- store.updateSessionState(s.id, "stopped");
152
+ // 3. Mark the session terminated so it stops counting and shows as stopped.
153
+ // (When purging, the row is deleted in step 4 — but we still transition it
154
+ // first so any concurrent reader observes a terminal state, never a gap.)
155
+ store.updateSessionState(s.id, "stopped");
156
+
157
+ // 4. Optionally erase every trace of the agent so the workspace is cleared.
158
+ let purged: PurgeReport | null = null;
159
+ if (aux) {
160
+ purged = purgeAgentData(root, s, { sessions: store, ...aux });
161
+ }
129
162
 
130
- reaped.push({
131
- id: s.id,
132
- agentName: s.agentName,
133
- capability: s.capability,
134
- idleMs: now - Date.parse(s.lastActivity),
135
- worktreeRemoved,
136
- });
163
+ reaped.push({
164
+ id: s.id,
165
+ agentName: s.agentName,
166
+ capability: s.capability,
167
+ idleMs: now - Date.parse(s.lastActivity),
168
+ worktreeRemoved,
169
+ purged,
170
+ });
171
+ }
172
+ } finally {
173
+ if (aux) {
174
+ aux.events.close();
175
+ aux.merge.close();
176
+ aux.mail.close();
177
+ }
137
178
  }
138
179
  return reaped;
139
180
  }
@@ -344,6 +344,24 @@ describe("createSessionStore — file-backed database", () => {
344
344
  rmSync(dir, { recursive: true, force: true });
345
345
  });
346
346
 
347
+ test("deleteSession removes the row entirely (not just a state change)", () => {
348
+ const store = createSessionStore(":memory:");
349
+ try {
350
+ const run = store.createRun();
351
+ const session = makeSession({ runId: run.id, state: "idle" });
352
+ store.upsertSession(session);
353
+ expect(store.getSession(session.id)).not.toBeNull();
354
+
355
+ store.deleteSession(session.id);
356
+ expect(store.getSession(session.id)).toBeNull();
357
+ expect(store.countActive(run.id)).toBe(0);
358
+ // Deleting a missing id is a no-op (no throw).
359
+ expect(() => store.deleteSession("does-not-exist")).not.toThrow();
360
+ } finally {
361
+ store.close();
362
+ }
363
+ });
364
+
347
365
  test("creates the parent directory and persists across reopen", () => {
348
366
  // Point at a NON-existent nested path to exercise the mkdir-parent logic.
349
367
  const dbPath = join(dir, "nested", "sessions.db");
@@ -56,6 +56,12 @@ export interface SessionStore {
56
56
  updateSessionState(id: string, state: SessionState): void;
57
57
  setRuntimeSessionId(id: string, runtimeSessionId: string): void;
58
58
  touch(id: string): void;
59
+ /**
60
+ * Permanently delete a session row by id (no-op if absent). Unlike
61
+ * {@link updateSessionState}(id, "stopped") — which keeps the row for history —
62
+ * this removes it entirely, used when fully purging a reaped agent's data.
63
+ */
64
+ deleteSession(id: string): void;
59
65
  countActive(runId?: string): number;
60
66
  countActiveByParent(parentAgent: string, runId?: string): number;
61
67
  // --- Lifecycle ---
@@ -312,6 +318,11 @@ export function createSessionStore(dbPath: string): SessionStore {
312
318
  db.query("UPDATE sessions SET last_activity = ? WHERE id = ?").run(now, id);
313
319
  }
314
320
 
321
+ function deleteSession(id: string): void {
322
+ // Hard delete. Safe to call for a missing id (DELETE simply affects 0 rows).
323
+ db.query("DELETE FROM sessions WHERE id = ?").run(id);
324
+ }
325
+
315
326
  function countActive(runId?: string): number {
316
327
  // Active = booting | working | idle (see ACTIVE_STATES). Used by schedulers
317
328
  // to enforce per-run / global concurrency caps.
@@ -363,6 +374,7 @@ export function createSessionStore(dbPath: string): SessionStore {
363
374
  updateSessionState,
364
375
  setRuntimeSessionId,
365
376
  touch,
377
+ deleteSession,
366
378
  countActive,
367
379
  countActiveByParent,
368
380
  close,
package/src/types.ts CHANGED
@@ -167,6 +167,14 @@ export interface AgentsConfig {
167
167
  skipGates: boolean;
168
168
  /** Skip the post-turn skill-distillation loop. */
169
169
  skipSkills: boolean;
170
+ /**
171
+ * When the idle reaper fires (in `agentplate serve`'s loop), also fully erase
172
+ * each reaped agent — mail, events, queued merges, on-disk state dir, and the
173
+ * session row — so the workspace is cleared, not just stopped. Default false
174
+ * (reaping keeps records for history). The `agentplate reap --purge` flag does
175
+ * the same on demand regardless of this setting.
176
+ */
177
+ purgeOnReap: boolean;
170
178
  }
171
179
 
172
180
  /**
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * Kept as a plain constant (not read from package.json at runtime) so the value
5
5
  * is available without filesystem access and survives bundling.
6
6
  */
7
- export const VERSION = "1.3.0";
7
+ export const VERSION = "1.4.0";
@@ -384,6 +384,13 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
384
384
  agents.skipReview = skips.includes("skipReview");
385
385
  agents.skipGates = skips.includes("skipGates");
386
386
  agents.skipSkills = skips.includes("skipSkills");
387
+
388
+ agents.purgeOnReap = ensure(
389
+ await p.confirm({
390
+ message: "Fully erase idle agents when reaped (mail, events, merges, files, session)?",
391
+ initialValue: agents.purgeOnReap,
392
+ }),
393
+ );
387
394
  }
388
395
 
389
396
  // 7. Summary -----------------------------------------------------------
@@ -403,6 +410,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
403
410
  previewProvider.baseUrl ? `base URL: ${previewProvider.baseUrl}` : undefined,
404
411
  `gates: ${qualityGates.length ? qualityGates.map((g) => g.name).join(", ") : "none"}`,
405
412
  `auto-merge:${autoMerge}`,
413
+ agents.purgeOnReap ? "purge-on-reap: on (idle agents fully erased)" : undefined,
406
414
  modelsByCapability?.scout
407
415
  ? `fast model: ${modelsByCapability.scout} (scout, reviewer)`
408
416
  : undefined,