@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/src/hooks/hook.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import pkg from "../../package.json";
4
+ import {
5
+ buildRatingsFromLlm,
6
+ buildSummaryWithRatingsPrompt,
7
+ dedupeRetrievalsForRater,
8
+ fetchRetrievalsForTask,
9
+ isLlmRaterEnabled,
10
+ postRatings,
11
+ type RetrievalRow,
12
+ } from "../be/memory/raters/llm";
13
+ import { runMemoryRater } from "../be/memory/raters/llm-summarizer";
4
14
  import type { Agent } from "../types";
5
15
  import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
6
16
 
@@ -195,6 +205,32 @@ async function restoreClaudeMdBackup(): Promise<void> {
195
205
  }
196
206
  }
197
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
+
198
234
  /**
199
235
  * Main hook handler - processes Claude Code hook events
200
236
  */
@@ -1043,9 +1079,19 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
1043
1079
  }
1044
1080
  }
1045
1081
 
1046
- // Session summarization via Claude Haiku
1047
- // Skip if this is a child session spawned by the summarization itself (prevents recursion)
1048
- 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
+ ) {
1049
1095
  try {
1050
1096
  let transcript = "";
1051
1097
  try {
@@ -1057,22 +1103,33 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
1057
1103
  }
1058
1104
 
1059
1105
  if (transcript.length > 100) {
1060
- // Read task context if available
1061
- let taskContext = "";
1062
- let taskId: string | undefined;
1063
- const taskFile = process.env.TASK_FILE;
1064
- if (taskFile) {
1065
- try {
1066
- const taskData = JSON.parse(await Bun.file(taskFile).text());
1067
- taskContext = `Task: ${taskData.task || "Unknown"}`;
1068
- taskId = taskData.id;
1069
- } catch {
1070
- /* no task file */
1071
- }
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();
1110
+
1111
+ const apiUrl =
1112
+ process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
1113
+ const apiKey = process.env.API_KEY || "";
1114
+
1115
+ // Memory-rater v1.5 step-4: piggyback per-memory ratings on the
1116
+ // existing summary call when MEMORY_RATERS includes `llm`.
1117
+ // Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-4.md §3
1118
+ const llmRaterEnabled = isLlmRaterEnabled();
1119
+ let retrievals: RetrievalRow[] = [];
1120
+ if (llmRaterEnabled && taskId) {
1121
+ const rawRetrievals = await fetchRetrievalsForTask({
1122
+ apiUrl,
1123
+ apiKey,
1124
+ agentId: agentInfo.id,
1125
+ taskId,
1126
+ });
1127
+ // Dedup self-similar cron-task memories before sending to the
1128
+ // rater — see `dedupeRetrievalsForRater` doc for the why.
1129
+ retrievals = dedupeRetrievalsForRater(rawRetrievals);
1072
1130
  }
1073
1131
 
1074
- // Summarize with Claude Haiku extract only high-value learnings
1075
- const summarizePrompt = `You are summarizing an AI agent's work session. Extract ONLY high-value learnings.
1132
+ const baseSummarizePrompt = `You are summarizing an AI agent's work session. Extract ONLY high-value learnings.
1076
1133
 
1077
1134
  DO NOT include:
1078
1135
  - Generic descriptions of what was done ("worked on task X")
@@ -1091,57 +1148,77 @@ ${taskContext ? `\nTask context: ${taskContext}` : ""}
1091
1148
  Transcript:
1092
1149
  ${transcript}`;
1093
1150
 
1094
- const tmpFile = `/tmp/session-summary-${Date.now()}.txt`;
1095
- await Bun.write(tmpFile, summarizePrompt);
1096
- const proc = Bun.spawn(
1097
- ["bash", "-c", `cat "${tmpFile}" | claude -p --model haiku --output-format json`],
1098
- {
1099
- stdout: "pipe",
1100
- stderr: "pipe",
1101
- env: { ...process.env, SKIP_SESSION_SUMMARY: "1" },
1102
- },
1103
- );
1104
- const timeoutId = setTimeout(() => proc.kill(), 30000);
1105
- const result = { stdout: await new Response(proc.stdout).text() };
1106
- clearTimeout(timeoutId);
1107
- await Bun.$`rm -f ${tmpFile}`.quiet();
1108
-
1109
- let summary: string;
1110
- try {
1111
- const summaryOutput = JSON.parse(result.stdout);
1112
- summary = summaryOutput.result ?? result.stdout;
1113
- } catch {
1114
- summary = result.stdout;
1115
- }
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);
1116
1156
 
1117
- // Skip indexing if the session had no significant learnings
1118
- if (
1119
- summary &&
1120
- summary.length > 20 &&
1121
- !summary.trim().toLowerCase().includes("no significant learnings")
1122
- ) {
1123
- const apiUrl =
1124
- process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
1125
- const apiKey = process.env.API_KEY || "";
1126
-
1127
- await fetch(`${apiUrl}/api/memory/index`, {
1128
- method: "POST",
1129
- headers: {
1130
- "Content-Type": "application/json",
1131
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
1132
- "X-Agent-ID": agentInfo.id,
1133
- },
1134
- body: JSON.stringify({
1135
- agentId: agentInfo.id,
1136
- content: summary,
1137
- name: taskContext
1138
- ? `Session: ${taskContext.slice(0, 80)}`
1139
- : `Session: ${new Date().toISOString().slice(0, 16)}`,
1140
- scope: "agent",
1141
- source: "session_summary",
1142
- ...(taskId ? { sourceTaskId: taskId } : {}),
1143
- }),
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 } : {}),
1144
1165
  });
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({
1184
+ agentId: agentInfo.id,
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
+ );
1220
+ }
1221
+ }
1145
1222
  }
1146
1223
  }
1147
1224
  } catch {
@@ -10,14 +10,18 @@ import {
10
10
  getDb,
11
11
  getSwarmConfigs,
12
12
  resetEmptyPollCount,
13
+ setAgentHarnessProvider,
13
14
  updateAgentActivity,
15
+ updateAgentCredentialState,
16
+ updateAgentCredStatus,
14
17
  updateAgentMaxTasks,
15
18
  updateAgentName,
16
19
  updateAgentProfile,
17
20
  updateAgentProvider,
18
21
  updateAgentStatus,
22
+ upsertSwarmConfig,
19
23
  } from "../be/db";
20
- import { ProviderNameSchema } from "../types";
24
+ import { AgentCredStatusSchema, ProviderNameSchema } from "../types";
21
25
  import { route } from "./route-def";
22
26
  import { agentWithCapacity, json, jsonError } from "./utils";
23
27
 
@@ -37,6 +41,12 @@ const registerAgent = route({
37
41
  capabilities: z.array(z.string()).optional(),
38
42
  maxTasks: z.number().int().optional(),
39
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(),
40
50
  }),
41
51
  responses: {
42
52
  200: { description: "Agent re-registered (already existed)" },
@@ -45,6 +55,25 @@ const registerAgent = route({
45
55
  },
46
56
  });
47
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
+
48
77
  const listAgents = route({
49
78
  method: "get",
50
79
  path: "/api/agents",
@@ -143,6 +172,62 @@ const getAgent = route({
143
172
  },
144
173
  });
145
174
 
175
+ // ─── Credential-status (Phase 3 + 4 of the credential safe-loop plan) ───────
176
+
177
+ const credentialStatusBody = z.object({
178
+ ready: z.boolean(),
179
+ /** Env-var names (or absolute file paths) the worker is blocked on. Empty/null when ready. */
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(),
188
+ });
189
+
190
+ const updateAgentCredentialStatusRoute = route({
191
+ method: "put",
192
+ path: "/api/agents/{id}/credential-status",
193
+ pattern: ["api", "agents", null, "credential-status"],
194
+ summary: "Worker self-report of credential readiness (Phase 3 boot loop)",
195
+ tags: ["Agents"],
196
+ params: z.object({ id: z.string() }),
197
+ body: credentialStatusBody,
198
+ responses: {
199
+ 200: { description: "State updated; returns the agent row." },
200
+ 404: { description: "Agent not found" },
201
+ },
202
+ });
203
+
204
+ const getAgentCredentialStatusRoute = route({
205
+ method: "get",
206
+ path: "/api/agents/{id}/credential-status",
207
+ pattern: ["api", "agents", null, "credential-status"],
208
+ summary: "Single-agent credential-status snapshot for the dashboard",
209
+ tags: ["Agents"],
210
+ params: z.object({ id: z.string() }),
211
+ responses: {
212
+ 200: { description: "Credential status payload" },
213
+ 404: { description: "Agent not found" },
214
+ },
215
+ });
216
+
217
+ const listCredentialStatusRoute = route({
218
+ method: "get",
219
+ path: "/api/agents/credential-status",
220
+ pattern: ["api", "agents", "credential-status"],
221
+ summary: "Bulk credential-status across all agents (powers the dashboard)",
222
+ tags: ["Agents"],
223
+ query: z.object({
224
+ status: z.enum(["idle", "busy", "offline", "waiting_for_credentials"]).optional(),
225
+ }),
226
+ responses: {
227
+ 200: { description: "List of {agentId, status, missing[], lastCheckedAt}" },
228
+ },
229
+ });
230
+
146
231
  // ─── Handlers ────────────────────────────────────────────────────────────────
147
232
 
148
233
  export async function handleAgentRegister(
@@ -169,6 +254,17 @@ export async function handleAgentRegister(
169
254
  if (parsed.body.provider && parsed.body.provider !== existingAgent.provider) {
170
255
  updateAgentProvider(existingAgent.id, parsed.body.provider);
171
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
+ }
172
268
  resetEmptyPollCount(existingAgent.id);
173
269
  return { agent: getAgentById(agentId), created: false };
174
270
  }
@@ -183,6 +279,7 @@ export async function handleAgentRegister(
183
279
  capabilities: parsed.body.capabilities ?? [],
184
280
  maxTasks: parsed.body.maxTasks ?? 1,
185
281
  provider: parsed.body.provider,
282
+ harnessProvider: parsed.body.harness_provider ?? null,
186
283
  });
187
284
 
188
285
  return { agent, created: true };
@@ -349,6 +446,99 @@ export async function handleAgentsRest(
349
446
  return true;
350
447
  }
351
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
+
471
+ // Bulk credential-status MUST be matched BEFORE single-agent routes — the
472
+ // path "api/agents/credential-status" otherwise looks like an agent id.
473
+ if (listCredentialStatusRoute.match(req.method, pathSegments)) {
474
+ const parsed = await listCredentialStatusRoute.parse(req, res, pathSegments, queryParams);
475
+ if (!parsed) return true;
476
+ const filter = parsed.query.status;
477
+ const agents = getAllAgents()
478
+ .filter((a) => (filter ? a.status === filter : true))
479
+ .map((a) => ({
480
+ agentId: a.id,
481
+ name: a.name,
482
+ status: a.status,
483
+ missing: a.credentialMissing ?? [],
484
+ provider: a.provider ?? null,
485
+ harnessProvider: a.harnessProvider ?? null,
486
+ credStatus: a.credStatus ?? null,
487
+ lastCheckedAt: a.lastUpdatedAt,
488
+ }));
489
+ json(res, { agents });
490
+ return true;
491
+ }
492
+
493
+ if (updateAgentCredentialStatusRoute.match(req.method, pathSegments)) {
494
+ const parsed = await updateAgentCredentialStatusRoute.parse(
495
+ req,
496
+ res,
497
+ pathSegments,
498
+ queryParams,
499
+ );
500
+ if (!parsed) return true;
501
+ const agent = updateAgentCredentialState(
502
+ parsed.params.id,
503
+ parsed.body.ready,
504
+ parsed.body.missing ?? null,
505
+ );
506
+ if (!agent) {
507
+ jsonError(res, "Agent not found", 404);
508
+ return true;
509
+ }
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));
518
+ return true;
519
+ }
520
+
521
+ if (getAgentCredentialStatusRoute.match(req.method, pathSegments)) {
522
+ const parsed = await getAgentCredentialStatusRoute.parse(req, res, pathSegments, queryParams);
523
+ if (!parsed) return true;
524
+ const agent = getAgentById(parsed.params.id);
525
+ if (!agent) {
526
+ jsonError(res, "Agent not found", 404);
527
+ return true;
528
+ }
529
+ json(res, {
530
+ agentId: agent.id,
531
+ name: agent.name,
532
+ status: agent.status,
533
+ missing: agent.credentialMissing ?? [],
534
+ provider: agent.provider ?? null,
535
+ harnessProvider: agent.harnessProvider ?? null,
536
+ credStatus: agent.credStatus ?? null,
537
+ lastCheckedAt: agent.lastUpdatedAt,
538
+ });
539
+ return true;
540
+ }
541
+
352
542
  if (getAgent.match(req.method, pathSegments)) {
353
543
  const parsed = await getAgent.parse(req, res, pathSegments, queryParams);
354
544
  if (!parsed) 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({
package/src/http/core.ts CHANGED
@@ -272,6 +272,11 @@ export async function handleCore(
272
272
 
273
273
  if (agent.status === "busy") {
274
274
  status = "busy";
275
+ } else if (agent.status === "waiting_for_credentials") {
276
+ // Preserve the waiting state — only the worker's own credential-wait
277
+ // tick (POST /api/agents/:id/credential-status) clears it once creds
278
+ // resolve. The pinger must not stomp it back to idle.
279
+ status = "waiting_for_credentials";
275
280
  }
276
281
 
277
282
  updateAgentStatus(agent.id, status);
@@ -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