@desplega.ai/agent-swarm 1.75.0 → 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 (48) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +973 -36
  3. package/package.json +2 -2
  4. package/src/be/db.ts +527 -9
  5. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  6. package/src/be/memory/raters/llm.ts +56 -75
  7. package/src/be/memory/retrieval-store.ts +21 -0
  8. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  9. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  10. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  11. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  12. package/src/be/migrations/058_task_templates.sql +31 -0
  13. package/src/be/swarm-config-guard.ts +24 -0
  14. package/src/commands/credential-wait.ts +1 -1
  15. package/src/commands/provider-credentials.ts +434 -0
  16. package/src/commands/runner.ts +229 -42
  17. package/src/hooks/hook.ts +115 -95
  18. package/src/http/agents.ts +82 -2
  19. package/src/http/config.ts +11 -1
  20. package/src/http/inbox-state.ts +89 -0
  21. package/src/http/index.ts +10 -0
  22. package/src/http/sessions.ts +86 -0
  23. package/src/http/status.ts +665 -0
  24. package/src/http/task-templates.ts +51 -0
  25. package/src/http/tasks.ts +85 -5
  26. package/src/http/users.ts +134 -0
  27. package/src/providers/claude-adapter.ts +5 -0
  28. package/src/providers/codex-adapter.ts +1 -1
  29. package/src/providers/index.ts +1 -1
  30. package/src/slack/handlers.ts +0 -1
  31. package/src/tests/agents-harness-provider.test.ts +333 -0
  32. package/src/tests/credential-check.test.ts +32 -1
  33. package/src/tests/credential-status-api.test.ts +42 -0
  34. package/src/tests/harness-provider-resolution.test.ts +242 -0
  35. package/src/tests/jira-sync.test.ts +1 -1
  36. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  37. package/src/tests/memory-rater-llm.test.ts +265 -107
  38. package/src/tests/migration-runner-regressions.test.ts +17 -2
  39. package/src/tests/sessions.test.ts +141 -0
  40. package/src/tests/status.test.ts +843 -0
  41. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  42. package/src/tests/template-recommendations.test.ts +148 -0
  43. package/src/tests/use-dismissible-card.test.ts +140 -0
  44. package/src/tools/swarm-config/set-config.ts +17 -1
  45. package/src/types.ts +117 -0
  46. package/src/utils/harness-provider.ts +32 -0
  47. package/tsconfig.json +0 -2
  48. package/src/providers/credentials.ts +0 -74
package/src/hooks/hook.ts CHANGED
@@ -4,13 +4,13 @@ import pkg from "../../package.json";
4
4
  import {
5
5
  buildRatingsFromLlm,
6
6
  buildSummaryWithRatingsPrompt,
7
- extractSummaryFromClaudeStdout,
7
+ dedupeRetrievalsForRater,
8
8
  fetchRetrievalsForTask,
9
9
  isLlmRaterEnabled,
10
- parseSummaryWithRatings,
11
10
  postRatings,
12
11
  type RetrievalRow,
13
12
  } from "../be/memory/raters/llm";
13
+ import { runMemoryRater } from "../be/memory/raters/llm-summarizer";
14
14
  import type { Agent } from "../types";
15
15
  import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
16
16
 
@@ -205,6 +205,32 @@ async function restoreClaudeMdBackup(): Promise<void> {
205
205
  }
206
206
  }
207
207
 
208
+ /**
209
+ * Resolve task context for the Stop-hook session-summary / memory-rater
210
+ * piggyback. Prefers the AGENT_SWARM_TASK_ID env var (set by the harness in
211
+ * `claude-adapter.ts`) so the rater still works when the on-disk TASK_FILE
212
+ * was already cleaned up — the silent-drop bug PR #444 traced.
213
+ */
214
+ export async function resolveStopHookTaskContext(
215
+ env: Record<string, string | undefined> = process.env,
216
+ ): Promise<{ taskContext: string; taskId: string | undefined }> {
217
+ let taskContext = "";
218
+ let taskId: string | undefined = env.AGENT_SWARM_TASK_ID || undefined;
219
+ const taskFile = env.TASK_FILE;
220
+ if (taskFile) {
221
+ try {
222
+ const taskData = JSON.parse(await Bun.file(taskFile).text());
223
+ taskContext = `Task: ${taskData.task || "Unknown"}`;
224
+ if (!taskId) taskId = taskData.id;
225
+ } catch (err) {
226
+ // Don't blackhole the read failure — log it once so future regressions
227
+ // are visible. Same one-line debug pattern as PR #444's gate trace.
228
+ console.error("[memory-rater:llm] TASK_FILE read failed:", (err as Error).message);
229
+ }
230
+ }
231
+ return { taskContext, taskId };
232
+ }
233
+
208
234
  /**
209
235
  * Main hook handler - processes Claude Code hook events
210
236
  */
@@ -1053,9 +1079,19 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
1053
1079
  }
1054
1080
  }
1055
1081
 
1056
- // Session summarization via Claude Haiku
1057
- // Skip if this is a child session spawned by the summarization itself (prevents recursion)
1058
- if (agentInfo?.id && msg.transcript_path && !process.env.SKIP_SESSION_SUMMARY) {
1082
+ // Session summarization + LLM rater piggyback via OpenRouter (Vercel AI SDK).
1083
+ //
1084
+ // No-op when OPENROUTER_API_KEY is unset — self-hosters / OSS users
1085
+ // without OpenRouter skip session summary + LLM ratings entirely. The
1086
+ // previous `claude -p` path silently produced "Not logged in · Please
1087
+ // run /login" rows after the 2026-05-05 CLAUDE_CODE_VERSION bump
1088
+ // stopped propagating CLAUDE_CODE_OAUTH_TOKEN to hook subprocesses.
1089
+ if (
1090
+ agentInfo?.id &&
1091
+ msg.transcript_path &&
1092
+ !process.env.SKIP_SESSION_SUMMARY &&
1093
+ process.env.OPENROUTER_API_KEY
1094
+ ) {
1059
1095
  try {
1060
1096
  let transcript = "";
1061
1097
  try {
@@ -1067,19 +1103,10 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
1067
1103
  }
1068
1104
 
1069
1105
  if (transcript.length > 100) {
1070
- // Read task context if available
1071
- let taskContext = "";
1072
- let taskId: string | undefined;
1073
- const taskFile = process.env.TASK_FILE;
1074
- if (taskFile) {
1075
- try {
1076
- const taskData = JSON.parse(await Bun.file(taskFile).text());
1077
- taskContext = `Task: ${taskData.task || "Unknown"}`;
1078
- taskId = taskData.id;
1079
- } catch {
1080
- /* no task file */
1081
- }
1082
- }
1106
+ // Prefer AGENT_SWARM_TASK_ID env var; fall back to TASK_FILE on
1107
+ // disk. PR #444 gate-trace showed the file disappears mid-session
1108
+ // and the silent catch dropped every LLM rater piggyback.
1109
+ const { taskContext, taskId } = await resolveStopHookTaskContext();
1083
1110
 
1084
1111
  const apiUrl =
1085
1112
  process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
@@ -1091,15 +1118,17 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
1091
1118
  const llmRaterEnabled = isLlmRaterEnabled();
1092
1119
  let retrievals: RetrievalRow[] = [];
1093
1120
  if (llmRaterEnabled && taskId) {
1094
- retrievals = await fetchRetrievalsForTask({
1121
+ const rawRetrievals = await fetchRetrievalsForTask({
1095
1122
  apiUrl,
1096
1123
  apiKey,
1097
1124
  agentId: agentInfo.id,
1098
1125
  taskId,
1099
1126
  });
1127
+ // Dedup self-similar cron-task memories before sending to the
1128
+ // rater — see `dedupeRetrievalsForRater` doc for the why.
1129
+ retrievals = dedupeRetrievalsForRater(rawRetrievals);
1100
1130
  }
1101
1131
 
1102
- // Summarize with Claude Haiku — extract only high-value learnings
1103
1132
  const baseSummarizePrompt = `You are summarizing an AI agent's work session. Extract ONLY high-value learnings.
1104
1133
 
1105
1134
  DO NOT include:
@@ -1119,85 +1148,76 @@ ${taskContext ? `\nTask context: ${taskContext}` : ""}
1119
1148
  Transcript:
1120
1149
  ${transcript}`;
1121
1150
 
1122
- const wantsRatings = llmRaterEnabled && retrievals.length > 0;
1123
- const summarizePrompt = wantsRatings
1124
- ? buildSummaryWithRatingsPrompt(baseSummarizePrompt, retrievals)
1125
- : baseSummarizePrompt;
1126
-
1127
- const tmpFile = `/tmp/session-summary-${Date.now()}.txt`;
1128
- await Bun.write(tmpFile, summarizePrompt);
1129
- const proc = Bun.spawn(
1130
- ["bash", "-c", `cat "${tmpFile}" | claude -p --model haiku --output-format json`],
1131
- {
1132
- stdout: "pipe",
1133
- stderr: "pipe",
1134
- env: { ...process.env, SKIP_SESSION_SUMMARY: "1" },
1135
- },
1136
- );
1137
- const timeoutId = setTimeout(() => proc.kill(), 30000);
1138
- const result = { stdout: await new Response(proc.stdout).text() };
1139
- clearTimeout(timeoutId);
1140
- await Bun.$`rm -f ${tmpFile}`.quiet();
1141
-
1142
- let summary: string;
1143
- let parsedRatings: ReturnType<typeof parseSummaryWithRatings> = null;
1144
- if (wantsRatings) {
1145
- parsedRatings = parseSummaryWithRatings(result.stdout);
1146
- }
1147
- if (parsedRatings) {
1148
- summary = parsedRatings.summary;
1149
- } else {
1150
- // Fallback: never index raw JSON. extractSummaryFromClaudeStdout
1151
- // pulls the `summary` field out of a structured-output payload
1152
- // whose ratings failed schema validation; otherwise behaves
1153
- // like the previous unstructured envelope.result extraction.
1154
- summary = extractSummaryFromClaudeStdout(result.stdout);
1155
- }
1151
+ // Always ask for the structured (summary + ratings) payload — same
1152
+ // cost as the unstructured path. Empty retrievals → empty memory
1153
+ // block → ratings: []; the postRatings gate below still skips the
1154
+ // POST when there's nothing to send.
1155
+ const summarizePrompt = buildSummaryWithRatingsPrompt(baseSummarizePrompt, retrievals);
1156
1156
 
1157
- // Skip indexing if the session had no significant learnings
1158
- if (
1159
- summary &&
1160
- summary.length > 20 &&
1161
- !summary.trim().toLowerCase().includes("no significant learnings")
1162
- ) {
1163
- await fetch(`${apiUrl}/api/memory/index`, {
1164
- method: "POST",
1165
- headers: {
1166
- "Content-Type": "application/json",
1167
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
1168
- "X-Agent-ID": agentInfo.id,
1169
- },
1170
- body: JSON.stringify({
1171
- agentId: agentInfo.id,
1172
- content: summary,
1173
- name: taskContext
1174
- ? `Session: ${taskContext.slice(0, 80)}`
1175
- : `Session: ${new Date().toISOString().slice(0, 16)}`,
1176
- scope: "agent",
1177
- source: "session_summary",
1178
- ...(taskId ? { sourceTaskId: taskId } : {}),
1179
- }),
1157
+ const raterResult = await runMemoryRater({
1158
+ prompt: summarizePrompt,
1159
+ apiKey: process.env.OPENROUTER_API_KEY,
1160
+ });
1161
+ if (!raterResult.ok) {
1162
+ console.error("[memory-rater:llm] runMemoryRater returned non-ok", {
1163
+ reason: raterResult.reason,
1164
+ ...(raterResult.status !== undefined ? { status: raterResult.status } : {}),
1180
1165
  });
1181
- }
1182
-
1183
- // Best-effort: post LLM ratings. Never blocks summary indexing.
1184
- if (parsedRatings && parsedRatings.ratings.length > 0) {
1185
- try {
1186
- const events = buildRatingsFromLlm(parsedRatings.ratings, retrievals);
1187
- if (events.length > 0) {
1188
- await postRatings({
1189
- apiUrl,
1190
- apiKey,
1166
+ } else {
1167
+ const summary = raterResult.data.summary;
1168
+ const ratings = raterResult.data.ratings;
1169
+
1170
+ // Skip indexing if the session had no significant learnings
1171
+ if (
1172
+ summary &&
1173
+ summary.length > 20 &&
1174
+ !summary.trim().toLowerCase().includes("no significant learnings")
1175
+ ) {
1176
+ await fetch(`${apiUrl}/api/memory/index`, {
1177
+ method: "POST",
1178
+ headers: {
1179
+ "Content-Type": "application/json",
1180
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
1181
+ "X-Agent-ID": agentInfo.id,
1182
+ },
1183
+ body: JSON.stringify({
1191
1184
  agentId: agentInfo.id,
1192
- taskId,
1193
- events,
1194
- });
1185
+ content: summary,
1186
+ name: taskContext
1187
+ ? `Session: ${taskContext.slice(0, 80)}`
1188
+ : `Session: ${new Date().toISOString().slice(0, 16)}`,
1189
+ scope: "agent",
1190
+ source: "session_summary",
1191
+ ...(taskId ? { sourceTaskId: taskId } : {}),
1192
+ }),
1193
+ });
1194
+ }
1195
+
1196
+ // Best-effort: post LLM ratings. Never blocks summary indexing.
1197
+ if (llmRaterEnabled && taskId && retrievals.length > 0 && ratings.length === 0) {
1198
+ console.error("[memory-rater:llm] piggyback produced no ratings", {
1199
+ retrievalsLen: retrievals.length,
1200
+ ratingsLen: 0,
1201
+ });
1202
+ }
1203
+ if (llmRaterEnabled && taskId && ratings.length > 0) {
1204
+ try {
1205
+ const events = buildRatingsFromLlm(ratings, retrievals);
1206
+ if (events.length > 0) {
1207
+ await postRatings({
1208
+ apiUrl,
1209
+ apiKey,
1210
+ agentId: agentInfo.id,
1211
+ taskId,
1212
+ events,
1213
+ });
1214
+ }
1215
+ } catch (err) {
1216
+ console.error(
1217
+ "[memory-rater:llm] piggyback rating emission failed:",
1218
+ (err as Error).message,
1219
+ );
1195
1220
  }
1196
- } catch (err) {
1197
- console.error(
1198
- "[memory-rater:llm] piggyback rating emission failed:",
1199
- (err as Error).message,
1200
- );
1201
1221
  }
1202
1222
  }
1203
1223
  }
@@ -10,15 +10,18 @@ import {
10
10
  getDb,
11
11
  getSwarmConfigs,
12
12
  resetEmptyPollCount,
13
+ setAgentHarnessProvider,
13
14
  updateAgentActivity,
14
15
  updateAgentCredentialState,
16
+ updateAgentCredStatus,
15
17
  updateAgentMaxTasks,
16
18
  updateAgentName,
17
19
  updateAgentProfile,
18
20
  updateAgentProvider,
19
21
  updateAgentStatus,
22
+ upsertSwarmConfig,
20
23
  } from "../be/db";
21
- import { ProviderNameSchema } from "../types";
24
+ import { AgentCredStatusSchema, ProviderNameSchema } from "../types";
22
25
  import { route } from "./route-def";
23
26
  import { agentWithCapacity, json, jsonError } from "./utils";
24
27
 
@@ -38,6 +41,12 @@ const registerAgent = route({
38
41
  capabilities: z.array(z.string()).optional(),
39
42
  maxTasks: z.number().int().optional(),
40
43
  provider: ProviderNameSchema.optional(),
44
+ /**
45
+ * Phase 1.5 (cloud-personalization): worker-pushed canonical harness
46
+ * provider. Persists to `agents.harness_provider`. Validated against
47
+ * the canonical list — unknown values reject the request with 400.
48
+ */
49
+ harness_provider: ProviderNameSchema.optional(),
41
50
  }),
42
51
  responses: {
43
52
  200: { description: "Agent re-registered (already existed)" },
@@ -46,6 +55,25 @@ const registerAgent = route({
46
55
  },
47
56
  });
48
57
 
58
+ const setAgentHarnessProviderRoute = route({
59
+ method: "patch",
60
+ path: "/api/agents/{id}/harness-provider",
61
+ pattern: ["api", "agents", null, "harness-provider"],
62
+ summary: "Re-assign an agent's harness_provider (live)",
63
+ description:
64
+ "Updates `agents.harness_provider` and upserts `swarm_config` (scope=agent, key=HARNESS_PROVIDER) so the worker's poll-loop reconciliation picks up the new provider within ~10s. No restart required. The swarm_config row is what actually drives the worker; the column mirrors the latest set value for dashboards.",
65
+ tags: ["Agents"],
66
+ params: z.object({ id: z.string() }),
67
+ body: z.object({
68
+ harness_provider: ProviderNameSchema,
69
+ }),
70
+ responses: {
71
+ 200: { description: "Updated agent row" },
72
+ 400: { description: "Validation error (unknown provider)" },
73
+ 404: { description: "Agent not found" },
74
+ },
75
+ });
76
+
49
77
  const listAgents = route({
50
78
  method: "get",
51
79
  path: "/api/agents",
@@ -150,6 +178,13 @@ const credentialStatusBody = z.object({
150
178
  ready: z.boolean(),
151
179
  /** Env-var names (or absolute file paths) the worker is blocked on. Empty/null when ready. */
152
180
  missing: z.array(z.string()).optional().nullable(),
181
+ /**
182
+ * Migration 055: full credential snapshot (presence + live test). Optional
183
+ * for backward compat — older workers may only POST `{ready, missing}`.
184
+ * When present, written to `agents.cred_status` as JSON; the dashboard
185
+ * reads the row instead of running its own check.
186
+ */
187
+ cred_status: AgentCredStatusSchema.optional().nullable(),
153
188
  });
154
189
 
155
190
  const updateAgentCredentialStatusRoute = route({
@@ -219,6 +254,17 @@ export async function handleAgentRegister(
219
254
  if (parsed.body.provider && parsed.body.provider !== existingAgent.provider) {
220
255
  updateAgentProvider(existingAgent.id, parsed.body.provider);
221
256
  }
257
+ // Phase 1.5: worker-pushed harness_provider always wins on
258
+ // re-registration. Env-driven, by design (per-agent live override
259
+ // belongs to DES-359). NULL => leave existing column untouched
260
+ // so PATCH /harness-provider doesn't get clobbered by re-register
261
+ // payloads from older workers.
262
+ if (
263
+ parsed.body.harness_provider &&
264
+ parsed.body.harness_provider !== existingAgent.harnessProvider
265
+ ) {
266
+ setAgentHarnessProvider(existingAgent.id, parsed.body.harness_provider);
267
+ }
222
268
  resetEmptyPollCount(existingAgent.id);
223
269
  return { agent: getAgentById(agentId), created: false };
224
270
  }
@@ -233,6 +279,7 @@ export async function handleAgentRegister(
233
279
  capabilities: parsed.body.capabilities ?? [],
234
280
  maxTasks: parsed.body.maxTasks ?? 1,
235
281
  provider: parsed.body.provider,
282
+ harnessProvider: parsed.body.harness_provider ?? null,
236
283
  });
237
284
 
238
285
  return { agent, created: true };
@@ -399,6 +446,28 @@ export async function handleAgentsRest(
399
446
  return true;
400
447
  }
401
448
 
449
+ if (setAgentHarnessProviderRoute.match(req.method, pathSegments)) {
450
+ const parsed = await setAgentHarnessProviderRoute.parse(req, res, pathSegments, queryParams);
451
+ if (!parsed) return true;
452
+ const agent = setAgentHarnessProvider(parsed.params.id, parsed.body.harness_provider);
453
+ if (!agent) {
454
+ jsonError(res, "Agent not found", 404);
455
+ return true;
456
+ }
457
+ // Mirror to swarm_config (scope=agent) so the worker's reconciliation
458
+ // loop actually reads the new value. The column above is for dashboard
459
+ // visibility; this row is the live override.
460
+ upsertSwarmConfig({
461
+ scope: "agent",
462
+ scopeId: parsed.params.id,
463
+ key: "HARNESS_PROVIDER",
464
+ value: parsed.body.harness_provider,
465
+ description: "Set via PATCH /api/agents/{id}/harness-provider",
466
+ });
467
+ json(res, agentWithCapacity(agent));
468
+ return true;
469
+ }
470
+
402
471
  // Bulk credential-status MUST be matched BEFORE single-agent routes — the
403
472
  // path "api/agents/credential-status" otherwise looks like an agent id.
404
473
  if (listCredentialStatusRoute.match(req.method, pathSegments)) {
@@ -413,6 +482,8 @@ export async function handleAgentsRest(
413
482
  status: a.status,
414
483
  missing: a.credentialMissing ?? [],
415
484
  provider: a.provider ?? null,
485
+ harnessProvider: a.harnessProvider ?? null,
486
+ credStatus: a.credStatus ?? null,
416
487
  lastCheckedAt: a.lastUpdatedAt,
417
488
  }));
418
489
  json(res, { agents });
@@ -436,7 +507,14 @@ export async function handleAgentsRest(
436
507
  jsonError(res, "Agent not found", 404);
437
508
  return true;
438
509
  }
439
- json(res, agentWithCapacity(agent));
510
+ // Phase 055: persist the richer worker-reported snapshot when sent.
511
+ // We accept `null` to explicitly clear (e.g. on harness change), and
512
+ // `undefined` to leave the existing row value untouched.
513
+ const finalAgent =
514
+ parsed.body.cred_status !== undefined
515
+ ? (updateAgentCredStatus(parsed.params.id, parsed.body.cred_status ?? null) ?? agent)
516
+ : agent;
517
+ json(res, agentWithCapacity(finalAgent));
440
518
  return true;
441
519
  }
442
520
 
@@ -454,6 +532,8 @@ export async function handleAgentsRest(
454
532
  status: agent.status,
455
533
  missing: agent.credentialMissing ?? [],
456
534
  provider: agent.provider ?? null,
535
+ harnessProvider: agent.harnessProvider ?? null,
536
+ credStatus: agent.credStatus ?? null,
457
537
  lastCheckedAt: agent.lastUpdatedAt,
458
538
  });
459
539
  return true;
@@ -9,7 +9,11 @@ import {
9
9
  maskSecrets,
10
10
  upsertSwarmConfig,
11
11
  } from "../be/db";
12
- import { isReservedConfigKey, reservedKeyError } from "../be/swarm-config-guard";
12
+ import {
13
+ isReservedConfigKey,
14
+ reservedKeyError,
15
+ validateConfigValue,
16
+ } from "../be/swarm-config-guard";
13
17
  import { reloadGlobalConfigsAndIntegrations } from "./core";
14
18
  import { route } from "./route-def";
15
19
  import { json, jsonError } from "./utils";
@@ -230,6 +234,12 @@ export async function handleConfig(
230
234
  return true;
231
235
  }
232
236
 
237
+ const validationError = validateConfigValue(key, value);
238
+ if (validationError) {
239
+ jsonError(res, validationError, 400);
240
+ return true;
241
+ }
242
+
233
243
  try {
234
244
  const includeSecrets = queryParams.get("includeSecrets") === "true";
235
245
  const config = upsertSwarmConfig({
@@ -0,0 +1,89 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { listInboxState, upsertInboxState } from "../be/db";
4
+ import { InboxItemStatusSchema, InboxItemTypeSchema } from "../types";
5
+ import { route } from "./route-def";
6
+ import { json, jsonError } from "./utils";
7
+
8
+ // ─── Route Definitions ───────────────────────────────────────────────────────
9
+
10
+ const listState = route({
11
+ method: "get",
12
+ path: "/api/inbox-state",
13
+ pattern: ["api", "inbox-state"],
14
+ summary: "List inbox-item state rows for a user",
15
+ tags: ["Inbox State"],
16
+ query: z.object({
17
+ userId: z.string(),
18
+ status: InboxItemStatusSchema.optional(),
19
+ itemType: InboxItemTypeSchema.optional(),
20
+ }),
21
+ responses: {
22
+ 200: { description: "Inbox state rows" },
23
+ 400: { description: "Validation error" },
24
+ 401: { description: "Unauthorized" },
25
+ },
26
+ auth: { apiKey: true },
27
+ });
28
+
29
+ const upsertState = route({
30
+ method: "patch",
31
+ path: "/api/inbox-state",
32
+ pattern: ["api", "inbox-state"],
33
+ summary: "Upsert per-user dismiss/snooze/done state for an inbox item",
34
+ tags: ["Inbox State"],
35
+ body: z.object({
36
+ userId: z.string(),
37
+ itemType: InboxItemTypeSchema,
38
+ itemId: z.string().min(1),
39
+ status: InboxItemStatusSchema,
40
+ snoozeUntil: z.string().datetime().optional(),
41
+ }),
42
+ responses: {
43
+ 200: { description: "Upserted inbox state row" },
44
+ 400: { description: "Validation error" },
45
+ 401: { description: "Unauthorized" },
46
+ },
47
+ auth: { apiKey: true },
48
+ });
49
+
50
+ // ─── Handler ─────────────────────────────────────────────────────────────────
51
+
52
+ export async function handleInboxState(
53
+ req: IncomingMessage,
54
+ res: ServerResponse,
55
+ pathSegments: string[],
56
+ queryParams: URLSearchParams,
57
+ ): Promise<boolean> {
58
+ if (listState.match(req.method, pathSegments)) {
59
+ const parsed = await listState.parse(req, res, pathSegments, queryParams);
60
+ if (!parsed) return true;
61
+ const items = listInboxState({
62
+ userId: parsed.query.userId,
63
+ status: parsed.query.status,
64
+ itemType: parsed.query.itemType,
65
+ });
66
+ json(res, { items });
67
+ return true;
68
+ }
69
+
70
+ if (upsertState.match(req.method, pathSegments)) {
71
+ const parsed = await upsertState.parse(req, res, pathSegments, queryParams);
72
+ if (!parsed) return true;
73
+ try {
74
+ const item = upsertInboxState({
75
+ userId: parsed.body.userId,
76
+ itemType: parsed.body.itemType,
77
+ itemId: parsed.body.itemId,
78
+ status: parsed.body.status,
79
+ snoozeUntil: parsed.body.snoozeUntil,
80
+ });
81
+ json(res, { item });
82
+ } catch (err) {
83
+ jsonError(res, err instanceof Error ? err.message : "Failed to upsert inbox state", 500);
84
+ }
85
+ return true;
86
+ }
87
+
88
+ return false;
89
+ }
package/src/http/index.ts CHANGED
@@ -29,6 +29,7 @@ import { handleDbQuery } from "./db-query";
29
29
  import { handleEcosystem } from "./ecosystem";
30
30
  import { handleEvents } from "./events";
31
31
  import { handleHeartbeat } from "./heartbeat";
32
+ import { handleInboxState } from "./inbox-state";
32
33
  import { handleIntegrations } from "./integrations";
33
34
  import { handleMcp } from "./mcp";
34
35
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
@@ -40,10 +41,14 @@ import { handlePromptTemplates } from "./prompt-templates";
40
41
  import { handleRepos } from "./repos";
41
42
  import { handleSchedules } from "./schedules";
42
43
  import { handleSessionData } from "./session-data";
44
+ import { handleSessions } from "./sessions";
43
45
  import { handleSkills } from "./skills";
44
46
  import { handleStats } from "./stats";
47
+ import { handleStatus } from "./status";
48
+ import { handleTaskTemplates } from "./task-templates";
45
49
  import { handleTasks } from "./tasks";
46
50
  import { handleTrackers } from "./trackers";
51
+ import { handleUsers } from "./users";
47
52
  import { getPathSegments, parseQueryParams, setCorsHeaders } from "./utils";
48
53
  import { handleWebhooks } from "./webhooks";
49
54
  import { handleWorkflowEvents } from "./workflow-events";
@@ -126,6 +131,7 @@ const httpServer = createHttpServer(async (req, res) => {
126
131
  () => handleContext(req, res, pathSegments, queryParams, myAgentId),
127
132
  () => handleTasks(req, res, pathSegments, queryParams, myAgentId),
128
133
  () => handleStats(req, res, pathSegments, queryParams),
134
+ () => handleStatus(req, res, pathSegments, queryParams),
129
135
  () => handleActiveSessions(req, res, pathSegments, queryParams, myAgentId),
130
136
  () => handlePricing(req, res, pathSegments, queryParams, myAgentId),
131
137
  () => handleSchedules(req, res, pathSegments, queryParams, myAgentId),
@@ -144,6 +150,10 @@ const httpServer = createHttpServer(async (req, res) => {
144
150
  () => handleApiKeys(req, res, pathSegments, queryParams),
145
151
  () => handleHeartbeat(req, res, pathSegments),
146
152
  () => handleEvents(req, res, pathSegments, queryParams, myAgentId),
153
+ () => handleUsers(req, res, pathSegments, queryParams),
154
+ () => handleSessions(req, res, pathSegments, queryParams),
155
+ () => handleInboxState(req, res, pathSegments, queryParams),
156
+ () => handleTaskTemplates(req, res, pathSegments, queryParams),
147
157
  () => handleMcp(req, res, transports),
148
158
  ];
149
159
 
@@ -0,0 +1,86 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { getRootTaskChain, getTaskById, listRecentSessions } from "../be/db";
4
+ import { route } from "./route-def";
5
+ import { json, jsonError } from "./utils";
6
+
7
+ // ─── Route Definitions ───────────────────────────────────────────────────────
8
+
9
+ const listSessions = route({
10
+ method: "get",
11
+ path: "/api/sessions",
12
+ pattern: ["api", "sessions"],
13
+ summary: "List recent task sessions (root tasks + chain summary)",
14
+ tags: ["Sessions"],
15
+ query: z.object({
16
+ limit: z.coerce.number().int().optional(),
17
+ offset: z.coerce.number().int().optional(),
18
+ /** Comma-separated source filter (e.g. `ui,slack`). Omit to include all. */
19
+ source: z.string().optional(),
20
+ /** Case-insensitive substring match against the root task's text. */
21
+ q: z.string().optional(),
22
+ }),
23
+ responses: {
24
+ 200: { description: "Recent sessions ordered by chain-wide last activity" },
25
+ 401: { description: "Unauthorized" },
26
+ },
27
+ auth: { apiKey: true },
28
+ });
29
+
30
+ const getSession = route({
31
+ method: "get",
32
+ path: "/api/sessions/{rootTaskId}",
33
+ pattern: ["api", "sessions", null],
34
+ summary: "Get a session — root task + the entire descendant chain",
35
+ tags: ["Sessions"],
36
+ params: z.object({ rootTaskId: z.string() }),
37
+ responses: {
38
+ 200: { description: "Root task + chain (ordered by createdAt)" },
39
+ 401: { description: "Unauthorized" },
40
+ 404: { description: "Root task not found" },
41
+ },
42
+ auth: { apiKey: true },
43
+ });
44
+
45
+ // ─── Handler ─────────────────────────────────────────────────────────────────
46
+
47
+ export async function handleSessions(
48
+ req: IncomingMessage,
49
+ res: ServerResponse,
50
+ pathSegments: string[],
51
+ queryParams: URLSearchParams,
52
+ ): Promise<boolean> {
53
+ if (listSessions.match(req.method, pathSegments)) {
54
+ const parsed = await listSessions.parse(req, res, pathSegments, queryParams);
55
+ if (!parsed) return true;
56
+ const sources = parsed.query.source
57
+ ? parsed.query.source
58
+ .split(",")
59
+ .map((s) => s.trim())
60
+ .filter(Boolean)
61
+ : undefined;
62
+ const sessions = listRecentSessions({
63
+ limit: parsed.query.limit,
64
+ offset: parsed.query.offset,
65
+ source: sources,
66
+ q: parsed.query.q,
67
+ });
68
+ json(res, { sessions });
69
+ return true;
70
+ }
71
+
72
+ if (getSession.match(req.method, pathSegments)) {
73
+ const parsed = await getSession.parse(req, res, pathSegments, queryParams);
74
+ if (!parsed) return true;
75
+ const root = getTaskById(parsed.params.rootTaskId);
76
+ if (!root) {
77
+ jsonError(res, "Root task not found", 404);
78
+ return true;
79
+ }
80
+ const chain = getRootTaskChain(parsed.params.rootTaskId);
81
+ json(res, { root, chain });
82
+ return true;
83
+ }
84
+
85
+ return false;
86
+ }