@agentplate/cli 1.2.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `agentplate watch` tests. Real initialized temp project + real (mock) turns,
3
+ * driven deterministically via `--once`. Asserts which idle agents get advanced.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ AGENTPLATE_DIR,
12
+ CONFIG_FILE,
13
+ DEFAULT_CONFIG,
14
+ serializeConfig,
15
+ setProjectRootOverride,
16
+ } from "../config.ts";
17
+ import { createMailClient } from "../mail/client.ts";
18
+ import { sessionsDbPath } from "../paths.ts";
19
+ import { createSessionStore } from "../sessions/store.ts";
20
+ import type { AgentSession } from "../types.ts";
21
+ import { createWatchCommand } from "./watch.ts";
22
+
23
+ let root: string;
24
+ let worktree: string;
25
+
26
+ function seedSession(over: Partial<AgentSession>): void {
27
+ const store = createSessionStore(sessionsDbPath(root));
28
+ const now = new Date().toISOString();
29
+ try {
30
+ store.upsertSession({
31
+ id: `session-${crypto.randomUUID()}`,
32
+ agentName: "builder-1",
33
+ capability: "builder",
34
+ taskId: "task-1",
35
+ runId: "run-1",
36
+ worktreePath: worktree,
37
+ branchName: "agentplate/builder-1",
38
+ state: "idle",
39
+ parentAgent: "lead-1",
40
+ depth: 1,
41
+ pid: null,
42
+ runtimeSessionId: "sess-prior",
43
+ startedAt: now,
44
+ lastActivity: now,
45
+ ...over,
46
+ });
47
+ } finally {
48
+ store.close();
49
+ }
50
+ }
51
+
52
+ function sendMailTo(agent: string): void {
53
+ const mail = createMailClient(root);
54
+ try {
55
+ mail.send({ from: "lead-1", to: agent, subject: "ping", body: "continue", type: "status" });
56
+ } finally {
57
+ mail.close();
58
+ }
59
+ }
60
+
61
+ /** Run `watch --once --json` and return the parsed summary. */
62
+ async function watchOnce(): Promise<{ driven: number; turns: Array<{ agent: string }> }> {
63
+ const original = process.stdout.write.bind(process.stdout);
64
+ let out = "";
65
+ process.stdout.write = (chunk: string | Uint8Array): boolean => {
66
+ out += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
67
+ return true;
68
+ };
69
+ try {
70
+ await createWatchCommand().parseAsync(["--once", "--json"], { from: "user" });
71
+ } finally {
72
+ process.stdout.write = original;
73
+ }
74
+ // jsonOutput wraps the payload in the standard { ok, data } envelope.
75
+ return JSON.parse(out.trim()).data;
76
+ }
77
+
78
+ beforeEach(() => {
79
+ root = mkdtempSync(join(tmpdir(), "agentplate-watch-"));
80
+ worktree = mkdtempSync(join(tmpdir(), "agentplate-watch-wt-"));
81
+ mkdirSync(join(root, AGENTPLATE_DIR), { recursive: true });
82
+ const config = structuredClone(DEFAULT_CONFIG);
83
+ config.runtime.default = "mock";
84
+ writeFileSync(join(root, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
85
+ setProjectRootOverride(root);
86
+ process.env.AGENTPLATE_MOCK_CMD = "true";
87
+ });
88
+
89
+ afterEach(() => {
90
+ setProjectRootOverride(null);
91
+ rmSync(root, { recursive: true, force: true });
92
+ rmSync(worktree, { recursive: true, force: true });
93
+ process.env.AGENTPLATE_MOCK_CMD = undefined;
94
+ });
95
+
96
+ describe("agentplate watch --once", () => {
97
+ test("drives an idle agent that has unread mail", async () => {
98
+ seedSession({ agentName: "builder-1", state: "idle" });
99
+ sendMailTo("builder-1");
100
+ const out = await watchOnce();
101
+ expect(out.driven).toBe(1);
102
+ expect(out.turns[0]?.agent).toBe("builder-1");
103
+ });
104
+
105
+ test("skips an idle agent with no unread mail", async () => {
106
+ seedSession({ agentName: "builder-1", state: "idle" });
107
+ const out = await watchOnce();
108
+ expect(out.driven).toBe(0);
109
+ });
110
+
111
+ test("never drives a terminal agent, even with mail", async () => {
112
+ seedSession({ agentName: "builder-1", state: "completed" });
113
+ sendMailTo("builder-1");
114
+ const out = await watchOnce();
115
+ expect(out.driven).toBe(0);
116
+ });
117
+
118
+ test("drives only the idle-with-mail agents in a mixed fleet", async () => {
119
+ seedSession({ agentName: "has-mail", state: "idle" });
120
+ seedSession({ agentName: "no-mail", state: "idle" });
121
+ sendMailTo("has-mail");
122
+ const out = await watchOnce();
123
+ expect(out.driven).toBe(1);
124
+ expect(out.turns.map((t) => t.agent)).toEqual(["has-mail"]);
125
+ });
126
+
127
+ test("drives multiple idle-with-mail agents in one pass (concurrent)", async () => {
128
+ for (const a of ["a1", "a2", "a3"]) {
129
+ seedSession({ agentName: a, state: "idle" });
130
+ sendMailTo(a);
131
+ }
132
+ const out = await watchOnce();
133
+ expect(out.driven).toBe(3);
134
+ expect(out.turns.map((t) => t.agent).sort()).toEqual(["a1", "a2", "a3"]);
135
+ });
136
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `agentplate watch` — the mail pump that makes warm-start automatic.
3
+ *
4
+ * Each tick it finds **idle** agents (paused after a turn, awaiting mail) that now
5
+ * have **unread mail**, and runs each one's next turn via {@link driveAgentTurn},
6
+ * which **resumes** the runtime session (warm start). Detection is non-destructive
7
+ * (`mail.check(..., { unreadOnly })`); the turn itself injects + marks the mail
8
+ * read. Each pass drives eligible agents concurrently, capped at the fleet's
9
+ * `maxConcurrent`; a failing turn is logged and the loop continues.
10
+ *
11
+ * Modes: `--once` (single pass), `--until-idle` (loop until no active agents
12
+ * remain — the run drained), or the default (loop until Ctrl-C).
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { Command } from "commander";
17
+ import { driveAgentTurn } from "../agents/drive.ts";
18
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
19
+ import { ValidationError } from "../errors.ts";
20
+ import { createEventStore } from "../events/store.ts";
21
+ import { jsonOutput } from "../json.ts";
22
+ import { brand, muted, printInfo, printSuccess, printWarning } from "../logging/color.ts";
23
+ import { createMailClient } from "../mail/client.ts";
24
+ import { currentRunPath, eventsDbPath, sessionsDbPath } from "../paths.ts";
25
+ import { createSessionStore } from "../sessions/store.ts";
26
+ import type { AgentSession } from "../types.ts";
27
+
28
+ interface WatchOptions {
29
+ run?: string;
30
+ interval?: string;
31
+ once?: boolean;
32
+ untilIdle?: boolean;
33
+ json?: boolean;
34
+ }
35
+
36
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
37
+
38
+ /** Current run id from `.agentplate/current-run.txt`, or undefined (= all runs). */
39
+ function readCurrentRun(root: string): string | undefined {
40
+ const path = currentRunPath(root);
41
+ if (!existsSync(path)) return undefined;
42
+ return readFileSync(path, "utf8").trim() || undefined;
43
+ }
44
+
45
+ export function createWatchCommand(): Command {
46
+ return new Command("watch")
47
+ .description("Auto-advance idle agents: run their next (resumed) turn when they have mail")
48
+ .option("--run <id>", "scope to a run (default: the current run)")
49
+ .option("--interval <ms>", "poll interval in milliseconds", "2000")
50
+ .option("--once", "run a single pass, then exit")
51
+ .option("--until-idle", "exit once no active agents remain (the run has drained)")
52
+ .option("--json", "output a JSON summary")
53
+ .action(async (opts: WatchOptions, command: Command) => {
54
+ const useJson = command.optsWithGlobals().json === true;
55
+ const root = findProjectRoot();
56
+ if (!isInitialized(root)) {
57
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
58
+ }
59
+ const config = loadConfig(root);
60
+ const interval = Number(opts.interval ?? "2000");
61
+ if (!Number.isFinite(interval) || interval < 0) {
62
+ throw new ValidationError("--interval must be a non-negative number of milliseconds.");
63
+ }
64
+ const runId = opts.run ?? readCurrentRun(root);
65
+
66
+ const store = createSessionStore(sessionsDbPath(root));
67
+ const mail = createMailClient(root);
68
+ const events = createEventStore(eventsDbPath(root));
69
+
70
+ let stop = false;
71
+ const onSigint = (): void => {
72
+ stop = true;
73
+ };
74
+ process.once("SIGINT", onSigint);
75
+
76
+ let totalDriven = 0;
77
+ const drivenLog: Array<{ agent: string; state: string }> = [];
78
+ // Cap simultaneous turns at the fleet's maxConcurrent (>= 1).
79
+ const concurrency = Math.max(1, config.agents.maxConcurrent);
80
+
81
+ /** Drive one agent's next turn, recording the outcome. */
82
+ const driveOne = async (session: AgentSession): Promise<void> => {
83
+ try {
84
+ const { finalState } = await driveAgentTurn({
85
+ root,
86
+ config,
87
+ session,
88
+ store,
89
+ events,
90
+ mail,
91
+ });
92
+ totalDriven++;
93
+ drivenLog.push({ agent: session.agentName, state: finalState });
94
+ if (!useJson) printInfo(` ${brand(session.agentName)} → ${finalState}`);
95
+ } catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ if (!useJson) printWarning(` ${session.agentName}: turn failed — ${message}`);
98
+ }
99
+ };
100
+
101
+ /**
102
+ * One pass: drive every idle agent that has unread mail, up to `concurrency`
103
+ * turns at a time. Returns the number driven.
104
+ */
105
+ const pass = async (): Promise<number> => {
106
+ const eligible = store
107
+ .listSessions({ runId, state: "idle" })
108
+ .filter((s) => mail.check(s.agentName, { unreadOnly: true }).length > 0);
109
+ let next = 0;
110
+ const worker = async (): Promise<void> => {
111
+ while (!stop) {
112
+ const session = eligible[next++];
113
+ if (!session) break;
114
+ await driveOne(session);
115
+ }
116
+ };
117
+ const lanes = Math.min(concurrency, eligible.length);
118
+ await Promise.all(Array.from({ length: lanes }, () => worker()));
119
+ return eligible.length;
120
+ };
121
+
122
+ try {
123
+ if (opts.once) {
124
+ await pass();
125
+ } else {
126
+ if (!useJson) {
127
+ printInfo(
128
+ `Watching${runId ? ` run ${muted(runId)}` : " all runs"} (every ${interval}ms; Ctrl-C to stop)…`,
129
+ );
130
+ }
131
+ while (!stop) {
132
+ await pass();
133
+ if (opts.untilIdle && store.countActive(runId) === 0) break;
134
+ if (stop) break;
135
+ await sleep(interval);
136
+ }
137
+ }
138
+ } finally {
139
+ process.removeListener("SIGINT", onSigint);
140
+ events.close();
141
+ mail.close();
142
+ store.close();
143
+ }
144
+
145
+ if (useJson) {
146
+ jsonOutput({ runId: runId ?? null, driven: totalDriven, turns: drivenLog });
147
+ return;
148
+ }
149
+ printSuccess(`Watch finished — drove ${totalDriven} turn(s).`);
150
+ });
151
+ }
@@ -92,6 +92,20 @@ describe("validateConfig", () => {
92
92
  cfg.merge.autoMerge = "always";
93
93
  expect(() => validateConfig(cfg)).toThrow();
94
94
  });
95
+
96
+ test("turn timeout + skip defaults are off", () => {
97
+ expect(DEFAULT_CONFIG.agents.turnTimeoutMinutes).toBe(0);
98
+ expect(DEFAULT_CONFIG.agents.skipScout).toBe(false);
99
+ expect(DEFAULT_CONFIG.agents.skipReview).toBe(false);
100
+ expect(DEFAULT_CONFIG.agents.skipGates).toBe(false);
101
+ expect(DEFAULT_CONFIG.agents.skipSkills).toBe(false);
102
+ });
103
+
104
+ test("rejects a negative turnTimeoutMinutes", () => {
105
+ const cfg = structuredClone(DEFAULT_CONFIG);
106
+ cfg.agents.turnTimeoutMinutes = -1;
107
+ expect(() => validateConfig(cfg)).toThrow();
108
+ });
95
109
  });
96
110
 
97
111
  describe("isInitialized / serializeConfig", () => {
package/src/config.ts CHANGED
@@ -43,6 +43,12 @@ export const DEFAULT_CONFIG: AgentplateConfig = {
43
43
  maxDepth: 2,
44
44
  maxAgentsPerLead: 5,
45
45
  idleTimeoutMinutes: 10,
46
+ turnTimeoutMinutes: 0,
47
+ skipScout: false,
48
+ skipReview: false,
49
+ skipGates: false,
50
+ skipSkills: false,
51
+ purgeOnReap: false,
46
52
  },
47
53
  merge: {
48
54
  aiResolveEnabled: true,
@@ -176,6 +182,11 @@ export function validateConfig(config: AgentplateConfig): void {
176
182
  "config.agents.idleTimeoutMinutes must be >= 0 (0 disables idle reaping)",
177
183
  );
178
184
  }
185
+ if (config.agents.turnTimeoutMinutes < 0) {
186
+ throw new ConfigError(
187
+ "config.agents.turnTimeoutMinutes must be >= 0 (0 disables the per-turn cap)",
188
+ );
189
+ }
179
190
  const autoMergeModes: AutoMergeMode[] = ["off", "on-gates-pass", "on-complete"];
180
191
  if (!autoMergeModes.includes(config.merge.autoMerge)) {
181
192
  throw new ConfigError(`config.merge.autoMerge must be one of: ${autoMergeModes.join(", ")}`);
@@ -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
  }
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ import { createStatusCommand } from "./commands/status.ts";
35
35
  import { createStopCommand } from "./commands/stop.ts";
36
36
  import { createTuiCommand } from "./commands/tui.ts";
37
37
  import { createTurnCommand } from "./commands/turn.ts";
38
+ import { createWatchCommand } from "./commands/watch.ts";
38
39
  import { createWorktreeCommand } from "./commands/worktree.ts";
39
40
  import { setProjectRootOverride } from "./config.ts";
40
41
  import { isAgentplateError } from "./errors.ts";
@@ -98,6 +99,7 @@ function buildProgram(): Command {
98
99
  program.addCommand(createCoordinatorCommand());
99
100
  program.addCommand(createSlingCommand());
100
101
  program.addCommand(createTurnCommand());
102
+ program.addCommand(createWatchCommand());
101
103
  program.addCommand(createSpecCommand());
102
104
  program.addCommand(createStatusCommand());
103
105
  program.addCommand(createMailCommand());
@@ -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
  },
@@ -1,15 +1,29 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { ValidationError } from "../errors.ts";
3
- import type { ResolvedModel } from "../types.ts";
3
+ import type { ResolvedModel, RuntimeConfig } from "../types.ts";
4
4
  import { ClaudeRuntime } from "./claude.ts";
5
5
  import { CodexRuntime } from "./codex.ts";
6
6
  import { CursorRuntime } from "./cursor.ts";
7
7
  import { GeminiRuntime } from "./gemini.ts";
8
8
  import { MockRuntime } from "./mock.ts";
9
9
  import { OpenCodeRuntime, opencodeModel } from "./opencode.ts";
10
- import { getRuntime, getRuntimeNames } from "./registry.ts";
10
+ import { getRuntime, getRuntimeNames, runtimeNameForCapability } from "./registry.ts";
11
11
  import type { AgentEvent, DirectSpawnOpts } from "./types.ts";
12
12
 
13
+ describe("runtimeNameForCapability", () => {
14
+ const rt: RuntimeConfig = { default: "claude", capabilities: { scout: "codex" } };
15
+ test("explicit override wins over everything", () => {
16
+ expect(runtimeNameForCapability(rt, "scout", "gemini")).toBe("gemini");
17
+ });
18
+ test("per-capability override applies when set", () => {
19
+ expect(runtimeNameForCapability(rt, "scout")).toBe("codex");
20
+ });
21
+ test("falls back to the default runtime", () => {
22
+ expect(runtimeNameForCapability(rt, "builder")).toBe("claude");
23
+ expect(runtimeNameForCapability({ default: "opencode" }, "scout")).toBe("opencode");
24
+ });
25
+ });
26
+
13
27
  // Minimal DirectSpawnOpts builder for argv-shape assertions. `cwd` and
14
28
  // `instructionPath` are required by the type but irrelevant to argv here.
15
29
  function spawnOpts(overrides: Partial<DirectSpawnOpts> = {}): DirectSpawnOpts {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { ValidationError } from "../errors.ts";
11
+ import type { Capability, RuntimeConfig } from "../types.ts";
11
12
  import { ClaudeRuntime } from "./claude.ts";
12
13
  import { CodexRuntime } from "./codex.ts";
13
14
  import { CursorRuntime } from "./cursor.ts";
@@ -54,6 +55,18 @@ export function getRuntime(name?: string, fallback?: string): AgentRuntime {
54
55
  return factory();
55
56
  }
56
57
 
58
+ /**
59
+ * Resolve which runtime adapter drives a given capability: an explicit `override`
60
+ * wins, then a per-capability entry in `runtime.capabilities`, then the default.
61
+ */
62
+ export function runtimeNameForCapability(
63
+ runtime: RuntimeConfig,
64
+ capability: Capability,
65
+ override?: string,
66
+ ): string {
67
+ return override ?? runtime.capabilities?.[capability] ?? runtime.default;
68
+ }
69
+
57
70
  /**
58
71
  * Names of all registered runtimes, in registration order. Used to validate a
59
72
  * user-supplied runtime name and to render the choices in help / errors.
@@ -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
  })