@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,667 @@
1
+ /**
2
+ * REST API surface for the web UI / TUI.
3
+ *
4
+ * Read-only JSON handlers over the existing SQLite stores (sessions, events,
5
+ * mail, skills, deploy audit). No new persistence — the surfaces render the same
6
+ * state the CLI reads. Every handler returns a plain JSON-serializable value;
7
+ * the server (serve.ts) wraps it in the standard envelope.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { loadConfig } from "../config.ts";
13
+ import { createDeployAudit } from "../deploy/audit.ts";
14
+ import { getAllDeployTargets } from "../deploy/registry.ts";
15
+ import { ValidationError } from "../errors.ts";
16
+ import { createEventStore } from "../events/store.ts";
17
+ import { createMailClient } from "../mail/client.ts";
18
+ import { currentRunPath, deploysDbPath, eventsDbPath, sessionsDbPath } from "../paths.ts";
19
+ import { createSessionStore } from "../sessions/store.ts";
20
+ import { createSkillStore } from "../skills/store.ts";
21
+
22
+ export interface ApiContext {
23
+ root: string;
24
+ }
25
+
26
+ /** A single REST route: method + path matcher + handler returning JSON data. */
27
+ export interface ApiRoute {
28
+ method: "GET";
29
+ /** Path pattern, e.g. "/api/agents" or "/api/agents/:name". */
30
+ pattern: string;
31
+ handler: (ctx: ApiContext, params: Record<string, string>, query: URLSearchParams) => unknown;
32
+ }
33
+
34
+ /**
35
+ * The active run every read surface agrees on: the id written to
36
+ * `.agentplate/current-run.txt` by `coordinator start` / `sling` (authoritative),
37
+ * falling back to the newest run in the store when the file is missing or stale.
38
+ *
39
+ * Centralizing this keeps the web UI, WS snapshot, and TUI all showing the SAME
40
+ * run's agents — previously `/api/agents` returned every run's sessions while the
41
+ * dashboard/TUI/overview used only the newest run, so the surfaces disagreed.
42
+ */
43
+ export function resolveCurrentRunId(
44
+ store: ReturnType<typeof createSessionStore>,
45
+ root: string,
46
+ ): string | null {
47
+ const path = currentRunPath(root);
48
+ if (existsSync(path)) {
49
+ const id = readFileSync(path, "utf8").trim();
50
+ if (id && store.getRun(id)) return id;
51
+ }
52
+ const latest = store.listRuns(1)[0];
53
+ return latest ? latest.id : null;
54
+ }
55
+
56
+ /** Project + config summary for the overview screen. */
57
+ function overview(ctx: ApiContext): unknown {
58
+ const config = loadConfig(ctx.root);
59
+ const store = createSessionStore(sessionsDbPath(ctx.root));
60
+ try {
61
+ const currentRunId = resolveCurrentRunId(store, ctx.root);
62
+ const currentRun = currentRunId ? store.getRun(currentRunId) : null;
63
+ const sessions = currentRunId ? store.listSessions({ runId: currentRunId }) : [];
64
+ const provider = config.providers[config.activeProvider];
65
+ return {
66
+ project: config.project.name,
67
+ runtime: config.runtime.default,
68
+ provider: config.activeProvider,
69
+ model: provider?.model ?? null,
70
+ deployTarget: config.deploy.default || null,
71
+ currentRun,
72
+ agentCount: sessions.length,
73
+ activeCount: sessions.filter((s) => s.state === "working").length,
74
+ };
75
+ } finally {
76
+ store.close();
77
+ }
78
+ }
79
+
80
+ function runs(ctx: ApiContext): unknown {
81
+ const store = createSessionStore(sessionsDbPath(ctx.root));
82
+ try {
83
+ return store.listRuns(50);
84
+ } finally {
85
+ store.close();
86
+ }
87
+ }
88
+
89
+ function agents(ctx: ApiContext, _params: Record<string, string>, query: URLSearchParams): unknown {
90
+ const store = createSessionStore(sessionsDbPath(ctx.root));
91
+ try {
92
+ // `?all=1` returns every run's sessions; otherwise scope to a specific
93
+ // `?run=<id>` or, by default, the active run — so the live view matches the
94
+ // dashboard/TUI instead of accumulating agents from every past run.
95
+ if (query.get("all") === "1") return store.listSessions();
96
+ const runId = query.get("run") ?? resolveCurrentRunId(store, ctx.root);
97
+ return runId ? store.listSessions({ runId }) : [];
98
+ } finally {
99
+ store.close();
100
+ }
101
+ }
102
+
103
+ function agentDetail(ctx: ApiContext, params: Record<string, string>): unknown {
104
+ const store = createSessionStore(sessionsDbPath(ctx.root));
105
+ const events = createEventStore(eventsDbPath(ctx.root));
106
+ const mailClient = createMailClient(ctx.root);
107
+ try {
108
+ const name = params.name ?? "";
109
+ const session = store.getSessionByAgent(name);
110
+ const recentEvents = events.list({ agentName: name, limit: 50 });
111
+ // Mail this agent sent and received — its handoff conversation.
112
+ const inbox = mailClient.list({ to: name, limit: 50 });
113
+ const sent = mailClient.list({ from: name, limit: 50 });
114
+ // Children spawned by this agent (for the hierarchy view).
115
+ const children = store.listSessions().filter((s) => s.parentAgent === name);
116
+ return { session, events: recentEvents, inbox, sent, children };
117
+ } finally {
118
+ mailClient.close();
119
+ events.close();
120
+ store.close();
121
+ }
122
+ }
123
+
124
+ function events(ctx: ApiContext, _params: Record<string, string>, query: URLSearchParams): unknown {
125
+ const store = createEventStore(eventsDbPath(ctx.root));
126
+ try {
127
+ const limit = Number(query.get("limit") ?? "100");
128
+ const agentName = query.get("agent") ?? undefined;
129
+ return store.list({ agentName, limit });
130
+ } finally {
131
+ store.close();
132
+ }
133
+ }
134
+
135
+ function mail(ctx: ApiContext, _params: Record<string, string>, query: URLSearchParams): unknown {
136
+ const client = createMailClient(ctx.root);
137
+ try {
138
+ const to = query.get("to") ?? undefined;
139
+ const from = query.get("from") ?? undefined;
140
+ return client.list({ to, from, limit: 100 });
141
+ } finally {
142
+ client.close();
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Protocol message types that represent an agent-to-agent HANDOFF (work moving
148
+ * between roles), as opposed to general chatter. Surfaced on the Handoffs tab.
149
+ */
150
+ const HANDOFF_TYPES = new Set<string>([
151
+ "dispatch",
152
+ "assign",
153
+ "worker_done",
154
+ "worker_died",
155
+ "merge_ready",
156
+ "merged",
157
+ "merge_failed",
158
+ "escalation",
159
+ "pipeline_ready",
160
+ "deploy_gate",
161
+ "deploy_done",
162
+ "deploy_failed",
163
+ "verify_done",
164
+ "verify_failed",
165
+ ]);
166
+
167
+ /** GET /api/handoffs — protocol mail representing work handed between agents. */
168
+ function handoffs(
169
+ ctx: ApiContext,
170
+ _params: Record<string, string>,
171
+ query: URLSearchParams,
172
+ ): unknown {
173
+ const limit = Number(query.get("limit") ?? "100");
174
+ const client = createMailClient(ctx.root);
175
+ try {
176
+ return client
177
+ .list({ limit: 300 })
178
+ .filter((m) => HANDOFF_TYPES.has(m.type))
179
+ .slice(0, limit)
180
+ .map((m) => ({
181
+ id: m.id,
182
+ from: m.from,
183
+ to: m.to,
184
+ type: m.type,
185
+ subject: m.subject,
186
+ body: m.body,
187
+ threadId: m.threadId,
188
+ createdAt: m.createdAt,
189
+ }));
190
+ } finally {
191
+ client.close();
192
+ }
193
+ }
194
+
195
+ function skills(ctx: ApiContext): unknown {
196
+ const store = createSkillStore(ctx.root);
197
+ try {
198
+ return store.list().map((s) => ({
199
+ slug: s.slug,
200
+ title: s.title,
201
+ goal: s.goal,
202
+ status: s.status,
203
+ confidence: s.confidence,
204
+ appliedCount: s.appliedCount,
205
+ successCount: s.successCount,
206
+ version: s.version,
207
+ }));
208
+ } finally {
209
+ store.close();
210
+ }
211
+ }
212
+
213
+ function deployTargets(): unknown {
214
+ return getAllDeployTargets().map((t) => ({
215
+ id: t.id,
216
+ label: t.label,
217
+ description: t.description,
218
+ stability: t.stability,
219
+ caps: t.caps,
220
+ }));
221
+ }
222
+
223
+ function deployHistory(ctx: ApiContext): unknown {
224
+ const audit = createDeployAudit(deploysDbPath(ctx.root));
225
+ try {
226
+ return audit.list({ limit: 100 });
227
+ } finally {
228
+ audit.close();
229
+ }
230
+ }
231
+
232
+ /** Severity of a feed line, used for coloring (mirrors the terminal dashboard). */
233
+ export type FeedLevel = "info" | "warn" | "error";
234
+
235
+ /** A single entry in the live feed (an agent event or a mail message). */
236
+ export interface FeedItem {
237
+ kind: "event" | "mail";
238
+ ts: string;
239
+ agent: string;
240
+ /** Compact 5-char event label (e.g. "TOOL+", "MAIL>", "SESS-", "ERROR"). */
241
+ label: string;
242
+ level: FeedLevel;
243
+ summary: string;
244
+ detail?: string;
245
+ }
246
+
247
+ /**
248
+ * Map a raw event/mail type to a compact 5-char label + level, mirroring the
249
+ * original agentplate terminal feed (TOOL+/TOOL-, SESS+/SESS-, MAIL>/MAIL<, …).
250
+ */
251
+ function eventLabel(type: string): { compact: string; level: FeedLevel } {
252
+ const t = type.toLowerCase();
253
+ const table: Record<string, { compact: string; level: FeedLevel }> = {
254
+ "tool-start": { compact: "TOOL+", level: "info" },
255
+ tool_use: { compact: "TOOL+", level: "info" },
256
+ "tool-end": { compact: "TOOL-", level: "info" },
257
+ tool_result: { compact: "TOOL-", level: "info" },
258
+ "session-start": { compact: "SESS+", level: "info" },
259
+ "session-end": { compact: "SESS-", level: "warn" },
260
+ assistant: { compact: "MSG ", level: "info" },
261
+ result: { compact: "RSULT", level: "info" },
262
+ error: { compact: "ERROR", level: "error" },
263
+ spawn: { compact: "SPAWN", level: "info" },
264
+ };
265
+ return table[t] ?? { compact: type.slice(0, 5).toUpperCase().padEnd(5), level: "info" };
266
+ }
267
+
268
+ /** Map a mail protocol type to a label + level. */
269
+ function mailLabel(type: string): { compact: string; level: FeedLevel } {
270
+ const t = type.toLowerCase();
271
+ if (t.endsWith("_failed") || t === "error" || t === "worker_died")
272
+ return { compact: "FAIL<", level: "error" };
273
+ if (t === "escalation" || t === "deploy_gate") return { compact: "ESCL!", level: "warn" };
274
+ if (t === "worker_done" || t === "merged" || t === "merge_ready" || t.endsWith("_done"))
275
+ return { compact: "DONE>", level: "info" };
276
+ if (t === "dispatch" || t === "assign") return { compact: "DISP>", level: "info" };
277
+ return { compact: "MAIL>", level: "info" };
278
+ }
279
+
280
+ /**
281
+ * Unified live feed: recent agent events (tool calls, lifecycle) + mail between
282
+ * agents, merged newest-first. Drives the UI's live activity stream. Each item
283
+ * carries a compact label + level so the UI can render a terminal-style stream.
284
+ */
285
+ export function buildFeed(ctx: ApiContext, limit = 60): FeedItem[] {
286
+ const items: FeedItem[] = [];
287
+ const eventStore = createEventStore(eventsDbPath(ctx.root));
288
+ try {
289
+ for (const e of eventStore.list({ limit })) {
290
+ const { compact, level } = eventLabel(e.type);
291
+ const detail = [e.tool ? `tool=${e.tool}` : "", e.detail ?? ""].filter(Boolean).join(" ");
292
+ items.push({
293
+ kind: "event",
294
+ ts: e.createdAt,
295
+ agent: e.agentName,
296
+ label: compact,
297
+ level,
298
+ summary: e.tool ? `${e.type} ${e.tool}` : e.type,
299
+ ...(detail ? { detail } : {}),
300
+ });
301
+ }
302
+ } finally {
303
+ eventStore.close();
304
+ }
305
+ const mailClient = createMailClient(ctx.root);
306
+ try {
307
+ for (const m of mailClient.list({ limit })) {
308
+ const { compact, level } = mailLabel(m.type);
309
+ items.push({
310
+ kind: "mail",
311
+ ts: m.createdAt,
312
+ agent: m.from,
313
+ label: compact,
314
+ level,
315
+ summary: `${m.from} → ${m.to}: ${m.subject}`,
316
+ ...(m.body ? { detail: m.body } : {}),
317
+ });
318
+ }
319
+ } finally {
320
+ mailClient.close();
321
+ }
322
+ items.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
323
+ return items.slice(0, limit);
324
+ }
325
+
326
+ function feed(ctx: ApiContext, _params: Record<string, string>, query: URLSearchParams): unknown {
327
+ return buildFeed(ctx, Number(query.get("limit") ?? "60"));
328
+ }
329
+
330
+ /** Agents grouped into operator-facing status buckets, with counts. */
331
+ export function buildStatusCounts(ctx: ApiContext): {
332
+ idle: number;
333
+ working: number;
334
+ completed: number;
335
+ stalled: number;
336
+ } {
337
+ const store = createSessionStore(sessionsDbPath(ctx.root));
338
+ try {
339
+ const runId = resolveCurrentRunId(store, ctx.root);
340
+ const sessions = runId ? store.listSessions({ runId }) : [];
341
+ const counts = { idle: 0, working: 0, completed: 0, stalled: 0 };
342
+ for (const s of sessions) {
343
+ if (s.state === "working" || s.state === "booting") counts.working++;
344
+ else if (s.state === "idle") counts.idle++;
345
+ else if (s.state === "completed") counts.completed++;
346
+ else counts.stalled++; // failed | stopped
347
+ }
348
+ return counts;
349
+ } finally {
350
+ store.close();
351
+ }
352
+ }
353
+
354
+ /** Derived status of a task, rolled up from its agent sessions. */
355
+ export type TaskStatus = "pending" | "active" | "done" | "failed";
356
+
357
+ /** A unit of work: a task id with its derived status + the agents on it. */
358
+ export interface TaskItem {
359
+ taskId: string;
360
+ status: TaskStatus;
361
+ /** Capabilities working it (e.g. ["builder","reviewer"]). */
362
+ capabilities: string[];
363
+ /** Agent names assigned to this task. */
364
+ agents: string[];
365
+ agentCount: number;
366
+ /** Most recent activity across the task's sessions, or null. */
367
+ lastActivity: string | null;
368
+ /** First line of the spec, when a spec file exists. */
369
+ summary: string | null;
370
+ }
371
+
372
+ /**
373
+ * Build the task list. A task is identified by its `taskId` and discovered from
374
+ * two sources unioned together: spec files under `.agentplate/specs/` and the
375
+ * distinct `taskId`s across agent sessions. Status rolls up from the sessions:
376
+ * - any session booting/working → "active"
377
+ * - else all completed (≥1 session) → "done"
378
+ * - else any failed/stopped (none active) → "failed"
379
+ * - a spec with no session yet → "pending"
380
+ */
381
+ export function buildTasks(ctx: ApiContext): TaskItem[] {
382
+ const store = createSessionStore(sessionsDbPath(ctx.root));
383
+ const byTask = new Map<string, TaskItem>();
384
+ try {
385
+ // 1. Seed from spec files (so queued-but-unspawned tasks appear).
386
+ const specsDir = join(ctx.root, ".agentplate", "specs");
387
+ if (existsSync(specsDir)) {
388
+ for (const file of readdirSync(specsDir)) {
389
+ if (!file.endsWith(".md")) continue;
390
+ const taskId = file.slice(0, -3);
391
+ const summary = firstSpecLine(join(specsDir, file));
392
+ byTask.set(taskId, {
393
+ taskId,
394
+ status: "pending",
395
+ capabilities: [],
396
+ agents: [],
397
+ agentCount: 0,
398
+ lastActivity: null,
399
+ summary,
400
+ });
401
+ }
402
+ }
403
+
404
+ // 2. Fold in the agent sessions (current run only, like the rest of the UI).
405
+ const runId = resolveCurrentRunId(store, ctx.root);
406
+ const sessions = runId ? store.listSessions({ runId }) : [];
407
+ for (const s of sessions) {
408
+ if (!s.taskId || s.taskId === "coordination") continue;
409
+ const existing = byTask.get(s.taskId) ?? {
410
+ taskId: s.taskId,
411
+ status: "pending" as TaskStatus,
412
+ capabilities: [],
413
+ agents: [],
414
+ agentCount: 0,
415
+ lastActivity: null,
416
+ summary: null,
417
+ };
418
+ if (!existing.agents.includes(s.agentName)) {
419
+ existing.agents.push(s.agentName);
420
+ existing.agentCount = existing.agents.length;
421
+ }
422
+ if (!existing.capabilities.includes(s.capability)) {
423
+ existing.capabilities.push(s.capability);
424
+ }
425
+ if (!existing.lastActivity || s.lastActivity > existing.lastActivity) {
426
+ existing.lastActivity = s.lastActivity;
427
+ }
428
+ byTask.set(s.taskId, existing);
429
+ }
430
+
431
+ // 3. Roll up status from each task's sessions.
432
+ for (const task of byTask.values()) {
433
+ const taskSessions = sessions.filter((s) => s.taskId === task.taskId);
434
+ if (taskSessions.length === 0) continue; // keep "pending" (spec only)
435
+ const anyActive = taskSessions.some(
436
+ (s) => s.state === "working" || s.state === "booting" || s.state === "idle",
437
+ );
438
+ const allDone = taskSessions.every((s) => s.state === "completed");
439
+ const anyFailed = taskSessions.some((s) => s.state === "failed" || s.state === "stopped");
440
+ task.status = anyActive ? "active" : allDone ? "done" : anyFailed ? "failed" : "active";
441
+ }
442
+
443
+ return [...byTask.values()].sort((a, b) => {
444
+ // Active first, then by most recent activity.
445
+ const rank = (t: TaskItem) => (t.status === "active" ? 0 : t.status === "pending" ? 1 : 2);
446
+ if (rank(a) !== rank(b)) return rank(a) - rank(b);
447
+ return (b.lastActivity ?? "") < (a.lastActivity ?? "") ? -1 : 1;
448
+ });
449
+ } finally {
450
+ store.close();
451
+ }
452
+ }
453
+
454
+ /** Read the first non-heading, non-blank line of a spec file (best-effort). */
455
+ function firstSpecLine(path: string): string | null {
456
+ try {
457
+ const text = readFileSync(path, "utf8");
458
+ for (const line of text.split("\n")) {
459
+ const trimmed = line.trim();
460
+ if (trimmed && !trimmed.startsWith("#")) return trimmed.slice(0, 140);
461
+ }
462
+ } catch {
463
+ // best-effort
464
+ }
465
+ return null;
466
+ }
467
+
468
+ function tasks(ctx: ApiContext): unknown {
469
+ return buildTasks(ctx);
470
+ }
471
+
472
+ /** Token + cost analytics rolled up from events (best-effort). */
473
+ export interface CostsReport {
474
+ /** Whether any token/cost data was found (else the UI shows an empty state). */
475
+ hasData: boolean;
476
+ totalTokens: number;
477
+ totalCostUsd: number;
478
+ /** Per-day cost trend (oldest→newest). */
479
+ daily: Array<{ date: string; costUsd: number; tokens: number }>;
480
+ /** Per-agent breakdown. */
481
+ byAgent: Array<{ agent: string; tokens: number; costUsd: number }>;
482
+ }
483
+
484
+ /**
485
+ * Build the costs report by aggregating per-turn token/cost events. Headless
486
+ * turns record the runtime's reported usage (e.g. a Claude `result` event) into
487
+ * the event store as detail JSON (`{ tokens, cost }`); this sums them by agent and
488
+ * by day. When no usage has been recorded yet it returns `hasData:false` and the
489
+ * UI renders the same charts with an empty state.
490
+ */
491
+ export function buildCosts(ctx: ApiContext): CostsReport {
492
+ const store = createEventStore(eventsDbPath(ctx.root));
493
+ const byAgent = new Map<string, { tokens: number; costUsd: number }>();
494
+ const byDay = new Map<string, { tokens: number; costUsd: number }>();
495
+ let totalTokens = 0;
496
+ let totalCostUsd = 0;
497
+ try {
498
+ for (const e of store.list({ limit: 5000 })) {
499
+ let tokens = 0;
500
+ let cost = 0;
501
+ if (e.detail) {
502
+ try {
503
+ const d = JSON.parse(e.detail) as { tokens?: number; cost?: number; costUsd?: number };
504
+ tokens = typeof d.tokens === "number" ? d.tokens : 0;
505
+ cost =
506
+ typeof d.cost === "number" ? d.cost : typeof d.costUsd === "number" ? d.costUsd : 0;
507
+ } catch {
508
+ // detail isn't JSON — no token data here.
509
+ }
510
+ }
511
+ if (tokens === 0 && cost === 0) continue;
512
+ totalTokens += tokens;
513
+ totalCostUsd += cost;
514
+ const a = byAgent.get(e.agentName) ?? { tokens: 0, costUsd: 0 };
515
+ a.tokens += tokens;
516
+ a.costUsd += cost;
517
+ byAgent.set(e.agentName, a);
518
+ const day = e.createdAt.slice(0, 10);
519
+ const dd = byDay.get(day) ?? { tokens: 0, costUsd: 0 };
520
+ dd.tokens += tokens;
521
+ dd.costUsd += cost;
522
+ byDay.set(day, dd);
523
+ }
524
+ } finally {
525
+ store.close();
526
+ }
527
+ return {
528
+ hasData: totalTokens > 0 || totalCostUsd > 0,
529
+ totalTokens,
530
+ totalCostUsd: Math.round(totalCostUsd * 10000) / 10000,
531
+ daily: [...byDay.entries()]
532
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
533
+ .map(([date, v]) => ({ date, costUsd: v.costUsd, tokens: v.tokens })),
534
+ byAgent: [...byAgent.entries()]
535
+ .map(([agent, v]) => ({ agent, tokens: v.tokens, costUsd: v.costUsd }))
536
+ .sort((a, b) => b.costUsd - a.costUsd),
537
+ };
538
+ }
539
+
540
+ function costs(ctx: ApiContext): unknown {
541
+ return buildCosts(ctx);
542
+ }
543
+
544
+ /** The complete REST route table (read-only GET handlers). */
545
+ export const API_ROUTES: ApiRoute[] = [
546
+ { method: "GET", pattern: "/api/overview", handler: overview },
547
+ { method: "GET", pattern: "/api/runs", handler: runs },
548
+ { method: "GET", pattern: "/api/agents", handler: agents },
549
+ { method: "GET", pattern: "/api/agents/:name", handler: agentDetail },
550
+ { method: "GET", pattern: "/api/events", handler: events },
551
+ { method: "GET", pattern: "/api/mail", handler: mail },
552
+ { method: "GET", pattern: "/api/handoffs", handler: handoffs },
553
+ { method: "GET", pattern: "/api/tasks", handler: tasks },
554
+ { method: "GET", pattern: "/api/costs", handler: costs },
555
+ { method: "GET", pattern: "/api/feed", handler: feed },
556
+ { method: "GET", pattern: "/api/skills", handler: skills },
557
+ { method: "GET", pattern: "/api/deploy/targets", handler: deployTargets },
558
+ { method: "GET", pattern: "/api/deploy/history", handler: deployHistory },
559
+ ];
560
+
561
+ /**
562
+ * Match a request path against a route pattern, returning captured params or
563
+ * null when no match. Supports `:param` segments.
564
+ */
565
+ export function matchRoute(pattern: string, path: string): Record<string, string> | null {
566
+ const pSegs = pattern.split("/").filter(Boolean);
567
+ const aSegs = path.split("/").filter(Boolean);
568
+ if (pSegs.length !== aSegs.length) return null;
569
+ const params: Record<string, string> = {};
570
+ for (let i = 0; i < pSegs.length; i++) {
571
+ const p = pSegs[i] ?? "";
572
+ const a = aSegs[i] ?? "";
573
+ if (p.startsWith(":")) {
574
+ params[p.slice(1)] = decodeURIComponent(a);
575
+ } else if (p !== a) {
576
+ return null;
577
+ }
578
+ }
579
+ return params;
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // Write actions (POST) — the interactive surface. Deliberately limited to
584
+ // messaging the coordinator and spawning a worker; no deploy/secrets/rollback.
585
+ // ---------------------------------------------------------------------------
586
+
587
+ /** POST /api/chat — message the coordinator over the mail bus. */
588
+ export function postChat(ctx: ApiContext, body: { message?: unknown }): unknown {
589
+ const message = typeof body.message === "string" ? body.message.trim() : "";
590
+ if (!message) throw new ValidationError("chat requires a non-empty `message`.");
591
+ const client = createMailClient(ctx.root);
592
+ try {
593
+ const sent = client.send({
594
+ from: "operator",
595
+ to: "coordinator",
596
+ subject: message.length > 60 ? `${message.slice(0, 57)}…` : message,
597
+ body: message,
598
+ type: "status",
599
+ });
600
+ return { sent };
601
+ } finally {
602
+ client.close();
603
+ }
604
+ }
605
+
606
+ /**
607
+ * POST /api/tasks — spawn a worker for a task. Runs `agentplate sling` as a
608
+ * detached subprocess so the HTTP request returns immediately (a full agent turn
609
+ * can take a while); the spawn's progress shows up in the live feed + status.
610
+ */
611
+ export function postTask(
612
+ ctx: ApiContext,
613
+ body: { prompt?: unknown; taskId?: unknown; capability?: unknown },
614
+ ): unknown {
615
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
616
+ if (!prompt) throw new ValidationError("task requires a non-empty `prompt`.");
617
+ const capability = typeof body.capability === "string" ? body.capability : "builder";
618
+ const taskId =
619
+ typeof body.taskId === "string" && body.taskId.trim()
620
+ ? body.taskId.trim()
621
+ : `ui-${Date.now().toString(36)}`;
622
+
623
+ // Record the task intent as a spec the worker reads, then sling detached.
624
+ const specDir = join(ctx.root, ".agentplate", "specs");
625
+ mkdirSync(specDir, { recursive: true });
626
+ const specFile = join(specDir, `${taskId}.md`);
627
+ writeFileSync(specFile, `# Task ${taskId}\n\n${prompt}\n`, "utf8");
628
+
629
+ // The spec above is the durable task intent — GET /api/tasks surfaces it as a
630
+ // queued task even with no worker running. The detached sling is best-effort:
631
+ // if the `agentplate` binary isn't resolvable (e.g. not on PATH), log and still
632
+ // accept the task rather than 500-ing after the spec was persisted.
633
+ let spawned = false;
634
+ try {
635
+ const proc = Bun.spawn(
636
+ [
637
+ "agentplate",
638
+ "sling",
639
+ taskId,
640
+ "--capability",
641
+ capability,
642
+ "--spec",
643
+ specFile,
644
+ "--project",
645
+ ctx.root,
646
+ ],
647
+ { cwd: ctx.root, stdout: "ignore", stderr: "ignore", stdin: "ignore" },
648
+ );
649
+ proc.unref();
650
+ spawned = true;
651
+ } catch (error) {
652
+ const message = error instanceof Error ? error.message : String(error);
653
+ console.error(`[agentplate] failed to spawn worker for task ${taskId}: ${message}`);
654
+ }
655
+ return { accepted: true, taskId, capability, spawned };
656
+ }
657
+
658
+ /** Resolve a path to a route + params, or null. */
659
+ export function resolveRoute(
660
+ path: string,
661
+ ): { route: ApiRoute; params: Record<string, string> } | null {
662
+ for (const route of API_ROUTES) {
663
+ const params = matchRoute(route.pattern, path);
664
+ if (params) return { route, params };
665
+ }
666
+ return null;
667
+ }