@desplega.ai/agent-swarm 1.81.1 → 1.83.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 (105) hide show
  1. package/openapi.json +127 -3
  2. package/package.json +3 -2
  3. package/src/be/db.ts +616 -96
  4. package/src/be/migrations/069_agent_tasks_perf_indexes.sql +17 -0
  5. package/src/be/migrations/070_seed_state.sql +22 -0
  6. package/src/be/migrations/071_codex_oauth_pool.sql +8 -0
  7. package/src/be/migrations/072_task_attachments.sql +38 -0
  8. package/src/be/migrations/073_task_attachments_agent_fs_ids.sql +15 -0
  9. package/src/be/scripts/typecheck.ts +450 -7
  10. package/src/be/seed/index.ts +9 -0
  11. package/src/be/seed/registry.ts +18 -0
  12. package/src/be/seed/runner.ts +98 -0
  13. package/src/be/seed/state-db.ts +36 -0
  14. package/src/be/seed/types.ts +59 -0
  15. package/src/be/seed-scripts/catalog/date-resolve.ts +104 -0
  16. package/src/be/seed-scripts/catalog/fetch-readable.ts +77 -0
  17. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +104 -0
  18. package/src/be/seed-scripts/catalog/group-count.ts +70 -0
  19. package/src/be/seed-scripts/catalog/json-query.ts +124 -0
  20. package/src/be/seed-scripts/catalog/linear-issue.ts +103 -0
  21. package/src/be/seed-scripts/catalog/memory-dedup-check.ts +61 -0
  22. package/src/be/seed-scripts/catalog/slack-thread-flatten.ts +86 -0
  23. package/src/be/seed-scripts/catalog/task-failure-audit.ts +87 -0
  24. package/src/be/seed-scripts/catalog/text-diff.ts +103 -0
  25. package/src/be/seed-scripts/index.ts +183 -0
  26. package/src/cli.tsx +2 -0
  27. package/src/commands/codex-login.ts +36 -6
  28. package/src/commands/provider-credentials.ts +11 -0
  29. package/src/commands/runner.ts +133 -44
  30. package/src/http/agents.ts +7 -1
  31. package/src/http/index.ts +123 -74
  32. package/src/http/pages.ts +25 -5
  33. package/src/http/route-def.ts +63 -0
  34. package/src/http/schedules.ts +11 -2
  35. package/src/http/scripts.ts +10 -1
  36. package/src/http/sessions.ts +24 -3
  37. package/src/http/skills.ts +8 -1
  38. package/src/http/stats.ts +19 -0
  39. package/src/http/tasks.ts +20 -5
  40. package/src/http/utils.ts +44 -0
  41. package/src/http/workflows.ts +11 -1
  42. package/src/http.ts +2 -0
  43. package/src/otel-impl.ts +34 -4
  44. package/src/otel.ts +1 -3
  45. package/src/providers/claude-adapter.ts +61 -0
  46. package/src/providers/codex-adapter.ts +22 -1
  47. package/src/providers/codex-oauth/auth-json-fs.ts +52 -0
  48. package/src/providers/codex-oauth/auth-json.ts +3 -3
  49. package/src/providers/codex-oauth/storage.ts +81 -21
  50. package/src/providers/otel-env.ts +63 -0
  51. package/src/providers/pi-mono-adapter.ts +20 -3
  52. package/src/providers/types.ts +10 -1
  53. package/src/scripts-runtime/eval-harness.ts +70 -3
  54. package/src/scripts-runtime/executors/native.ts +19 -1
  55. package/src/scripts-runtime/executors/types.ts +17 -0
  56. package/src/scripts-runtime/loader.ts +2 -0
  57. package/src/server.ts +2 -0
  58. package/src/slack/blocks.ts +132 -1
  59. package/src/slack/responses.ts +15 -5
  60. package/src/slack/watcher.ts +12 -0
  61. package/src/tests/claude-adapter-otel.test.ts +225 -0
  62. package/src/tests/codex-adapter-otel.test.ts +120 -0
  63. package/src/tests/codex-login.test.ts +142 -0
  64. package/src/tests/codex-oauth-adapter.test.ts +108 -0
  65. package/src/tests/codex-oauth-auth-json-fs.test.ts +112 -0
  66. package/src/tests/codex-oauth-storage.test.ts +262 -86
  67. package/src/tests/codex-pool.test.ts +284 -0
  68. package/src/tests/credential-check.test.ts +47 -0
  69. package/src/tests/http-semconv-attributes.test.ts +92 -0
  70. package/src/tests/list-endpoint-slimming.test.ts +179 -0
  71. package/src/tests/mcp-tools-user.test.ts +48 -1
  72. package/src/tests/otel-env.test.ts +103 -0
  73. package/src/tests/otel-service-name.test.ts +55 -0
  74. package/src/tests/pagination-metrics.test.ts +165 -0
  75. package/src/tests/prompt-template-resolver.test.ts +1 -1
  76. package/src/tests/rest-api.test.ts +51 -1
  77. package/src/tests/route-def-find-route.test.ts +106 -0
  78. package/src/tests/scripts-http.test.ts +110 -0
  79. package/src/tests/scripts-runtime.test.ts +1 -0
  80. package/src/tests/scripts-typecheck.test.ts +175 -0
  81. package/src/tests/seed-scripts.test.ts +220 -0
  82. package/src/tests/seed.test.ts +163 -0
  83. package/src/tests/send-task-requested-by.test.ts +154 -0
  84. package/src/tests/slack-attachments-block.test.ts +240 -0
  85. package/src/tests/slack-blocks.test.ts +162 -0
  86. package/src/tests/slack-watcher.test.ts +83 -0
  87. package/src/tests/store-progress-attachments-handler.test.ts +480 -0
  88. package/src/tests/store-progress-attachments.test.ts +353 -0
  89. package/src/tests/workflow-http-v2.test.ts +16 -2
  90. package/src/tools/get-metrics.ts +46 -0
  91. package/src/tools/get-swarm.ts +10 -3
  92. package/src/tools/get-task-details.ts +15 -2
  93. package/src/tools/get-tasks.ts +22 -5
  94. package/src/tools/resolve-user.ts +25 -10
  95. package/src/tools/schedules/list-schedules.ts +15 -4
  96. package/src/tools/send-task.ts +16 -0
  97. package/src/tools/store-progress.ts +102 -4
  98. package/src/tools/tool-config.ts +2 -1
  99. package/src/tools/utils.ts +3 -1
  100. package/src/tools/workflows/list-workflows.ts +12 -3
  101. package/src/types.ts +149 -1
  102. package/src/utils/constants.ts +58 -0
  103. package/src/utils/internal-ai/register-bedrock.ts +34 -0
  104. package/src/utils/secret-scrubber.ts +3 -0
  105. /package/src/be/{seed.ts → seed-prompt-templates.ts} +0 -0
package/src/be/db.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  AgentTask,
16
16
  AgentTaskSource,
17
17
  AgentTaskStatus,
18
+ AgentTaskSummary,
18
19
  AgentWithTasks,
19
20
  Budget,
20
21
  BudgetRefusalCause,
@@ -44,6 +45,7 @@ import type {
44
45
  PageAuthMode,
45
46
  PageContentType,
46
47
  PageSnapshot,
48
+ PageSummary,
47
49
  PageVersion,
48
50
  PricingProvider,
49
51
  PricingRow,
@@ -53,6 +55,7 @@ import type {
53
55
  ProviderName,
54
56
  RepoGuidelines,
55
57
  ScheduledTask,
58
+ ScheduledTaskSummary,
56
59
  Service,
57
60
  ServiceStatus,
58
61
  SessionCost,
@@ -64,6 +67,7 @@ import type {
64
67
  SkillWithInstallInfo,
65
68
  SwarmConfig,
66
69
  SwarmRepo,
70
+ TaskAttachment,
67
71
  TaskTemplate,
68
72
  TaskTemplateKind,
69
73
  TriggerConfig,
@@ -80,6 +84,7 @@ import type {
80
84
  WorkflowRunStep,
81
85
  WorkflowRunStepStatus,
82
86
  WorkflowSnapshot,
87
+ WorkflowSummary,
83
88
  WorkflowVersion,
84
89
  } from "../types";
85
90
  import { deriveProviderFromKeyType } from "../utils/credentials";
@@ -87,7 +92,7 @@ import { scrubSecrets } from "../utils/secret-scrubber";
87
92
  import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
88
93
  import { normalizeDate, normalizeDateRequired } from "./date-utils";
89
94
  import { runMigrations } from "./migrations/runner";
90
- import { seedDefaultTemplates } from "./seed";
95
+ import { seedDefaultTemplates } from "./seed-prompt-templates";
91
96
  import { isReservedConfigKey, reservedKeyError } from "./swarm-config-guard";
92
97
 
93
98
  let db: Database | null = null;
@@ -128,6 +133,10 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
128
133
  database.run("PRAGMA journal_mode = WAL;");
129
134
  database.run("PRAGMA busy_timeout = 5000;");
130
135
  database.run("PRAGMA foreign_keys = ON;");
136
+ database.run("PRAGMA synchronous = NORMAL;");
137
+ database.run("PRAGMA cache_size = -64000;");
138
+ database.run("PRAGMA mmap_size = 268435456;");
139
+ database.run("PRAGMA temp_store = MEMORY;");
131
140
 
132
141
  // Load sqlite-vec extension for vector search.
133
142
  // In compiled binaries (`bun build --compile`) the JS lives in /$bunfs/ and
@@ -575,8 +584,15 @@ type AgentRow = {
575
584
  cred_status: string | null;
576
585
  };
577
586
 
578
- function rowToAgent(row: AgentRow): Agent {
579
- return {
587
+ /**
588
+ * Map an agent row to the `Agent` shape. When `slim` is true the six identity
589
+ * markdown blobs (`claudeMd`/`soulMd`/`identityMd`/`toolsMd`/`heartbeatMd`/
590
+ * `setupScript`) are omitted — they bloat list responses by ~16 KB/agent and
591
+ * are never needed at the swarm-overview level. Fetch them via
592
+ * `GET /api/agents/{id}` when required.
593
+ */
594
+ function rowToAgent(row: AgentRow, slim = false): Agent {
595
+ const base: Agent = {
580
596
  id: row.id,
581
597
  name: row.name,
582
598
  isLead: row.isLead === 1,
@@ -586,12 +602,6 @@ function rowToAgent(row: AgentRow): Agent {
586
602
  capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
587
603
  maxTasks: row.maxTasks ?? 1,
588
604
  emptyPollCount: row.emptyPollCount ?? 0,
589
- claudeMd: row.claudeMd ?? undefined,
590
- soulMd: row.soulMd ?? undefined,
591
- identityMd: row.identityMd ?? undefined,
592
- setupScript: row.setupScript ?? undefined,
593
- toolsMd: row.toolsMd ?? undefined,
594
- heartbeatMd: row.heartbeatMd ?? undefined,
595
605
  lastActivityAt: row.lastActivityAt ?? undefined,
596
606
  provider: (row.provider as ProviderName | null) ?? undefined,
597
607
  harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
@@ -602,6 +612,16 @@ function rowToAgent(row: AgentRow): Agent {
602
612
  : null,
603
613
  credStatus: row.cred_status ? (JSON.parse(row.cred_status) as AgentCredStatus) : null,
604
614
  };
615
+ if (slim) return base;
616
+ return {
617
+ ...base,
618
+ claudeMd: row.claudeMd ?? undefined,
619
+ soulMd: row.soulMd ?? undefined,
620
+ identityMd: row.identityMd ?? undefined,
621
+ setupScript: row.setupScript ?? undefined,
622
+ toolsMd: row.toolsMd ?? undefined,
623
+ heartbeatMd: row.heartbeatMd ?? undefined,
624
+ };
605
625
  }
606
626
 
607
627
  export const agentQueries = {
@@ -680,8 +700,11 @@ export function getAgentById(id: string): Agent | null {
680
700
  return row ? rowToAgent(row) : null;
681
701
  }
682
702
 
683
- export function getAllAgents(): Agent[] {
684
- return agentQueries.getAll().all().map(rowToAgent);
703
+ export function getAllAgents(opts?: { slim?: boolean }): Agent[] {
704
+ return agentQueries
705
+ .getAll()
706
+ .all()
707
+ .map((row) => rowToAgent(row, opts?.slim ?? false));
685
708
  }
686
709
 
687
710
  export function getLeadAgent(): Agent | null {
@@ -778,7 +801,7 @@ export function listAgentsWithCredStatusByProvider(provider: string): Agent[] {
778
801
  const rows = getDb()
779
802
  .prepare<AgentRow, [string]>(`SELECT * FROM agents WHERE harness_provider = ? ORDER BY name`)
780
803
  .all(provider);
781
- return rows.map(rowToAgent);
804
+ return rows.map((row) => rowToAgent(row));
782
805
  }
783
806
 
784
807
  /**
@@ -1055,6 +1078,41 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
1055
1078
  };
1056
1079
  }
1057
1080
 
1081
+ /**
1082
+ * Slim list-row mapper — truncates the `task` text to a bounded preview and
1083
+ * drops completion/integration/context blobs (`output`, `failureReason`,
1084
+ * `providerMeta`, all `vcs*`/`slack*`/`agentmail*`/`credential*`/`mention*` and
1085
+ * context-window fields). The preview is long enough for pool-triage; the full
1086
+ * brief is on `get-task-details` / `GET /api/tasks/{id}`.
1087
+ */
1088
+ function rowToAgentTaskSummary(row: AgentTaskRow): AgentTaskSummary {
1089
+ const t = rowToAgentTask(row);
1090
+ return {
1091
+ id: t.id,
1092
+ agentId: t.agentId,
1093
+ creatorAgentId: t.creatorAgentId,
1094
+ task: previewText(t.task, TASK_PREVIEW_LENGTH),
1095
+ status: t.status,
1096
+ source: t.source,
1097
+ taskType: t.taskType,
1098
+ tags: t.tags,
1099
+ priority: t.priority,
1100
+ dependsOn: t.dependsOn,
1101
+ offeredTo: t.offeredTo,
1102
+ acceptedAt: t.acceptedAt,
1103
+ parentTaskId: t.parentTaskId,
1104
+ scheduleId: t.scheduleId,
1105
+ model: t.model,
1106
+ provider: t.provider,
1107
+ requestedByUserId: t.requestedByUserId,
1108
+ progress: t.progress,
1109
+ createdAt: t.createdAt,
1110
+ lastUpdatedAt: t.lastUpdatedAt,
1111
+ finishedAt: t.finishedAt,
1112
+ peakContextPercent: t.peakContextPercent,
1113
+ };
1114
+ }
1115
+
1058
1116
  export const taskQueries = {
1059
1117
  insert: () =>
1060
1118
  getDb().prepare<
@@ -1224,7 +1282,7 @@ export function markTaskSlackReplySent(taskId: string): void {
1224
1282
  export function getChildTasks(parentTaskId: string): AgentTask[] {
1225
1283
  return getDb()
1226
1284
  .prepare<AgentTaskRow, [string]>(
1227
- `SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC`,
1285
+ `SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC, rowid ASC`,
1228
1286
  )
1229
1287
  .all(parentTaskId)
1230
1288
  .map(rowToAgentTask);
@@ -1348,7 +1406,15 @@ export interface TaskFilters {
1348
1406
  includeHeartbeat?: boolean;
1349
1407
  }
1350
1408
 
1351
- export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1409
+ export function getAllTasks(filters?: TaskFilters): AgentTask[];
1410
+ export function getAllTasks(
1411
+ filters: TaskFilters | undefined,
1412
+ opts: { slim: true },
1413
+ ): AgentTaskSummary[];
1414
+ export function getAllTasks(
1415
+ filters?: TaskFilters,
1416
+ opts?: { slim?: boolean },
1417
+ ): AgentTask[] | AgentTaskSummary[] {
1352
1418
  const conditions: string[] = [];
1353
1419
  const params: (string | AgentTaskStatus)[] = [];
1354
1420
 
@@ -1433,19 +1499,25 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1433
1499
  const offset = filters?.offset ?? 0;
1434
1500
  const query = `SELECT * FROM agent_tasks ${whereClause} ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit} OFFSET ${offset}`;
1435
1501
 
1436
- let tasks = getDb()
1502
+ const rows = getDb()
1437
1503
  .prepare<AgentTaskRow, (string | AgentTaskStatus)[]>(query)
1438
- .all(...params)
1439
- .map(rowToAgentTask);
1504
+ .all(...params);
1440
1505
 
1441
- // Filter for ready tasks (dependencies met) if requested
1442
- if (filters?.readyOnly) {
1443
- tasks = tasks.filter((task) => {
1444
- if (!task.dependsOn || task.dependsOn.length === 0) return true;
1445
- return checkDependencies(task.id).ready;
1446
- });
1506
+ // Filter for ready tasks (dependencies met) if requested. Both the full and
1507
+ // the slim row shapes carry `id` + `dependsOn`, so the same predicate works.
1508
+ const isReady = (task: { id: string; dependsOn: string[] }): boolean => {
1509
+ if (!task.dependsOn || task.dependsOn.length === 0) return true;
1510
+ return checkDependencies(task.id).ready;
1511
+ };
1512
+
1513
+ if (opts?.slim) {
1514
+ let tasks = rows.map(rowToAgentTaskSummary);
1515
+ if (filters?.readyOnly) tasks = tasks.filter(isReady);
1516
+ return tasks;
1447
1517
  }
1448
1518
 
1519
+ let tasks = rows.map(rowToAgentTask);
1520
+ if (filters?.readyOnly) tasks = tasks.filter(isReady);
1449
1521
  return tasks;
1450
1522
  }
1451
1523
 
@@ -2000,7 +2072,7 @@ export function getPausedTasksForAgent(agentId: string): AgentTask[] {
2000
2072
  .prepare<AgentTaskRow, [string]>(
2001
2073
  `SELECT * FROM agent_tasks
2002
2074
  WHERE agentId = ? AND status = 'paused'
2003
- ORDER BY createdAt ASC`,
2075
+ ORDER BY createdAt ASC, rowid ASC`,
2004
2076
  )
2005
2077
  .all(agentId);
2006
2078
  return rows.map(rowToAgentTask);
@@ -2054,6 +2126,185 @@ export function updateTaskProgress(id: string, progress: string): AgentTask | nu
2054
2126
  return row ? rowToAgentTask(row) : null;
2055
2127
  }
2056
2128
 
2129
+ // ============================================================================
2130
+ // Task Attachments (Phase 1 — pointer-based artifacts)
2131
+ // ============================================================================
2132
+ //
2133
+ // Pointer-only attachments live in their own table; `agent_tasks` is
2134
+ // untouched. Append-only in Phase 1 — `insertTaskAttachment` silently no-ops
2135
+ // on a duplicate (sha256 match, or kind+pointer+name tuple match) so
2136
+ // idempotent re-calls don't fan out duplicate rows. The `kind` enum here
2137
+ // MUST stay in sync with the SQL CHECK constraint (migration 072) and the
2138
+ // `TaskAttachmentKindSchema` zod enum.
2139
+
2140
+ type TaskAttachmentRow = {
2141
+ id: string;
2142
+ task_id: string;
2143
+ agent_id: string | null;
2144
+ name: string;
2145
+ kind: string;
2146
+ url: string | null;
2147
+ path: string | null;
2148
+ page_id: string | null;
2149
+ agent_fs_org_id: string | null;
2150
+ agent_fs_drive_id: string | null;
2151
+ mime_type: string | null;
2152
+ size_bytes: number | null;
2153
+ sha256: string | null;
2154
+ intent: string | null;
2155
+ description: string | null;
2156
+ is_primary: number;
2157
+ created_at: string;
2158
+ };
2159
+
2160
+ function rowToTaskAttachment(row: TaskAttachmentRow): TaskAttachment {
2161
+ return {
2162
+ id: row.id,
2163
+ taskId: row.task_id,
2164
+ agentId: row.agent_id,
2165
+ name: row.name,
2166
+ kind: row.kind as TaskAttachment["kind"],
2167
+ url: row.url ?? undefined,
2168
+ path: row.path ?? undefined,
2169
+ pageId: row.page_id ?? undefined,
2170
+ orgId: row.agent_fs_org_id ?? undefined,
2171
+ driveId: row.agent_fs_drive_id ?? undefined,
2172
+ mimeType: row.mime_type ?? undefined,
2173
+ sizeBytes: row.size_bytes ?? undefined,
2174
+ sha256: row.sha256 ?? undefined,
2175
+ intent: row.intent ?? undefined,
2176
+ description: row.description ?? undefined,
2177
+ isPrimary: !!row.is_primary,
2178
+ createdAt: row.created_at,
2179
+ };
2180
+ }
2181
+
2182
+ export interface InsertTaskAttachmentInput {
2183
+ taskId: string;
2184
+ agentId: string | null;
2185
+ name: string;
2186
+ kind: TaskAttachment["kind"];
2187
+ url?: string;
2188
+ path?: string;
2189
+ pageId?: string;
2190
+ /** agent-fs only — paired with `driveId` to build a public live-host URL. */
2191
+ orgId?: string;
2192
+ /** agent-fs only — paired with `orgId` to build a public live-host URL. */
2193
+ driveId?: string;
2194
+ mimeType?: string;
2195
+ sizeBytes?: number;
2196
+ sha256?: string;
2197
+ intent?: string;
2198
+ description?: string;
2199
+ isPrimary?: boolean;
2200
+ }
2201
+
2202
+ /**
2203
+ * Insert a task attachment. Append-only + dedup:
2204
+ * - if sha256 is present and a row for this task already has that sha256,
2205
+ * skip (return existing row);
2206
+ * - otherwise skip if a row exists for the same task with the same
2207
+ * (kind, path|url|page_id, name) tuple.
2208
+ * Returns the stored attachment (newly inserted or pre-existing duplicate).
2209
+ */
2210
+ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAttachment {
2211
+ const db = getDb();
2212
+
2213
+ if (input.sha256) {
2214
+ const existing = db
2215
+ .prepare<TaskAttachmentRow, [string, string]>(
2216
+ "SELECT * FROM task_attachments WHERE task_id = ? AND sha256 = ? LIMIT 1",
2217
+ )
2218
+ .get(input.taskId, input.sha256);
2219
+ if (existing) return rowToTaskAttachment(existing);
2220
+ }
2221
+
2222
+ const tupleExisting = db
2223
+ .prepare<TaskAttachmentRow, [string, string, string, string, string, string]>(
2224
+ `SELECT * FROM task_attachments
2225
+ WHERE task_id = ?
2226
+ AND kind = ?
2227
+ AND IFNULL(path, '') = ?
2228
+ AND IFNULL(url, '') = ?
2229
+ AND IFNULL(page_id, '') = ?
2230
+ AND name = ?
2231
+ ORDER BY created_at ASC
2232
+ LIMIT 1`,
2233
+ )
2234
+ .get(
2235
+ input.taskId,
2236
+ input.kind,
2237
+ input.path ?? "",
2238
+ input.url ?? "",
2239
+ input.pageId ?? "",
2240
+ input.name,
2241
+ );
2242
+ if (tupleExisting) return rowToTaskAttachment(tupleExisting);
2243
+
2244
+ const id = crypto.randomUUID();
2245
+ const row = db
2246
+ .prepare<
2247
+ TaskAttachmentRow,
2248
+ [
2249
+ string,
2250
+ string,
2251
+ string | null,
2252
+ string,
2253
+ string,
2254
+ string | null,
2255
+ string | null,
2256
+ string | null,
2257
+ string | null,
2258
+ string | null,
2259
+ string | null,
2260
+ number | null,
2261
+ string | null,
2262
+ string | null,
2263
+ string | null,
2264
+ number,
2265
+ ]
2266
+ >(
2267
+ `INSERT INTO task_attachments
2268
+ (id, task_id, agent_id, name, kind, url, path, page_id,
2269
+ agent_fs_org_id, agent_fs_drive_id,
2270
+ mime_type, size_bytes, sha256, intent, description, is_primary)
2271
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2272
+ RETURNING *`,
2273
+ )
2274
+ .get(
2275
+ id,
2276
+ input.taskId,
2277
+ input.agentId ?? null,
2278
+ input.name,
2279
+ input.kind,
2280
+ input.url ?? null,
2281
+ input.path ?? null,
2282
+ input.pageId ?? null,
2283
+ input.orgId ?? null,
2284
+ input.driveId ?? null,
2285
+ input.mimeType ?? null,
2286
+ input.sizeBytes ?? null,
2287
+ input.sha256 ?? null,
2288
+ input.intent ?? null,
2289
+ input.description ?? null,
2290
+ input.isPrimary ? 1 : 0,
2291
+ );
2292
+
2293
+ if (!row) {
2294
+ throw new Error("Failed to insert task attachment");
2295
+ }
2296
+ return rowToTaskAttachment(row);
2297
+ }
2298
+
2299
+ export function getTaskAttachments(taskId: string): TaskAttachment[] {
2300
+ return getDb()
2301
+ .prepare<TaskAttachmentRow, [string]>(
2302
+ "SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, rowid ASC",
2303
+ )
2304
+ .all(taskId)
2305
+ .map(rowToTaskAttachment);
2306
+ }
2307
+
2057
2308
  // ============================================================================
2058
2309
  // Combined Queries (Agent with Tasks)
2059
2310
  // ============================================================================
@@ -2070,9 +2321,9 @@ export function getAgentWithTasks(id: string): AgentWithTasks | null {
2070
2321
  return txn();
2071
2322
  }
2072
2323
 
2073
- export function getAllAgentsWithTasks(): AgentWithTasks[] {
2324
+ export function getAllAgentsWithTasks(opts?: { slim?: boolean }): AgentWithTasks[] {
2074
2325
  const txn = getDb().transaction(() => {
2075
- const agents = getAllAgents();
2326
+ const agents = getAllAgents({ slim: opts?.slim ?? false });
2076
2327
  return agents.map((agent) => ({
2077
2328
  ...agent,
2078
2329
  tasks: getTasksByAgentId(agent.id),
@@ -2166,8 +2417,13 @@ export function getLogsByAgentId(agentId: string): AgentLog[] {
2166
2417
  return logQueries.getByAgentId().all(agentId).map(rowToAgentLog);
2167
2418
  }
2168
2419
 
2169
- export function getLogsByTaskId(taskId: string): AgentLog[] {
2170
- return logQueries.getByTaskId().all(taskId).map(rowToAgentLog);
2420
+ export function getLogsByTaskId(taskId: string, limit = 200): AgentLog[] {
2421
+ return getDb()
2422
+ .prepare<AgentLogRow, [string, number]>(
2423
+ "SELECT * FROM agent_log WHERE taskId = ? ORDER BY createdAt DESC LIMIT ?",
2424
+ )
2425
+ .all(taskId, limit)
2426
+ .map(rowToAgentLog);
2171
2427
  }
2172
2428
 
2173
2429
  export function getLogsByTaskIdChronological(taskId: string): AgentLog[] {
@@ -2629,7 +2885,7 @@ export function releaseStaleReviewingTasks(timeoutMinutes: number = 30): number
2629
2885
  export function getOfferedTasksForAgent(agentId: string): AgentTask[] {
2630
2886
  return getDb()
2631
2887
  .prepare<AgentTaskRow, [string]>(
2632
- "SELECT * FROM agent_tasks WHERE offeredTo = ? AND status = 'offered' ORDER BY createdAt ASC",
2888
+ "SELECT * FROM agent_tasks WHERE offeredTo = ? AND status = 'offered' ORDER BY createdAt ASC, rowid ASC",
2633
2889
  )
2634
2890
  .all(agentId)
2635
2891
  .map(rowToAgentTask);
@@ -2682,7 +2938,7 @@ export function getUnassignedTasksCount(): number {
2682
2938
  export function getUnassignedTaskIds(limit = 10): string[] {
2683
2939
  const rows = getDb()
2684
2940
  .prepare<{ id: string }, [number]>(
2685
- "SELECT id FROM agent_tasks WHERE status = 'unassigned' ORDER BY priority DESC, createdAt ASC LIMIT ?",
2941
+ "SELECT id FROM agent_tasks WHERE status = 'unassigned' ORDER BY priority DESC, createdAt ASC, rowid ASC LIMIT ?",
2686
2942
  )
2687
2943
  .all(limit);
2688
2944
  return rows.map((r) => r.id);
@@ -4477,6 +4733,21 @@ type ScheduledTaskRow = {
4477
4733
  lastUpdatedAt: string;
4478
4734
  };
4479
4735
 
4736
+ // ── List-endpoint slimming helpers ──────────────────────────────────────────
4737
+ // List endpoints ship slim rows by default; heavy text fields are replaced
4738
+ // with bounded previews. Lengths are generous enough for triage/recognition
4739
+ // while keeping list payloads small.
4740
+ /** Preview length for a schedule's `taskTemplate`. */
4741
+ const SCHEDULE_TEMPLATE_PREVIEW_LENGTH = 280;
4742
+ /** Preview length for a task's `task` text (pool-triage needs to read it). */
4743
+ const TASK_PREVIEW_LENGTH = 300;
4744
+
4745
+ /** Truncate text for a list-row preview. Appends an ellipsis when clipped. */
4746
+ function previewText(text: string | null | undefined, maxChars: number): string {
4747
+ const s = text ?? "";
4748
+ return s.length > maxChars ? `${s.slice(0, maxChars)}…` : s;
4749
+ }
4750
+
4480
4751
  function rowToScheduledTask(row: ScheduledTaskRow): ScheduledTask {
4481
4752
  return {
4482
4753
  id: row.id,
@@ -4511,7 +4782,28 @@ export interface ScheduledTaskFilters {
4511
4782
  hideCompleted?: boolean;
4512
4783
  }
4513
4784
 
4514
- export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask[] {
4785
+ /**
4786
+ * Slim list-row mapper — replaces the full `taskTemplate` (the per-run prompt,
4787
+ * avg ~3.6 KB) with a bounded `taskTemplatePreview`. Fetch the full template
4788
+ * via `getScheduledTaskById(id)`.
4789
+ */
4790
+ function rowToScheduledTaskSummary(row: ScheduledTaskRow): ScheduledTaskSummary {
4791
+ const { taskTemplate, ...rest } = rowToScheduledTask(row);
4792
+ return {
4793
+ ...rest,
4794
+ taskTemplatePreview: previewText(taskTemplate, SCHEDULE_TEMPLATE_PREVIEW_LENGTH),
4795
+ };
4796
+ }
4797
+
4798
+ export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask[];
4799
+ export function getScheduledTasks(
4800
+ filters: ScheduledTaskFilters | undefined,
4801
+ opts: { slim: true },
4802
+ ): ScheduledTaskSummary[];
4803
+ export function getScheduledTasks(
4804
+ filters?: ScheduledTaskFilters,
4805
+ opts?: { slim?: boolean },
4806
+ ): ScheduledTask[] | ScheduledTaskSummary[] {
4515
4807
  let query = "SELECT * FROM scheduled_tasks WHERE 1=1";
4516
4808
  const params: (string | number)[] = [];
4517
4809
 
@@ -4536,10 +4828,10 @@ export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask
4536
4828
 
4537
4829
  query += " ORDER BY name ASC";
4538
4830
 
4539
- return getDb()
4831
+ const rows = getDb()
4540
4832
  .prepare<ScheduledTaskRow, (string | number)[]>(query)
4541
- .all(...params)
4542
- .map(rowToScheduledTask);
4833
+ .all(...params);
4834
+ return opts?.slim ? rows.map(rowToScheduledTaskSummary) : rows.map(rowToScheduledTask);
4543
4835
  }
4544
4836
 
4545
4837
  export function getScheduledTaskById(id: string): ScheduledTask | null {
@@ -5580,7 +5872,7 @@ export function getIdleWorkersWithCapacity(): Agent[] {
5580
5872
  WHERE status = 'idle' AND isLead = 0`,
5581
5873
  )
5582
5874
  .all()
5583
- .map(rowToAgent);
5875
+ .map((row) => rowToAgent(row));
5584
5876
 
5585
5877
  return agents.filter((agent) => {
5586
5878
  const activeCount = getActiveTaskCount(agent.id);
@@ -5739,7 +6031,43 @@ export function getWorkflow(id: string): Workflow | null {
5739
6031
  return row ? rowToWorkflow(row) : null;
5740
6032
  }
5741
6033
 
5742
- export function listWorkflows(filters?: { enabled?: boolean }): Workflow[] {
6034
+ /**
6035
+ * Slim list-row mapper — drops the heavy `definition` (avg ~18 KB/row) and the
6036
+ * trigger config, keeping a derived `nodeCount` so the list view can still
6037
+ * answer "how big is this workflow" without the full DAG. Fetch the full shape
6038
+ * via `getWorkflow(id)`.
6039
+ */
6040
+ function rowToWorkflowSummary(row: WorkflowRow): WorkflowSummary {
6041
+ let nodeCount = 0;
6042
+ try {
6043
+ const def = JSON.parse(row.definition) as WorkflowDefinition;
6044
+ nodeCount = Array.isArray(def?.nodes) ? def.nodes.length : 0;
6045
+ } catch {
6046
+ nodeCount = 0;
6047
+ }
6048
+ return {
6049
+ id: row.id,
6050
+ name: row.name,
6051
+ description: row.description ?? undefined,
6052
+ enabled: row.enabled === 1,
6053
+ dir: row.dir ?? undefined,
6054
+ vcsRepo: row.vcs_repo ?? undefined,
6055
+ createdByAgentId: row.createdByAgentId ?? undefined,
6056
+ createdAt: normalizeDateRequired(row.createdAt),
6057
+ lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
6058
+ nodeCount,
6059
+ };
6060
+ }
6061
+
6062
+ export function listWorkflows(filters?: { enabled?: boolean }): Workflow[];
6063
+ export function listWorkflows(
6064
+ filters: { enabled?: boolean } | undefined,
6065
+ opts: { slim: true },
6066
+ ): WorkflowSummary[];
6067
+ export function listWorkflows(
6068
+ filters?: { enabled?: boolean },
6069
+ opts?: { slim?: boolean },
6070
+ ): Workflow[] | WorkflowSummary[] {
5743
6071
  let query = "SELECT * FROM workflows WHERE 1=1";
5744
6072
  const params: (string | number)[] = [];
5745
6073
  if (filters?.enabled !== undefined) {
@@ -5747,10 +6075,10 @@ export function listWorkflows(filters?: { enabled?: boolean }): Workflow[] {
5747
6075
  params.push(filters.enabled ? 1 : 0);
5748
6076
  }
5749
6077
  query += " ORDER BY name ASC";
5750
- return getDb()
6078
+ const rows = getDb()
5751
6079
  .prepare<WorkflowRow, (string | number)[]>(query)
5752
- .all(...params)
5753
- .map(rowToWorkflow);
6080
+ .all(...params);
6081
+ return opts?.slim ? rows.map(rowToWorkflowSummary) : rows.map(rowToWorkflow);
5754
6082
  }
5755
6083
 
5756
6084
  export function updateWorkflow(
@@ -6378,22 +6706,84 @@ export function getPageBySlug(agentId: string, slug: string): Page | null {
6378
6706
  return row ? rowToPage(row) : null;
6379
6707
  }
6380
6708
 
6381
- export function listPagesByAgent(agentId: string, limit = 100, offset = 0): Page[] {
6382
- return getDb()
6709
+ /**
6710
+ * Slim list-row mapper — drops the page `body` (the full HTML/JSON document,
6711
+ * up to ~290 KB and ~95% of a list payload) and `passwordHash`. Fetch the
6712
+ * full page via `getPage(id)`.
6713
+ */
6714
+ function rowToPageSummary(row: PageRow): PageSummary {
6715
+ return {
6716
+ id: row.id,
6717
+ agentId: row.agentId,
6718
+ slug: row.slug,
6719
+ title: row.title,
6720
+ description: row.description ?? undefined,
6721
+ contentType: row.contentType as PageContentType,
6722
+ authMode: row.authMode as PageAuthMode,
6723
+ needsCredentials: row.needsCredentials
6724
+ ? (JSON.parse(row.needsCredentials) as string[])
6725
+ : undefined,
6726
+ viewCount: typeof row.view_count === "number" ? row.view_count : 0,
6727
+ createdAt: normalizeDateRequired(row.createdAt),
6728
+ updatedAt: normalizeDateRequired(row.updatedAt),
6729
+ };
6730
+ }
6731
+
6732
+ export function listPagesByAgent(agentId: string, limit?: number, offset?: number): Page[];
6733
+ export function listPagesByAgent(
6734
+ agentId: string,
6735
+ limit: number | undefined,
6736
+ offset: number | undefined,
6737
+ opts: { slim: true },
6738
+ ): PageSummary[];
6739
+ export function listPagesByAgent(
6740
+ agentId: string,
6741
+ limit = 100,
6742
+ offset = 0,
6743
+ opts?: { slim?: boolean },
6744
+ ): Page[] | PageSummary[] {
6745
+ const rows = getDb()
6383
6746
  .prepare<PageRow, [string, number, number]>(
6384
6747
  "SELECT * FROM pages WHERE agentId = ? ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6385
6748
  )
6386
- .all(agentId, limit, offset)
6387
- .map(rowToPage);
6388
- }
6389
-
6390
- export function listAllPages(limit = 100, offset = 0): Page[] {
6391
- return getDb()
6749
+ .all(agentId, limit, offset);
6750
+ return opts?.slim ? rows.map(rowToPageSummary) : rows.map(rowToPage);
6751
+ }
6752
+
6753
+ export function listAllPages(limit?: number, offset?: number): Page[];
6754
+ export function listAllPages(
6755
+ limit: number | undefined,
6756
+ offset: number | undefined,
6757
+ opts: { slim: true },
6758
+ ): PageSummary[];
6759
+ export function listAllPages(
6760
+ limit = 100,
6761
+ offset = 0,
6762
+ opts?: { slim?: boolean },
6763
+ ): Page[] | PageSummary[] {
6764
+ const rows = getDb()
6392
6765
  .prepare<PageRow, [number, number]>(
6393
6766
  "SELECT * FROM pages ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6394
6767
  )
6395
- .all(limit, offset)
6396
- .map(rowToPage);
6768
+ .all(limit, offset);
6769
+ return opts?.slim ? rows.map(rowToPageSummary) : rows.map(rowToPage);
6770
+ }
6771
+
6772
+ /**
6773
+ * Total page count — used to back a filter-aware `total` in the `/api/pages`
6774
+ * pager so the UI shows the real count, not just the current page's length.
6775
+ */
6776
+ export function countAllPages(): number {
6777
+ const row = getDb().prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM pages").get();
6778
+ return row?.count ?? 0;
6779
+ }
6780
+
6781
+ /** Page count scoped to a single agent — companion to `listPagesByAgent`. */
6782
+ export function countPagesByAgent(agentId: string): number {
6783
+ const row = getDb()
6784
+ .prepare<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM pages WHERE agentId = ?")
6785
+ .get(agentId);
6786
+ return row?.count ?? 0;
6397
6787
  }
6398
6788
 
6399
6789
  /**
@@ -7842,11 +8232,16 @@ export interface SkillFilters {
7842
8232
  includeContent?: boolean;
7843
8233
  }
7844
8234
 
8235
+ /**
8236
+ * Explicit column list used when `includeContent: false` — selects every
8237
+ * skill column except the heavy `content` (the full SKILL.md, avg ~10 KB),
8238
+ * which is replaced with an empty string so the row still satisfies `Skill`.
8239
+ */
8240
+ const SKILL_SLIM_COLUMNS =
8241
+ "id, name, description, type, scope, ownerAgentId, sourceUrl, sourceRepo, sourcePath, sourceBranch, sourceHash, isComplex, allowedTools, model, effort, context, agent, disableModelInvocation, userInvocable, version, isEnabled, createdAt, lastUpdatedAt, lastFetchedAt, '' as content";
8242
+
7845
8243
  export function listSkills(filters?: SkillFilters): Skill[] {
7846
- const columns =
7847
- filters?.includeContent === false
7848
- ? "id, name, description, type, scope, ownerAgentId, sourceUrl, sourceRepo, sourcePath, sourceBranch, sourceHash, isComplex, allowedTools, model, effort, context, agent, disableModelInvocation, userInvocable, version, isEnabled, createdAt, lastUpdatedAt, lastFetchedAt, '' as content"
7849
- : "*";
8244
+ const columns = filters?.includeContent === false ? SKILL_SLIM_COLUMNS : "*";
7850
8245
  let query = `SELECT ${columns} FROM skills WHERE 1=1`;
7851
8246
  const params: (string | number)[] = [];
7852
8247
 
@@ -7885,11 +8280,12 @@ export function listSkills(filters?: SkillFilters): Skill[] {
7885
8280
  .map(rowToSkill);
7886
8281
  }
7887
8282
 
7888
- export function searchSkills(query: string, limit = 20): Skill[] {
8283
+ export function searchSkills(query: string, limit = 20, includeContent = true): Skill[] {
7889
8284
  const term = `%${query}%`;
8285
+ const columns = includeContent === false ? SKILL_SLIM_COLUMNS : "*";
7890
8286
  return getDb()
7891
8287
  .prepare<SkillRow, [string, string, number]>(
7892
- "SELECT * FROM skills WHERE (name LIKE ? OR description LIKE ?) AND isEnabled = 1 ORDER BY name ASC LIMIT ?",
8288
+ `SELECT ${columns} FROM skills WHERE (name LIKE ? OR description LIKE ?) AND isEnabled = 1 ORDER BY name ASC LIMIT ?`,
7893
8289
  )
7894
8290
  .all(term, term, limit)
7895
8291
  .map(rowToSkill);
@@ -9094,16 +9490,30 @@ export interface SessionListItem {
9094
9490
  latestStatus: AgentTaskStatus;
9095
9491
  }
9096
9492
 
9493
+ /**
9494
+ * Slim variant of {@link SessionListItem} — the `root` task is an
9495
+ * `AgentTaskSummary` (full `task` text + completion/integration blobs dropped).
9496
+ * The session list only renders a brief of the root; the full root + chain are
9497
+ * on `GET /api/sessions/{rootTaskId}`.
9498
+ */
9499
+ export interface SessionListItemSummary {
9500
+ root: AgentTaskSummary;
9501
+ chainTaskCount: number;
9502
+ lastActivityAt: string;
9503
+ latestStatus: AgentTaskStatus;
9504
+ }
9505
+
9097
9506
  /**
9098
9507
  * List the most recent sessions ordered by chain-wide latest activity.
9099
9508
  * A "session" here is any task with `parentTaskId IS NULL` — its descendants
9100
9509
  * (children, grand-children, …) are summarized via the recursive CTE.
9101
9510
  *
9102
- * `lastActivityAt` is `MAX(t.lastUpdatedAt)` over the entire chain rooted at
9103
- * the candidate task, computed as a correlated subquery so the outer ORDER
9104
- * BY can sort against it.
9511
+ * Single-pass CTE: seeds with root tasks matching the filter, walks the full
9512
+ * descendant tree once, then aggregates chainCount / lastActivityAt /
9513
+ * latestStatus in two lightweight non-recursive CTEs — replacing the original
9514
+ * pattern of 3 correlated subqueries each re-running the recursion per row.
9105
9515
  */
9106
- export function listRecentSessions(opts?: {
9516
+ interface ListRecentSessionsOpts {
9107
9517
  limit?: number;
9108
9518
  offset?: number;
9109
9519
  /** Filter to root tasks whose `source` is in this list. Empty/undefined → no source filter. */
@@ -9112,7 +9522,19 @@ export function listRecentSessions(opts?: {
9112
9522
  q?: string;
9113
9523
  /** When set, restrict to root tasks where `requestedByUserId` equals this value. NULL rows are excluded. */
9114
9524
  requestedByUserId?: string;
9115
- }): SessionListItem[] {
9525
+ /** When true, return slim `SessionListItemSummary` rows (default: full). */
9526
+ slim?: boolean;
9527
+ }
9528
+
9529
+ export function listRecentSessions(
9530
+ opts?: ListRecentSessionsOpts & { slim?: false },
9531
+ ): SessionListItem[];
9532
+ export function listRecentSessions(
9533
+ opts: ListRecentSessionsOpts & { slim: true },
9534
+ ): SessionListItemSummary[];
9535
+ export function listRecentSessions(
9536
+ opts?: ListRecentSessionsOpts,
9537
+ ): SessionListItem[] | SessionListItemSummary[] {
9116
9538
  const limit = opts?.limit ?? 25;
9117
9539
  const offset = opts?.offset ?? 0;
9118
9540
  const sources = opts?.source?.filter((s) => s.length > 0) ?? [];
@@ -9141,48 +9563,55 @@ export function listRecentSessions(opts?: {
9141
9563
  AgentTaskRow & { __chainCount: number; __lastActivityAt: string; __latestStatus: string },
9142
9564
  typeof params
9143
9565
  >(
9144
- `SELECT
9566
+ `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status) AS (
9567
+ SELECT r.id, r.id, r.lastUpdatedAt, r.status
9568
+ FROM agent_tasks r
9569
+ WHERE ${conditions.join(" AND ")}
9570
+ UNION ALL
9571
+ SELECT c.root_id, t.id, t.lastUpdatedAt, t.status
9572
+ FROM agent_tasks t
9573
+ JOIN chain c ON t.parentTaskId = c.id
9574
+ ),
9575
+ agg AS (
9576
+ SELECT
9577
+ root_id,
9578
+ COUNT(*) AS chainCount,
9579
+ MAX(lastUpdatedAt) AS lastActivityAt
9580
+ FROM chain
9581
+ GROUP BY root_id
9582
+ ),
9583
+ latest_status AS (
9584
+ SELECT c.root_id, c.status AS latestStatus
9585
+ FROM chain c
9586
+ JOIN agg a ON c.root_id = a.root_id AND c.lastUpdatedAt = a.lastActivityAt
9587
+ GROUP BY c.root_id
9588
+ )
9589
+ SELECT
9145
9590
  r.*,
9146
- (SELECT COUNT(*) FROM agent_tasks d
9147
- WHERE d.id IN (
9148
- WITH RECURSIVE chain(id) AS (
9149
- SELECT r.id
9150
- UNION ALL
9151
- SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
9152
- )
9153
- SELECT id FROM chain
9154
- )
9155
- ) AS __chainCount,
9156
- (SELECT MAX(d.lastUpdatedAt) FROM agent_tasks d
9157
- WHERE d.id IN (
9158
- WITH RECURSIVE chain(id) AS (
9159
- SELECT r.id
9160
- UNION ALL
9161
- SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
9162
- )
9163
- SELECT id FROM chain
9164
- )
9165
- ) AS __lastActivityAt,
9166
- (SELECT d.status FROM agent_tasks d
9167
- WHERE d.id IN (
9168
- WITH RECURSIVE chain(id) AS (
9169
- SELECT r.id
9170
- UNION ALL
9171
- SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
9172
- )
9173
- SELECT id FROM chain
9174
- )
9175
- ORDER BY d.lastUpdatedAt DESC
9176
- LIMIT 1
9177
- ) AS __latestStatus
9591
+ a.chainCount AS __chainCount,
9592
+ a.lastActivityAt AS __lastActivityAt,
9593
+ COALESCE(ls.latestStatus, r.status) AS __latestStatus
9178
9594
  FROM agent_tasks r
9179
- WHERE ${conditions.join(" AND ")}
9180
- ORDER BY __lastActivityAt DESC
9595
+ JOIN agg a ON a.root_id = r.id
9596
+ LEFT JOIN latest_status ls ON ls.root_id = r.id
9597
+ ORDER BY a.lastActivityAt DESC
9181
9598
  LIMIT ? OFFSET ?`,
9182
9599
  )
9183
9600
  .all(...params);
9184
9601
 
9185
- return rootRows.map((row) => {
9602
+ if (opts?.slim) {
9603
+ return rootRows.map((row): SessionListItemSummary => {
9604
+ const { __chainCount, __lastActivityAt, __latestStatus, ...taskRow } = row;
9605
+ return {
9606
+ root: rowToAgentTaskSummary(taskRow as AgentTaskRow),
9607
+ chainTaskCount: __chainCount,
9608
+ lastActivityAt: __lastActivityAt ?? row.lastUpdatedAt,
9609
+ latestStatus: (__latestStatus as AgentTaskStatus) ?? row.status,
9610
+ };
9611
+ });
9612
+ }
9613
+
9614
+ return rootRows.map((row): SessionListItem => {
9186
9615
  const { __chainCount, __lastActivityAt, __latestStatus, ...taskRow } = row;
9187
9616
  return {
9188
9617
  root: rowToAgentTask(taskRow as AgentTaskRow),
@@ -9193,6 +9622,43 @@ export function listRecentSessions(opts?: {
9193
9622
  });
9194
9623
  }
9195
9624
 
9625
+ /**
9626
+ * Filter-aware count of sessions (root tasks) matching the same `source` / `q`
9627
+ * / `requestedByUserId` filters as `listRecentSessions`. Powers a correct
9628
+ * `total` in the `/api/sessions` pager — a session is a root task, so this is
9629
+ * a plain count, no recursive chain walk needed.
9630
+ */
9631
+ export function countSessions(
9632
+ opts?: Pick<ListRecentSessionsOpts, "source" | "q" | "requestedByUserId">,
9633
+ ): number {
9634
+ const sources = opts?.source?.filter((s) => s.length > 0) ?? [];
9635
+ const q = opts?.q?.trim();
9636
+ const requestedByUserId = opts?.requestedByUserId?.trim() || undefined;
9637
+
9638
+ const conditions: string[] = ["parentTaskId IS NULL"];
9639
+ const params: string[] = [];
9640
+
9641
+ if (sources.length > 0) {
9642
+ conditions.push(`source IN (${sources.map(() => "?").join(", ")})`);
9643
+ params.push(...sources);
9644
+ }
9645
+ if (q && q.length > 0) {
9646
+ conditions.push("lower(task) LIKE ?");
9647
+ params.push(`%${q.toLowerCase()}%`);
9648
+ }
9649
+ if (requestedByUserId) {
9650
+ conditions.push("requestedByUserId = ?");
9651
+ params.push(requestedByUserId);
9652
+ }
9653
+
9654
+ const row = getDb()
9655
+ .prepare<{ count: number }, string[]>(
9656
+ `SELECT COUNT(*) AS count FROM agent_tasks WHERE ${conditions.join(" AND ")}`,
9657
+ )
9658
+ .get(...params);
9659
+ return row?.count ?? 0;
9660
+ }
9661
+
9196
9662
  // ============================================================================
9197
9663
  // Budgets, daily-spend aggregation, and budget-refusal notifications (Phase 2)
9198
9664
  // ----------------------------------------------------------------------------
@@ -9685,6 +10151,60 @@ export function getInstanceActivity(): {
9685
10151
  };
9686
10152
  }
9687
10153
 
10154
+ export interface SwarmMetrics {
10155
+ tasks: { total: number; by_status: Record<string, number> };
10156
+ agents: { total: number; by_status: Record<string, number> };
10157
+ workflows: { total: number; enabled: number };
10158
+ pages: { total: number };
10159
+ sessions: { active: number };
10160
+ skills: { total: number };
10161
+ }
10162
+
10163
+ /**
10164
+ * Lightweight swarm-wide counts for UI footers/sidebars and MCP context —
10165
+ * a single object so callers never have to fetch full list payloads just to
10166
+ * count. Pure `COUNT(*)` / `GROUP BY` queries; the `agent_tasks` status
10167
+ * grouping rides the indexes added in migration 069.
10168
+ */
10169
+ export function getSwarmMetrics(): SwarmMetrics {
10170
+ const db = getDb();
10171
+
10172
+ const groupCounts = (table: string): { total: number; by_status: Record<string, number> } => {
10173
+ const rows = db
10174
+ .prepare<{ status: string; count: number }, []>(
10175
+ `SELECT status, COUNT(*) AS count FROM ${table} GROUP BY status`,
10176
+ )
10177
+ .all();
10178
+ const by_status: Record<string, number> = {};
10179
+ let total = 0;
10180
+ for (const r of rows) {
10181
+ by_status[r.status] = r.count;
10182
+ total += r.count;
10183
+ }
10184
+ return { total, by_status };
10185
+ };
10186
+
10187
+ const workflowRow = db
10188
+ .prepare<{ total: number; enabled: number }, []>(
10189
+ "SELECT COUNT(*) AS total, SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS enabled FROM workflows",
10190
+ )
10191
+ .get();
10192
+ const pagesRow = db.prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM pages").get();
10193
+ const sessionsRow = db
10194
+ .prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM active_sessions")
10195
+ .get();
10196
+ const skillsRow = db.prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM skills").get();
10197
+
10198
+ return {
10199
+ tasks: groupCounts("agent_tasks"),
10200
+ agents: groupCounts("agents"),
10201
+ workflows: { total: workflowRow?.total ?? 0, enabled: workflowRow?.enabled ?? 0 },
10202
+ pages: { total: pagesRow?.count ?? 0 },
10203
+ sessions: { active: sessionsRow?.count ?? 0 },
10204
+ skills: { total: skillsRow?.count ?? 0 },
10205
+ };
10206
+ }
10207
+
9688
10208
  /**
9689
10209
  * `first_task` milestone: true once any task has reached `status = 'completed'`.
9690
10210
  * Cheap LIMIT 1 probe; the row's contents don't matter, only existence.