@desplega.ai/agent-swarm 1.74.4 → 1.76.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 (88) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +1264 -46
  3. package/package.json +2 -2
  4. package/src/be/db.ts +563 -9
  5. package/src/be/memory/edges-store.ts +69 -0
  6. package/src/be/memory/providers/sqlite-store.ts +4 -0
  7. package/src/be/memory/raters/explicit-self.ts +22 -0
  8. package/src/be/memory/raters/implicit-citation.ts +44 -0
  9. package/src/be/memory/raters/llm-client.ts +172 -0
  10. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  11. package/src/be/memory/raters/llm.ts +375 -0
  12. package/src/be/memory/raters/noop.ts +14 -0
  13. package/src/be/memory/raters/registry.ts +86 -0
  14. package/src/be/memory/raters/retrieval.ts +88 -0
  15. package/src/be/memory/raters/run-server-raters.ts +97 -0
  16. package/src/be/memory/raters/store.ts +228 -0
  17. package/src/be/memory/raters/types.ts +101 -0
  18. package/src/be/memory/reranker.ts +32 -2
  19. package/src/be/memory/retrieval-store.ts +116 -0
  20. package/src/be/memory/types.ts +3 -0
  21. package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
  22. package/src/be/migrations/052_memory_edges.sql +36 -0
  23. package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
  24. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  25. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  26. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  27. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  28. package/src/be/migrations/058_task_templates.sql +31 -0
  29. package/src/be/swarm-config-guard.ts +24 -0
  30. package/src/commands/credential-wait.ts +186 -0
  31. package/src/commands/provider-credentials.ts +434 -0
  32. package/src/commands/runner.ts +253 -21
  33. package/src/hooks/hook.ts +143 -66
  34. package/src/http/agents.ts +191 -1
  35. package/src/http/config.ts +11 -1
  36. package/src/http/core.ts +5 -0
  37. package/src/http/inbox-state.ts +89 -0
  38. package/src/http/index.ts +10 -0
  39. package/src/http/memory.ts +230 -1
  40. package/src/http/sessions.ts +86 -0
  41. package/src/http/status.ts +665 -0
  42. package/src/http/task-templates.ts +51 -0
  43. package/src/http/tasks.ts +85 -5
  44. package/src/http/users.ts +134 -0
  45. package/src/prompts/memories.ts +62 -0
  46. package/src/providers/claude-adapter.ts +22 -0
  47. package/src/providers/claude-managed-adapter.ts +24 -0
  48. package/src/providers/codex-adapter.ts +43 -1
  49. package/src/providers/devin-adapter.ts +18 -0
  50. package/src/providers/index.ts +7 -0
  51. package/src/providers/opencode-adapter.ts +60 -0
  52. package/src/providers/pi-mono-adapter.ts +71 -0
  53. package/src/providers/types.ts +34 -0
  54. package/src/server.ts +2 -0
  55. package/src/slack/handlers.ts +0 -1
  56. package/src/tests/agents-harness-provider.test.ts +333 -0
  57. package/src/tests/credential-check.test.ts +367 -0
  58. package/src/tests/credential-status-api.test.ts +223 -0
  59. package/src/tests/credential-status-routing.test.ts +150 -0
  60. package/src/tests/credential-wait.test.ts +282 -0
  61. package/src/tests/harness-provider-resolution.test.ts +242 -0
  62. package/src/tests/jira-sync.test.ts +1 -1
  63. package/src/tests/memory-edges.test.ts +722 -0
  64. package/src/tests/memory-rate-endpoint.test.ts +330 -0
  65. package/src/tests/memory-rate-tool.test.ts +252 -0
  66. package/src/tests/memory-rater-e2e.test.ts +578 -0
  67. package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
  68. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  69. package/src/tests/memory-rater-llm.test.ts +964 -0
  70. package/src/tests/memory-rater-store.test.ts +249 -0
  71. package/src/tests/memory-reranker.test.ts +161 -2
  72. package/src/tests/migration-runner-regressions.test.ts +17 -2
  73. package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
  74. package/src/tests/run-server-raters.test.ts +291 -0
  75. package/src/tests/sessions.test.ts +141 -0
  76. package/src/tests/status.test.ts +843 -0
  77. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  78. package/src/tests/template-recommendations.test.ts +148 -0
  79. package/src/tests/tool-annotations.test.ts +2 -2
  80. package/src/tests/use-dismissible-card.test.ts +140 -0
  81. package/src/tools/memory-rate.ts +166 -0
  82. package/src/tools/memory-search.ts +18 -0
  83. package/src/tools/store-progress.ts +37 -0
  84. package/src/tools/swarm-config/set-config.ts +17 -1
  85. package/src/tools/tool-config.ts +1 -0
  86. package/src/types.ts +122 -1
  87. package/src/utils/harness-provider.ts +32 -0
  88. package/tsconfig.json +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.74.4",
3
+ "version": "1.76.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -108,7 +108,7 @@
108
108
  "@mariozechner/pi-ai": "^0.73.0",
109
109
  "@mariozechner/pi-coding-agent": "^0.73.0",
110
110
  "@modelcontextprotocol/sdk": "^1.25.1",
111
- "@openai/codex-sdk": "^0.125.0",
111
+ "@openai/codex-sdk": "^0.128.0",
112
112
  "@opencode-ai/sdk": "^1.14.30",
113
113
  "@openfort/openfort-node": "^0.9.1",
114
114
  "@slack/bolt": "^4.6.0",
package/src/be/db.ts CHANGED
@@ -6,6 +6,7 @@ import { configureDbResolver } from "../prompts/resolver";
6
6
  import type {
7
7
  ActiveSession,
8
8
  Agent,
9
+ AgentCredStatus,
9
10
  AgentLog,
10
11
  AgentLogEventType,
11
12
  AgentMcpServer,
@@ -27,7 +28,9 @@ import type {
27
28
  ContextSnapshotEventType,
28
29
  ContextVersion,
29
30
  CooldownConfig,
30
- DevinProviderMeta,
31
+ InboxItemState,
32
+ InboxItemStatus,
33
+ InboxItemType,
31
34
  InboxMessage,
32
35
  InboxMessageStatus,
33
36
  InputValue,
@@ -54,6 +57,8 @@ import type {
54
57
  SkillWithInstallInfo,
55
58
  SwarmConfig,
56
59
  SwarmRepo,
60
+ TaskTemplate,
61
+ TaskTemplateKind,
57
62
  TriggerConfig,
58
63
  User,
59
64
  VersionableField,
@@ -555,6 +560,12 @@ type AgentRow = {
555
560
  provider: string | null;
556
561
  createdAt: string;
557
562
  lastUpdatedAt: string;
563
+ /** JSON array of env-var names; populated only when status is `waiting_for_credentials`. */
564
+ credentialMissing: string | null;
565
+ /** Phase 1.5: per-agent harness provider pushed on worker registration. */
566
+ harness_provider: string | null;
567
+ /** Migration 055: worker-self-reported credential snapshot (JSON of AgentCredStatus). NULL = unreported. */
568
+ cred_status: string | null;
558
569
  };
559
570
 
560
571
  function rowToAgent(row: AgentRow): Agent {
@@ -576,15 +587,23 @@ function rowToAgent(row: AgentRow): Agent {
576
587
  heartbeatMd: row.heartbeatMd ?? undefined,
577
588
  lastActivityAt: row.lastActivityAt ?? undefined,
578
589
  provider: (row.provider as ProviderName | null) ?? undefined,
590
+ harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
579
591
  createdAt: row.createdAt,
580
592
  lastUpdatedAt: row.lastUpdatedAt,
593
+ credentialMissing: row.credentialMissing
594
+ ? (JSON.parse(row.credentialMissing) as string[])
595
+ : null,
596
+ credStatus: row.cred_status ? (JSON.parse(row.cred_status) as AgentCredStatus) : null,
581
597
  };
582
598
  }
583
599
 
584
600
  export const agentQueries = {
585
601
  insert: () =>
586
- getDb().prepare<AgentRow, [string, string, number, AgentStatus, number, string | null]>(
587
- "INSERT INTO agents (id, name, isLead, status, maxTasks, provider, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
602
+ getDb().prepare<
603
+ AgentRow,
604
+ [string, string, number, AgentStatus, number, string | null, string | null]
605
+ >(
606
+ "INSERT INTO agents (id, name, isLead, status, maxTasks, provider, harness_provider, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
588
607
  ),
589
608
 
590
609
  getById: () => getDb().prepare<AgentRow, [string]>("SELECT * FROM agents WHERE id = ?"),
@@ -596,9 +615,36 @@ export const agentQueries = {
596
615
  "UPDATE agents SET status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
597
616
  ),
598
617
 
618
+ updateCredentialState: () =>
619
+ getDb().prepare<AgentRow, [AgentStatus, string | null, string]>(
620
+ "UPDATE agents SET status = ?, credentialMissing = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
621
+ ),
622
+
599
623
  delete: () => getDb().prepare<null, [string]>("DELETE FROM agents WHERE id = ?"),
600
624
  };
601
625
 
626
+ /**
627
+ * Phase 3 of the worker credential safe-loop plan.
628
+ *
629
+ * `ready=true` clears the waiting state — the agent transitions to `idle`
630
+ * and the dispatcher will start handing it tasks again.
631
+ *
632
+ * `ready=false` parks the agent on `waiting_for_credentials` with the env-var
633
+ * names it's blocked on. The capacity dispatch query already filters
634
+ * `status === 'idle'` so the new value is implicitly excluded with no other
635
+ * code change.
636
+ */
637
+ export function updateAgentCredentialState(
638
+ agentId: string,
639
+ ready: boolean,
640
+ missing: string[] | null,
641
+ ): Agent | null {
642
+ const status: AgentStatus = ready ? "idle" : "waiting_for_credentials";
643
+ const missingJson = ready ? null : missing && missing.length > 0 ? JSON.stringify(missing) : null;
644
+ const row = agentQueries.updateCredentialState().get(status, missingJson, agentId);
645
+ return row ? rowToAgent(row) : null;
646
+ }
647
+
602
648
  export function createAgent(
603
649
  agent: Omit<Agent, "id" | "createdAt" | "lastUpdatedAt"> & { id?: string },
604
650
  ): Agent {
@@ -606,7 +652,15 @@ export function createAgent(
606
652
  const maxTasks = agent.maxTasks ?? 1;
607
653
  const row = agentQueries
608
654
  .insert()
609
- .get(id, agent.name, agent.isLead ? 1 : 0, agent.status, maxTasks, agent.provider ?? null);
655
+ .get(
656
+ id,
657
+ agent.name,
658
+ agent.isLead ? 1 : 0,
659
+ agent.status,
660
+ maxTasks,
661
+ agent.provider ?? null,
662
+ agent.harnessProvider ?? null,
663
+ );
610
664
  if (!row) throw new Error("Failed to create agent");
611
665
  try {
612
666
  createLogEntry({ eventType: "agent_joined", agentId: id, newValue: agent.status });
@@ -664,6 +718,83 @@ export function updateAgentProvider(id: string, provider: ProviderName): Agent |
664
718
  return row ? rowToAgent(row) : null;
665
719
  }
666
720
 
721
+ /**
722
+ * Phase 1.5 (cloud-personalization): set the per-agent `harness_provider`
723
+ * column. Pass `null` to clear. Validation against the canonical provider
724
+ * list happens at the API layer via `ProviderNameSchema`.
725
+ *
726
+ * Returns the updated row, or null if the agent does not exist.
727
+ */
728
+ export function setAgentHarnessProvider(id: string, provider: ProviderName | null): Agent | null {
729
+ const row = getDb()
730
+ .prepare<AgentRow, [string | null, string]>(
731
+ `UPDATE agents SET harness_provider = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
732
+ WHERE id = ? RETURNING *`,
733
+ )
734
+ .get(provider, id);
735
+ return row ? rowToAgent(row) : null;
736
+ }
737
+
738
+ /**
739
+ * Migration 055 — write the worker-self-reported credential snapshot.
740
+ * Pass `null` to clear (e.g. on agent re-registration). Validation against
741
+ * the JSON shape happens at the API layer via `AgentCredStatusSchema`.
742
+ *
743
+ * Worker reports this alongside the existing `updateAgentCredentialState`
744
+ * call; we keep the writes in two functions so the dispatch pattern stays
745
+ * one-row-one-fact, and the PATCH handler can choose which to call based
746
+ * on which fields the request body carried.
747
+ */
748
+ export function updateAgentCredStatus(
749
+ id: string,
750
+ credStatus: AgentCredStatus | null,
751
+ ): Agent | null {
752
+ const json = credStatus ? JSON.stringify(credStatus) : null;
753
+ const row = getDb()
754
+ .prepare<AgentRow, [string | null, string]>(
755
+ `UPDATE agents SET cred_status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
756
+ WHERE id = ? RETURNING *`,
757
+ )
758
+ .get(json, id);
759
+ return row ? rowToAgent(row) : null;
760
+ }
761
+
762
+ /**
763
+ * Migration 055 — read all agents whose `harness_provider` matches a given
764
+ * provider, with their reported `cred_status`. Used by the credential-status
765
+ * API endpoint to roll up "is this provider working across the fleet?".
766
+ *
767
+ * Agents with NULL `cred_status` (never reported, or CRED_CHECK_DISABLE=1)
768
+ * are still returned — the caller surfaces them as "unreported".
769
+ */
770
+ export function listAgentsWithCredStatusByProvider(provider: string): Agent[] {
771
+ const rows = getDb()
772
+ .prepare<AgentRow, [string]>(`SELECT * FROM agents WHERE harness_provider = ? ORDER BY name`)
773
+ .all(provider);
774
+ return rows.map(rowToAgent);
775
+ }
776
+
777
+ /**
778
+ * Phase 1.5 (cloud-personalization): aggregate count of registered agents
779
+ * by `harness_provider`. NULL rows (agents that registered before the
780
+ * migration or never pushed a value) are excluded — they show up in the
781
+ * total agent count but not here.
782
+ *
783
+ * Used by future fleet displays. Not consumed in this phase.
784
+ */
785
+ export function getAgentHarnessProviders(): Array<{ provider: string; count: number }> {
786
+ const rows = getDb()
787
+ .prepare<{ provider: string; count: number }, []>(
788
+ `SELECT harness_provider AS provider, COUNT(*) AS count
789
+ FROM agents
790
+ WHERE harness_provider IS NOT NULL
791
+ GROUP BY harness_provider
792
+ ORDER BY harness_provider`,
793
+ )
794
+ .all();
795
+ return rows.map((r) => ({ provider: r.provider, count: r.count }));
796
+ }
797
+
667
798
  export function updateAgentActivity(id: string): void {
668
799
  getDb()
669
800
  .prepare<null, [string]>(
@@ -774,6 +905,10 @@ export function getRemainingCapacity(agentId: string): number {
774
905
  export function updateAgentStatusFromCapacity(agentId: string): void {
775
906
  const agent = getAgentById(agentId);
776
907
  if (!agent || agent.status === "offline") return;
908
+ // `waiting_for_credentials` is owned by the worker's credential-wait
909
+ // tick — task-completion shouldn't accidentally promote a blocked agent
910
+ // back to idle.
911
+ if (agent.status === "waiting_for_credentials") return;
777
912
 
778
913
  const activeCount = getActiveTaskCount(agentId);
779
914
  const newStatus = activeCount > 0 ? "busy" : "idle";
@@ -1186,7 +1321,8 @@ export function findTaskByVcs(vcsRepo: string, vcsNumber: number): AgentTask | n
1186
1321
  export const findTaskByGitHub = findTaskByVcs;
1187
1322
 
1188
1323
  export interface TaskFilters {
1189
- status?: AgentTaskStatus;
1324
+ /** Single status (back-compat) OR array of statuses (multi-status filter). */
1325
+ status?: AgentTaskStatus | AgentTaskStatus[];
1190
1326
  agentId?: string;
1191
1327
  search?: string;
1192
1328
  // New filters
@@ -1196,6 +1332,10 @@ export interface TaskFilters {
1196
1332
  taskType?: string;
1197
1333
  tags?: string[];
1198
1334
  scheduleId?: string;
1335
+ /** Filter to tasks whose `source` is in this list. Empty/undefined → no filter. */
1336
+ source?: AgentTaskSource[];
1337
+ /** ISO 8601 timestamp; only return tasks where createdAt >= this. */
1338
+ createdAfter?: string;
1199
1339
  limit?: number;
1200
1340
  offset?: number;
1201
1341
  includeHeartbeat?: boolean;
@@ -1206,8 +1346,19 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1206
1346
  const params: (string | AgentTaskStatus)[] = [];
1207
1347
 
1208
1348
  if (filters?.status) {
1209
- conditions.push("status = ?");
1210
- params.push(filters.status);
1349
+ if (Array.isArray(filters.status)) {
1350
+ if (filters.status.length === 1) {
1351
+ conditions.push("status = ?");
1352
+ params.push(filters.status[0]!);
1353
+ } else if (filters.status.length > 1) {
1354
+ const placeholders = filters.status.map(() => "?").join(", ");
1355
+ conditions.push(`status IN (${placeholders})`);
1356
+ for (const s of filters.status) params.push(s);
1357
+ }
1358
+ } else {
1359
+ conditions.push("status = ?");
1360
+ params.push(filters.status);
1361
+ }
1211
1362
  }
1212
1363
 
1213
1364
  if (filters?.agentId) {
@@ -1249,6 +1400,17 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1249
1400
  params.push(filters.scheduleId);
1250
1401
  }
1251
1402
 
1403
+ if (filters?.source && filters.source.length > 0) {
1404
+ const placeholders = filters.source.map(() => "?").join(", ");
1405
+ conditions.push(`source IN (${placeholders})`);
1406
+ for (const s of filters.source) params.push(s);
1407
+ }
1408
+
1409
+ if (filters?.createdAfter) {
1410
+ conditions.push("createdAt >= ?");
1411
+ params.push(filters.createdAfter);
1412
+ }
1413
+
1252
1414
  // Exclude heartbeat tasks by default
1253
1415
  if (!filters?.includeHeartbeat) {
1254
1416
  conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
@@ -1284,8 +1446,19 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1284
1446
  const params: (string | AgentTaskStatus)[] = [];
1285
1447
 
1286
1448
  if (filters?.status) {
1287
- conditions.push("status = ?");
1288
- params.push(filters.status);
1449
+ if (Array.isArray(filters.status)) {
1450
+ if (filters.status.length === 1) {
1451
+ conditions.push("status = ?");
1452
+ params.push(filters.status[0]!);
1453
+ } else if (filters.status.length > 1) {
1454
+ const placeholders = filters.status.map(() => "?").join(", ");
1455
+ conditions.push(`status IN (${placeholders})`);
1456
+ for (const s of filters.status) params.push(s);
1457
+ }
1458
+ } else {
1459
+ conditions.push("status = ?");
1460
+ params.push(filters.status);
1461
+ }
1289
1462
  }
1290
1463
 
1291
1464
  if (filters?.agentId) {
@@ -1325,6 +1498,17 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1325
1498
  params.push(filters.scheduleId);
1326
1499
  }
1327
1500
 
1501
+ if (filters?.source && filters.source.length > 0) {
1502
+ const placeholders = filters.source.map(() => "?").join(", ");
1503
+ conditions.push(`source IN (${placeholders})`);
1504
+ for (const s of filters.source) params.push(s);
1505
+ }
1506
+
1507
+ if (filters?.createdAfter) {
1508
+ conditions.push("createdAt >= ?");
1509
+ params.push(filters.createdAfter);
1510
+ }
1511
+
1328
1512
  // Exclude heartbeat tasks by default
1329
1513
  if (!filters?.includeHeartbeat) {
1330
1514
  conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
@@ -8446,6 +8630,301 @@ export function deleteUser(id: string): boolean {
8446
8630
  return result.changes > 0;
8447
8631
  }
8448
8632
 
8633
+ // ============================================================================
8634
+ // Inbox Item State (per-user dismiss/snooze/done for action-items inbox)
8635
+ // ============================================================================
8636
+
8637
+ interface InboxItemStateRow {
8638
+ id: string;
8639
+ userId: string;
8640
+ itemType: string;
8641
+ itemId: string;
8642
+ status: string;
8643
+ snoozeUntil: string | null;
8644
+ dismissedAt: string | null;
8645
+ doneAt: string | null;
8646
+ createdAt: string;
8647
+ lastUpdatedAt: string;
8648
+ }
8649
+
8650
+ function rowToInboxItemState(row: InboxItemStateRow): InboxItemState {
8651
+ return {
8652
+ id: row.id,
8653
+ userId: row.userId,
8654
+ itemType: row.itemType as InboxItemType,
8655
+ itemId: row.itemId,
8656
+ status: row.status as InboxItemStatus,
8657
+ snoozeUntil: row.snoozeUntil ?? undefined,
8658
+ dismissedAt: row.dismissedAt ?? undefined,
8659
+ doneAt: row.doneAt ?? undefined,
8660
+ createdAt: row.createdAt,
8661
+ lastUpdatedAt: row.lastUpdatedAt,
8662
+ };
8663
+ }
8664
+
8665
+ export function listInboxState(opts: {
8666
+ userId: string;
8667
+ status?: InboxItemStatus;
8668
+ itemType?: InboxItemType;
8669
+ }): InboxItemState[] {
8670
+ const conditions: string[] = ["userId = ?"];
8671
+ const params: string[] = [opts.userId];
8672
+
8673
+ if (opts.status) {
8674
+ conditions.push("status = ?");
8675
+ params.push(opts.status);
8676
+ }
8677
+ if (opts.itemType) {
8678
+ conditions.push("itemType = ?");
8679
+ params.push(opts.itemType);
8680
+ }
8681
+
8682
+ const where = conditions.join(" AND ");
8683
+ return getDb()
8684
+ .prepare<InboxItemStateRow, string[]>(
8685
+ `SELECT * FROM inbox_item_state WHERE ${where} ORDER BY lastUpdatedAt DESC`,
8686
+ )
8687
+ .all(...params)
8688
+ .map(rowToInboxItemState);
8689
+ }
8690
+
8691
+ export function upsertInboxState(opts: {
8692
+ userId: string;
8693
+ itemType: InboxItemType;
8694
+ itemId: string;
8695
+ status: InboxItemStatus;
8696
+ snoozeUntil?: string;
8697
+ dismissedAt?: string;
8698
+ doneAt?: string;
8699
+ }): InboxItemState {
8700
+ const now = new Date().toISOString();
8701
+ // Auto-derive timestamps from status when not explicitly provided.
8702
+ const dismissedAt = opts.dismissedAt ?? (opts.status === "dismissed" ? now : null);
8703
+ const doneAt = opts.doneAt ?? (opts.status === "done" ? now : null);
8704
+ const snoozeUntil = opts.snoozeUntil ?? null;
8705
+
8706
+ // SQLite upsert via UNIQUE(userId, itemType, itemId).
8707
+ const row = getDb()
8708
+ .prepare<InboxItemStateRow, (string | null)[]>(
8709
+ `INSERT INTO inbox_item_state (userId, itemType, itemId, status, snoozeUntil, dismissedAt, doneAt, createdAt, lastUpdatedAt)
8710
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
8711
+ ON CONFLICT(userId, itemType, itemId) DO UPDATE SET
8712
+ status = excluded.status,
8713
+ snoozeUntil = excluded.snoozeUntil,
8714
+ dismissedAt = excluded.dismissedAt,
8715
+ doneAt = excluded.doneAt,
8716
+ lastUpdatedAt = excluded.lastUpdatedAt
8717
+ RETURNING *`,
8718
+ )
8719
+ .get(
8720
+ opts.userId,
8721
+ opts.itemType,
8722
+ opts.itemId,
8723
+ opts.status,
8724
+ snoozeUntil,
8725
+ dismissedAt,
8726
+ doneAt,
8727
+ now,
8728
+ now,
8729
+ );
8730
+ if (!row) throw new Error("Failed to upsert inbox state");
8731
+ return rowToInboxItemState(row);
8732
+ }
8733
+
8734
+ // ============================================================================
8735
+ // Task Templates ("To start" bucket — polymorphic starters registry)
8736
+ // ============================================================================
8737
+
8738
+ interface TaskTemplateRow {
8739
+ id: string;
8740
+ title: string;
8741
+ description: string;
8742
+ prompt: string;
8743
+ kind: string;
8744
+ payload: string;
8745
+ category: string | null;
8746
+ tags: string;
8747
+ createdAt: string;
8748
+ }
8749
+
8750
+ function rowToTaskTemplate(row: TaskTemplateRow): TaskTemplate {
8751
+ let payload: Record<string, unknown> = {};
8752
+ try {
8753
+ payload = JSON.parse(row.payload);
8754
+ } catch {}
8755
+ let tags: string[] = [];
8756
+ try {
8757
+ tags = JSON.parse(row.tags);
8758
+ } catch {}
8759
+ return {
8760
+ id: row.id,
8761
+ title: row.title,
8762
+ description: row.description,
8763
+ prompt: row.prompt,
8764
+ kind: row.kind as TaskTemplateKind,
8765
+ payload,
8766
+ category: row.category ?? undefined,
8767
+ tags,
8768
+ createdAt: row.createdAt,
8769
+ };
8770
+ }
8771
+
8772
+ export function listTaskTemplates(opts?: {
8773
+ category?: string;
8774
+ kind?: TaskTemplateKind;
8775
+ query?: string;
8776
+ }): TaskTemplate[] {
8777
+ const conditions: string[] = [];
8778
+ const params: string[] = [];
8779
+
8780
+ if (opts?.category) {
8781
+ conditions.push("category = ?");
8782
+ params.push(opts.category);
8783
+ }
8784
+ if (opts?.kind) {
8785
+ conditions.push("kind = ?");
8786
+ params.push(opts.kind);
8787
+ }
8788
+ if (opts?.query && opts.query.trim().length > 0) {
8789
+ // Case-insensitive LIKE match against title OR description, single
8790
+ // parameter-bound WHERE clause to prevent injection.
8791
+ conditions.push("(LOWER(title) LIKE ? OR LOWER(description) LIKE ?)");
8792
+ const needle = `%${opts.query.toLowerCase()}%`;
8793
+ params.push(needle, needle);
8794
+ }
8795
+
8796
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
8797
+ return getDb()
8798
+ .prepare<TaskTemplateRow, string[]>(`SELECT * FROM task_templates ${where} ORDER BY createdAt`)
8799
+ .all(...params)
8800
+ .map(rowToTaskTemplate);
8801
+ }
8802
+
8803
+ // ============================================================================
8804
+ // Sessions — root task chain + recent-sessions list
8805
+ // ============================================================================
8806
+
8807
+ /**
8808
+ * Walk the parent→child chain rooted at `rootTaskId` via recursive CTE.
8809
+ * Returns the chain ordered by `createdAt` (so the root is first; siblings
8810
+ * appear in creation order; grand-children after their parents).
8811
+ */
8812
+ export function getRootTaskChain(rootTaskId: string): AgentTask[] {
8813
+ const rows = getDb()
8814
+ .prepare<AgentTaskRow, string>(
8815
+ `WITH RECURSIVE chain(id) AS (
8816
+ SELECT id FROM agent_tasks WHERE id = ?
8817
+ UNION ALL
8818
+ SELECT t.id FROM agent_tasks t
8819
+ JOIN chain c ON t.parentTaskId = c.id
8820
+ )
8821
+ SELECT t.* FROM agent_tasks t
8822
+ JOIN chain ON chain.id = t.id
8823
+ ORDER BY t.createdAt`,
8824
+ )
8825
+ .all(rootTaskId);
8826
+ return rows.map(rowToAgentTask);
8827
+ }
8828
+
8829
+ export interface SessionListItem {
8830
+ root: AgentTask;
8831
+ chainTaskCount: number;
8832
+ lastActivityAt: string;
8833
+ latestStatus: AgentTaskStatus;
8834
+ }
8835
+
8836
+ /**
8837
+ * List the most recent sessions ordered by chain-wide latest activity.
8838
+ * A "session" here is any task with `parentTaskId IS NULL` — its descendants
8839
+ * (children, grand-children, …) are summarized via the recursive CTE.
8840
+ *
8841
+ * `lastActivityAt` is `MAX(t.lastUpdatedAt)` over the entire chain rooted at
8842
+ * the candidate task, computed as a correlated subquery so the outer ORDER
8843
+ * BY can sort against it.
8844
+ */
8845
+ export function listRecentSessions(opts?: {
8846
+ limit?: number;
8847
+ offset?: number;
8848
+ /** Filter to root tasks whose `source` is in this list. Empty/undefined → no source filter. */
8849
+ source?: string[];
8850
+ /** Case-insensitive substring match against `r.task`. */
8851
+ q?: string;
8852
+ }): SessionListItem[] {
8853
+ const limit = opts?.limit ?? 25;
8854
+ const offset = opts?.offset ?? 0;
8855
+ const sources = opts?.source?.filter((s) => s.length > 0) ?? [];
8856
+ const q = opts?.q?.trim();
8857
+
8858
+ const conditions: string[] = ["r.parentTaskId IS NULL"];
8859
+ const params: (string | number)[] = [];
8860
+
8861
+ if (sources.length > 0) {
8862
+ conditions.push(`r.source IN (${sources.map(() => "?").join(", ")})`);
8863
+ params.push(...sources);
8864
+ }
8865
+ if (q && q.length > 0) {
8866
+ conditions.push("lower(r.task) LIKE ?");
8867
+ params.push(`%${q.toLowerCase()}%`);
8868
+ }
8869
+ params.push(limit, offset);
8870
+
8871
+ const rootRows = getDb()
8872
+ .prepare<
8873
+ AgentTaskRow & { __chainCount: number; __lastActivityAt: string; __latestStatus: string },
8874
+ typeof params
8875
+ >(
8876
+ `SELECT
8877
+ r.*,
8878
+ (SELECT COUNT(*) FROM agent_tasks d
8879
+ WHERE d.id IN (
8880
+ WITH RECURSIVE chain(id) AS (
8881
+ SELECT r.id
8882
+ UNION ALL
8883
+ SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
8884
+ )
8885
+ SELECT id FROM chain
8886
+ )
8887
+ ) AS __chainCount,
8888
+ (SELECT MAX(d.lastUpdatedAt) FROM agent_tasks d
8889
+ WHERE d.id IN (
8890
+ WITH RECURSIVE chain(id) AS (
8891
+ SELECT r.id
8892
+ UNION ALL
8893
+ SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
8894
+ )
8895
+ SELECT id FROM chain
8896
+ )
8897
+ ) AS __lastActivityAt,
8898
+ (SELECT d.status FROM agent_tasks d
8899
+ WHERE d.id IN (
8900
+ WITH RECURSIVE chain(id) AS (
8901
+ SELECT r.id
8902
+ UNION ALL
8903
+ SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
8904
+ )
8905
+ SELECT id FROM chain
8906
+ )
8907
+ ORDER BY d.lastUpdatedAt DESC
8908
+ LIMIT 1
8909
+ ) AS __latestStatus
8910
+ FROM agent_tasks r
8911
+ WHERE ${conditions.join(" AND ")}
8912
+ ORDER BY __lastActivityAt DESC
8913
+ LIMIT ? OFFSET ?`,
8914
+ )
8915
+ .all(...params);
8916
+
8917
+ return rootRows.map((row) => {
8918
+ const { __chainCount, __lastActivityAt, __latestStatus, ...taskRow } = row;
8919
+ return {
8920
+ root: rowToAgentTask(taskRow as AgentTaskRow),
8921
+ chainTaskCount: __chainCount,
8922
+ lastActivityAt: __lastActivityAt ?? row.lastUpdatedAt,
8923
+ latestStatus: (__latestStatus as AgentTaskStatus) ?? row.status,
8924
+ };
8925
+ });
8926
+ }
8927
+
8449
8928
  // ============================================================================
8450
8929
  // Budgets, daily-spend aggregation, and budget-refusal notifications (Phase 2)
8451
8930
  // ----------------------------------------------------------------------------
@@ -8875,3 +9354,78 @@ export function setBudgetRefusalFollowUpTaskId(
8875
9354
  )
8876
9355
  .run(followUpTaskId, taskId, date);
8877
9356
  }
9357
+
9358
+ // ============================================================================
9359
+ // /status helpers — instance activity + first-task milestone
9360
+ // ============================================================================
9361
+
9362
+ /**
9363
+ * Count agents that have heartbeated within the last `minutes` minutes,
9364
+ * grouped by lead/worker. Used by the `workers` setup milestone on
9365
+ * `GET /status` to flip from `configured` → `verified` only when both a lead
9366
+ * and at least one worker are alive.
9367
+ *
9368
+ * "Recent" defaults to 5 minutes — a multiple of `ACTIVITY_THROTTLE_MS = 5_000`
9369
+ * (`src/providers/swarm-events-shared.ts:48-49`) plus margin for missed
9370
+ * heartbeats. Agents with `status = 'offline'` are excluded.
9371
+ */
9372
+ export function getLiveAgentCounts(minutes: number = 5): {
9373
+ leads_alive: number;
9374
+ workers_alive: number;
9375
+ } {
9376
+ const row = getDb()
9377
+ .prepare<{ leads_alive: number | null; workers_alive: number | null }, [number]>(
9378
+ `SELECT
9379
+ SUM(CASE WHEN isLead = 1 THEN 1 ELSE 0 END) AS leads_alive,
9380
+ SUM(CASE WHEN isLead = 0 THEN 1 ELSE 0 END) AS workers_alive
9381
+ FROM agents
9382
+ WHERE lastActivityAt IS NOT NULL
9383
+ AND lastActivityAt >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ?1 || ' minutes')
9384
+ AND status != 'offline'`,
9385
+ )
9386
+ .get(minutes);
9387
+ return {
9388
+ leads_alive: row?.leads_alive ?? 0,
9389
+ workers_alive: row?.workers_alive ?? 0,
9390
+ };
9391
+ }
9392
+
9393
+ /**
9394
+ * Aggregate activity numbers for `GET /status`'s `activity` block.
9395
+ * - `agents_online` / `leads_online`: heartbeated within the last 5 minutes.
9396
+ * - `recent_tasks_count`: agent_tasks rows created in the last 24 hours.
9397
+ *
9398
+ * `agents_online` reports total alive agents (leads + workers) so the home
9399
+ * page can show a single "online" stat without summing on the client.
9400
+ */
9401
+ export function getInstanceActivity(): {
9402
+ agents_online: number;
9403
+ leads_online: number;
9404
+ recent_tasks_count: number;
9405
+ } {
9406
+ const { leads_alive, workers_alive } = getLiveAgentCounts(5);
9407
+ const tasksRow = getDb()
9408
+ .prepare<{ count: number }, []>(
9409
+ `SELECT COUNT(*) AS count FROM agent_tasks
9410
+ WHERE createdAt >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')`,
9411
+ )
9412
+ .get();
9413
+ return {
9414
+ agents_online: leads_alive + workers_alive,
9415
+ leads_online: leads_alive,
9416
+ recent_tasks_count: tasksRow?.count ?? 0,
9417
+ };
9418
+ }
9419
+
9420
+ /**
9421
+ * `first_task` milestone: true once any task has reached `status = 'completed'`.
9422
+ * Cheap LIMIT 1 probe; the row's contents don't matter, only existence.
9423
+ */
9424
+ export function hasFirstCompletedTask(): boolean {
9425
+ const row = getDb()
9426
+ .prepare<{ one: number }, []>(
9427
+ `SELECT 1 AS one FROM agent_tasks WHERE status = 'completed' LIMIT 1`,
9428
+ )
9429
+ .get();
9430
+ return row !== null;
9431
+ }