@agentplate/cli 1.0.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Host system metrics for the System Monitor screen + bottom status bar.
3
+ *
4
+ * Reads real CPU / memory / disk / uptime of the machine running `agentplate
5
+ * serve` via Node's `os` module and a `df` probe (best-effort; degrades to nulls
6
+ * if a probe fails). CPU load is approximated from the 1-minute load average
7
+ * over the core count — cheap and dependency-free.
8
+ */
9
+
10
+ import { cpus, freemem, hostname, loadavg, totalmem, uptime } from "node:os";
11
+
12
+ export interface SystemMetrics {
13
+ cpu: {
14
+ cores: number;
15
+ /** 0..100 utilization estimate from 1-min load average / cores. */
16
+ percent: number;
17
+ loadAvg: [number, number, number];
18
+ };
19
+ memory: {
20
+ usedBytes: number;
21
+ totalBytes: number;
22
+ percent: number;
23
+ };
24
+ disk: {
25
+ usedBytes: number | null;
26
+ totalBytes: number | null;
27
+ percent: number | null;
28
+ };
29
+ uptimeSeconds: number;
30
+ hostname: string;
31
+ platform: string;
32
+ }
33
+
34
+ /** Probe disk usage of the root filesystem via `df -k /` (best-effort). */
35
+ async function diskUsage(): Promise<SystemMetrics["disk"]> {
36
+ // `df` is Unix-only; on Windows report unknown rather than erroring (the UI
37
+ // already renders "—" for null disk metrics).
38
+ if (process.platform === "win32") {
39
+ return { usedBytes: null, totalBytes: null, percent: null };
40
+ }
41
+ try {
42
+ const proc = Bun.spawn(["df", "-k", "/"], { stdout: "pipe", stderr: "pipe" });
43
+ const out = await new Response(proc.stdout).text();
44
+ await proc.exited;
45
+ // Second line: Filesystem 1K-blocks Used Available Capacity ... Mounted
46
+ const line = out.trim().split("\n")[1] ?? "";
47
+ const cols = line.split(/\s+/);
48
+ const totalKb = Number(cols[1]);
49
+ const usedKb = Number(cols[2]);
50
+ if (!Number.isFinite(totalKb) || !Number.isFinite(usedKb) || totalKb <= 0) {
51
+ return { usedBytes: null, totalBytes: null, percent: null };
52
+ }
53
+ const totalBytes = totalKb * 1024;
54
+ const usedBytes = usedKb * 1024;
55
+ return { usedBytes, totalBytes, percent: Math.round((usedBytes / totalBytes) * 100) };
56
+ } catch {
57
+ return { usedBytes: null, totalBytes: null, percent: null };
58
+ }
59
+ }
60
+
61
+ /** Collect a full system-metrics snapshot. */
62
+ export async function collectSystemMetrics(): Promise<SystemMetrics> {
63
+ const cores = cpus().length || 1;
64
+ const la = loadavg();
65
+ const load1 = la[0] ?? 0;
66
+ const cpuPercent = Math.min(100, Math.round((load1 / cores) * 100));
67
+
68
+ const total = totalmem();
69
+ const free = freemem();
70
+ const usedMem = total - free;
71
+
72
+ const disk = await diskUsage();
73
+
74
+ return {
75
+ cpu: {
76
+ cores,
77
+ percent: cpuPercent,
78
+ loadAvg: [la[0] ?? 0, la[1] ?? 0, la[2] ?? 0],
79
+ },
80
+ memory: {
81
+ usedBytes: usedMem,
82
+ totalBytes: total,
83
+ percent: total > 0 ? Math.round((usedMem / total) * 100) : 0,
84
+ },
85
+ disk,
86
+ uptimeSeconds: Math.round(uptime()),
87
+ hostname: hostname(),
88
+ platform: process.platform,
89
+ };
90
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Weather widget data via Open-Meteo (free, no API key).
3
+ *
4
+ * Geocodes a location name → lat/lon, then fetches current conditions + a 3-day
5
+ * forecast. Best-effort: on any network/parse failure returns `available:false`
6
+ * so the UI can hide or stub the widget. Results are cached briefly to avoid
7
+ * hammering the API from the 5s snapshot loop (the widget is polled, not pushed).
8
+ */
9
+
10
+ const DEFAULT_LOCATION = process.env.AGENTPLATE_WEATHER_LOCATION || "Madrid";
11
+
12
+ export interface WeatherDay {
13
+ date: string;
14
+ tempMax: number;
15
+ tempMin: number;
16
+ code: number;
17
+ }
18
+
19
+ export interface Weather {
20
+ available: boolean;
21
+ location: string;
22
+ tempC: number | null;
23
+ feelsLikeC: number | null;
24
+ humidity: number | null;
25
+ windKmh: number | null;
26
+ code: number | null;
27
+ description: string;
28
+ forecast: WeatherDay[];
29
+ }
30
+
31
+ const WMO: Record<number, string> = {
32
+ 0: "Clear sky",
33
+ 1: "Mainly clear",
34
+ 2: "Partly cloudy",
35
+ 3: "Overcast",
36
+ 45: "Fog",
37
+ 48: "Rime fog",
38
+ 51: "Light drizzle",
39
+ 61: "Light rain",
40
+ 63: "Rain",
41
+ 65: "Heavy rain",
42
+ 71: "Light snow",
43
+ 73: "Snow",
44
+ 80: "Rain showers",
45
+ 95: "Thunderstorm",
46
+ };
47
+
48
+ function describe(code: number | null): string {
49
+ return code != null ? (WMO[code] ?? "—") : "—";
50
+ }
51
+
52
+ interface CacheEntry {
53
+ at: number;
54
+ value: Weather;
55
+ }
56
+ const cache = new Map<string, CacheEntry>();
57
+ const TTL_MS = 10 * 60 * 1000; // 10 minutes
58
+
59
+ const empty = (location: string): Weather => ({
60
+ available: false,
61
+ location,
62
+ tempC: null,
63
+ feelsLikeC: null,
64
+ humidity: null,
65
+ windKmh: null,
66
+ code: null,
67
+ description: "—",
68
+ forecast: [],
69
+ });
70
+
71
+ /** Fetch current weather + forecast for a location (cached ~10 min). */
72
+ export async function fetchWeather(loc?: string | null): Promise<Weather> {
73
+ const location = loc?.trim() || DEFAULT_LOCATION;
74
+ const cached = cache.get(location);
75
+ if (cached && Date.now() - cached.at < TTL_MS) return cached.value;
76
+
77
+ try {
78
+ // 1. Geocode.
79
+ const geoRes = await fetch(
80
+ `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`,
81
+ );
82
+ const geo = (await geoRes.json()) as {
83
+ results?: Array<{ latitude: number; longitude: number; name: string }>;
84
+ };
85
+ const place = geo.results?.[0];
86
+ if (!place) {
87
+ const v = empty(location);
88
+ cache.set(location, { at: Date.now(), value: v });
89
+ return v;
90
+ }
91
+
92
+ // 2. Current + daily forecast.
93
+ const wxRes = await fetch(
94
+ `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}` +
95
+ `&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m` +
96
+ `&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=3&timezone=auto`,
97
+ );
98
+ const wx = (await wxRes.json()) as {
99
+ current?: {
100
+ temperature_2m: number;
101
+ apparent_temperature: number;
102
+ relative_humidity_2m: number;
103
+ weather_code: number;
104
+ wind_speed_10m: number;
105
+ };
106
+ daily?: {
107
+ time: string[];
108
+ weather_code: number[];
109
+ temperature_2m_max: number[];
110
+ temperature_2m_min: number[];
111
+ };
112
+ };
113
+ const cur = wx.current;
114
+ const daily = wx.daily;
115
+ const forecast: WeatherDay[] = daily
116
+ ? daily.time.map((date, i) => ({
117
+ date,
118
+ tempMax: Math.round(daily.temperature_2m_max[i] ?? 0),
119
+ tempMin: Math.round(daily.temperature_2m_min[i] ?? 0),
120
+ code: daily.weather_code[i] ?? 0,
121
+ }))
122
+ : [];
123
+
124
+ const value: Weather = {
125
+ available: Boolean(cur),
126
+ location: place.name,
127
+ tempC: cur ? Math.round(cur.temperature_2m) : null,
128
+ feelsLikeC: cur ? Math.round(cur.apparent_temperature) : null,
129
+ humidity: cur ? Math.round(cur.relative_humidity_2m) : null,
130
+ windKmh: cur ? Math.round(cur.wind_speed_10m) : null,
131
+ code: cur ? cur.weather_code : null,
132
+ description: describe(cur?.weather_code ?? null),
133
+ forecast,
134
+ };
135
+ cache.set(location, { at: Date.now(), value });
136
+ return value;
137
+ } catch {
138
+ return empty(location);
139
+ }
140
+ }
@@ -0,0 +1,162 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { sessionsDbPath } from "../paths.ts";
6
+ import type { AgentSession } from "../types.ts";
7
+ import { reapIdleSessions, selectIdleSessions } from "./reaper.ts";
8
+ import { createSessionStore } from "./store.ts";
9
+
10
+ const NOW = Date.parse("2026-06-01T12:00:00.000Z");
11
+ const IDLE_MS = 10 * 60_000;
12
+
13
+ /** ISO timestamp `n` minutes before NOW. */
14
+ function minsAgo(n: number): string {
15
+ return new Date(NOW - n * 60_000).toISOString();
16
+ }
17
+
18
+ function mk(overrides: Partial<AgentSession>): AgentSession {
19
+ return {
20
+ id: `s-${overrides.agentName ?? "a"}`,
21
+ agentName: "a",
22
+ capability: "builder",
23
+ taskId: "t",
24
+ runId: "r1",
25
+ // Not under .agentplate/worktrees, so the removable-worktree guard skips git.
26
+ worktreePath: "/tmp/not-managed/a",
27
+ branchName: "agentplate/a",
28
+ state: "idle",
29
+ parentAgent: null,
30
+ depth: 1,
31
+ pid: null,
32
+ runtimeSessionId: null,
33
+ startedAt: minsAgo(60),
34
+ lastActivity: minsAgo(60),
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("selectIdleSessions", () => {
40
+ test("reaps an active session idle past the window", () => {
41
+ const out = selectIdleSessions([mk({ agentName: "old", lastActivity: minsAgo(11) })], {
42
+ idleMs: IDLE_MS,
43
+ now: NOW,
44
+ });
45
+ expect(out.map((s) => s.agentName)).toEqual(["old"]);
46
+ });
47
+
48
+ test("keeps a session active within the window", () => {
49
+ const out = selectIdleSessions([mk({ agentName: "fresh", lastActivity: minsAgo(5) })], {
50
+ idleMs: IDLE_MS,
51
+ now: NOW,
52
+ });
53
+ expect(out).toEqual([]);
54
+ });
55
+
56
+ test("reaps exactly at the boundary", () => {
57
+ const out = selectIdleSessions([mk({ agentName: "edge", lastActivity: minsAgo(10) })], {
58
+ idleMs: IDLE_MS,
59
+ now: NOW,
60
+ });
61
+ expect(out.map((s) => s.agentName)).toEqual(["edge"]);
62
+ });
63
+
64
+ test("reaps stalled working/booting sessions too (no activity = idle)", () => {
65
+ const sessions = [
66
+ mk({ agentName: "hung", state: "working", lastActivity: minsAgo(20) }),
67
+ mk({ agentName: "boot", state: "booting", lastActivity: minsAgo(20) }),
68
+ ];
69
+ expect(
70
+ selectIdleSessions(sessions, { idleMs: IDLE_MS, now: NOW })
71
+ .map((s) => s.agentName)
72
+ .sort(),
73
+ ).toEqual(["boot", "hung"]);
74
+ });
75
+
76
+ test("skips terminal states", () => {
77
+ const sessions = [
78
+ mk({ agentName: "done", state: "completed", lastActivity: minsAgo(60) }),
79
+ mk({ agentName: "failed", state: "failed", lastActivity: minsAgo(60) }),
80
+ mk({ agentName: "stopped", state: "stopped", lastActivity: minsAgo(60) }),
81
+ ];
82
+ expect(selectIdleSessions(sessions, { idleMs: IDLE_MS, now: NOW })).toEqual([]);
83
+ });
84
+
85
+ test("excludes the coordinator by default", () => {
86
+ const coord = mk({
87
+ agentName: "coordinator",
88
+ capability: "coordinator",
89
+ state: "working",
90
+ lastActivity: minsAgo(60),
91
+ });
92
+ expect(selectIdleSessions([coord], { idleMs: IDLE_MS, now: NOW })).toEqual([]);
93
+ });
94
+
95
+ test("honors a custom exclude list", () => {
96
+ const lead = mk({ agentName: "l", capability: "lead", lastActivity: minsAgo(60) });
97
+ expect(
98
+ selectIdleSessions([lead], { idleMs: IDLE_MS, now: NOW, excludeCapabilities: ["lead"] }),
99
+ ).toEqual([]);
100
+ });
101
+
102
+ test("skips sessions with an unparseable lastActivity", () => {
103
+ const bad = mk({ agentName: "bad", lastActivity: "not-a-date" });
104
+ expect(selectIdleSessions([bad], { idleMs: IDLE_MS, now: NOW })).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe("reapIdleSessions", () => {
109
+ let root: string;
110
+ beforeEach(() => {
111
+ root = mkdtempSync(join(tmpdir(), "ap-reap-"));
112
+ });
113
+ afterEach(() => {
114
+ rmSync(root, { recursive: true, force: true });
115
+ });
116
+
117
+ test("marks idle sessions stopped, leaves fresh ones, and reports what it reaped", async () => {
118
+ const store = createSessionStore(sessionsDbPath(root));
119
+ try {
120
+ const run = store.createRun("r");
121
+ store.upsertSession(
122
+ mk({ id: "s-old", agentName: "old", runId: run.id, lastActivity: minsAgo(30) }),
123
+ );
124
+ store.upsertSession(
125
+ mk({ id: "s-fresh", agentName: "fresh", runId: run.id, lastActivity: minsAgo(2) }),
126
+ );
127
+
128
+ const reaped = await reapIdleSessions(store, root, { idleMs: IDLE_MS, now: NOW });
129
+
130
+ expect(reaped.map((r) => r.agentName)).toEqual(["old"]);
131
+ expect(reaped[0]?.idleMs).toBe(30 * 60_000);
132
+ // `old` is now stopped; `fresh` keeps running.
133
+ expect(store.getSessionByAgent("old")?.state).toBe("stopped");
134
+ expect(store.getSessionByAgent("fresh")?.state).toBe("idle");
135
+ } finally {
136
+ store.close();
137
+ }
138
+ });
139
+
140
+ test("never touches the coordinator even when long-idle", async () => {
141
+ const store = createSessionStore(sessionsDbPath(root));
142
+ try {
143
+ const run = store.createRun("r");
144
+ store.upsertSession(
145
+ mk({
146
+ id: "s-coord",
147
+ agentName: "coordinator",
148
+ capability: "coordinator",
149
+ state: "working",
150
+ worktreePath: root, // coordinator runs at the project root
151
+ runId: run.id,
152
+ lastActivity: minsAgo(120),
153
+ }),
154
+ );
155
+ const reaped = await reapIdleSessions(store, root, { idleMs: IDLE_MS, now: NOW });
156
+ expect(reaped).toEqual([]);
157
+ expect(store.getSessionByAgent("coordinator")?.state).toBe("working");
158
+ } finally {
159
+ store.close();
160
+ }
161
+ });
162
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Idle-session reaper — terminate agents that have gone quiet.
3
+ *
4
+ * Agentplate workers run spawn-per-turn: between turns a worker sits `idle` with
5
+ * no live process, waiting for mail that may never come. A stalled turn can also
6
+ * leave a session stuck `working`. Either way, an agent with no activity for the
7
+ * configured idle window is dead weight — it counts toward the concurrency cap and
8
+ * clutters the UI. This module reaps them: mark the session `stopped`, kill any
9
+ * live pid, and (by default) remove its worktree + branch.
10
+ *
11
+ * "Activity" is the session's `last_activity`, which `sling` bumps on every
12
+ * streamed runtime event, so an agent actively working a long turn keeps itself
13
+ * fresh and is never reaped — only genuinely idle/stalled agents are. The
14
+ * coordinator is excluded by default (it's a long-lived interactive session whose
15
+ * worktree IS the project root, and it does not stream per-turn events).
16
+ */
17
+
18
+ import { worktreesDir } from "../paths.ts";
19
+ import type { AgentSession, SessionState } from "../types.ts";
20
+ import { deleteBranch, removeWorktree, worktreeExists } from "../worktree/manager.ts";
21
+ import type { SessionStore } from "./store.ts";
22
+
23
+ /** States that can be reaped — the pre-terminal (still-counted) ones. */
24
+ const REAPABLE_STATES: readonly SessionState[] = ["booting", "working", "idle"];
25
+
26
+ /** Capabilities never reaped (the coordinator runs at the project root). */
27
+ const DEFAULT_EXCLUDE: readonly string[] = ["coordinator"];
28
+
29
+ export interface SelectIdleOptions {
30
+ /** Idle window in milliseconds: reap sessions with no activity for this long. */
31
+ idleMs: number;
32
+ /** Reference "now" in epoch ms (injectable for tests). */
33
+ now: number;
34
+ /** Capabilities to skip (defaults to `["coordinator"]`). */
35
+ excludeCapabilities?: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * Pure selector: which sessions are idle past the window. Kept side-effect-free so
40
+ * the reap policy is trivially unit-testable without a store or git.
41
+ */
42
+ export function selectIdleSessions(
43
+ sessions: readonly AgentSession[],
44
+ opts: SelectIdleOptions,
45
+ ): AgentSession[] {
46
+ const exclude = new Set(opts.excludeCapabilities ?? DEFAULT_EXCLUDE);
47
+ return sessions.filter((s) => {
48
+ if (!REAPABLE_STATES.includes(s.state)) return false;
49
+ if (exclude.has(s.capability)) return false;
50
+ const last = Date.parse(s.lastActivity);
51
+ if (Number.isNaN(last)) return false;
52
+ return opts.now - last >= opts.idleMs;
53
+ });
54
+ }
55
+
56
+ /** One reaped agent, returned for logging / reporting. */
57
+ export interface ReapedAgent {
58
+ id: string;
59
+ agentName: string;
60
+ capability: string;
61
+ /** How long it had been idle, in ms. */
62
+ idleMs: number;
63
+ /** Whether its worktree was removed (false if kept, missing, or removal failed). */
64
+ worktreeRemoved: boolean;
65
+ }
66
+
67
+ export interface ReapOptions {
68
+ /** Idle window in milliseconds. */
69
+ idleMs: number;
70
+ /** Reference "now" in epoch ms (defaults to `Date.now()`). */
71
+ now?: number;
72
+ /** Remove the reaped agent's worktree + branch (default true). */
73
+ removeWorktrees?: boolean;
74
+ /** Capabilities to skip (defaults to `["coordinator"]`). */
75
+ excludeCapabilities?: readonly string[];
76
+ }
77
+
78
+ /**
79
+ * Reap idle sessions: kill any live pid, optionally remove the worktree + branch,
80
+ * and mark the session `stopped`. Every side effect is best-effort and isolated
81
+ * per session — a failure to kill a process or remove a worktree still marks the
82
+ * session stopped and never aborts the sweep. Returns the agents that were reaped.
83
+ */
84
+ export async function reapIdleSessions(
85
+ store: SessionStore,
86
+ root: string,
87
+ opts: ReapOptions,
88
+ ): Promise<ReapedAgent[]> {
89
+ const now = opts.now ?? Date.now();
90
+ const removeWorktrees = opts.removeWorktrees !== false;
91
+ const stale = selectIdleSessions(store.listSessions(), {
92
+ idleMs: opts.idleMs,
93
+ now,
94
+ excludeCapabilities: opts.excludeCapabilities,
95
+ });
96
+
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.
105
+ }
106
+ }
107
+
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 });
114
+ }
115
+ worktreeRemoved = true;
116
+ // Branch delete is separate and best-effort (no-op if already gone).
117
+ try {
118
+ await deleteBranch(root, s.branchName);
119
+ } catch {
120
+ // Branch may be merged away or shared — leave it.
121
+ }
122
+ } catch {
123
+ worktreeRemoved = false;
124
+ }
125
+ }
126
+
127
+ // 3. Mark the session terminated so it stops counting and shows as stopped.
128
+ store.updateSessionState(s.id, "stopped");
129
+
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
+ });
137
+ }
138
+ return reaped;
139
+ }
140
+
141
+ /**
142
+ * Only worktrees under `.agentplate/worktrees/` are removable. This refuses to
143
+ * touch the project root (the coordinator's `worktreePath`) or any path outside
144
+ * the managed worktrees dir — a hard safety net beyond the capability exclusion.
145
+ */
146
+ function isRemovableWorktree(root: string, worktreePath: string): boolean {
147
+ if (!worktreePath || worktreePath === root) return false;
148
+ return worktreePath.startsWith(worktreesDir(root));
149
+ }