@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
@@ -0,0 +1,51 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { listTaskTemplates } from "../be/db";
4
+ import { TaskTemplateKindSchema } from "../types";
5
+ import { route } from "./route-def";
6
+ import { json } from "./utils";
7
+
8
+ // ─── Route Definitions ───────────────────────────────────────────────────────
9
+
10
+ const listTemplates = route({
11
+ method: "get",
12
+ path: "/api/task-templates",
13
+ pattern: ["api", "task-templates"],
14
+ summary: "List task templates ('To start' bucket)",
15
+ tags: ["Task Templates"],
16
+ query: z.object({
17
+ category: z.string().optional(),
18
+ /** v2 hook — v1 callers always pass `kind=task` (or omit). */
19
+ kind: TaskTemplateKindSchema.optional(),
20
+ /** Case-insensitive LIKE match against `title` OR `description`. */
21
+ query: z.string().optional(),
22
+ }),
23
+ responses: {
24
+ 200: { description: "Task template list" },
25
+ 401: { description: "Unauthorized" },
26
+ },
27
+ auth: { apiKey: true },
28
+ });
29
+
30
+ // ─── Handler ─────────────────────────────────────────────────────────────────
31
+
32
+ export async function handleTaskTemplates(
33
+ req: IncomingMessage,
34
+ res: ServerResponse,
35
+ pathSegments: string[],
36
+ queryParams: URLSearchParams,
37
+ ): Promise<boolean> {
38
+ if (listTemplates.match(req.method, pathSegments)) {
39
+ const parsed = await listTemplates.parse(req, res, pathSegments, queryParams);
40
+ if (!parsed) return true;
41
+ const templates = listTaskTemplates({
42
+ category: parsed.query.category,
43
+ kind: parsed.query.kind,
44
+ query: parsed.query.query,
45
+ });
46
+ json(res, { templates });
47
+ return true;
48
+ }
49
+
50
+ return false;
51
+ }
package/src/http/tasks.ts CHANGED
@@ -7,10 +7,12 @@ import {
7
7
  failTask,
8
8
  getAllTasks,
9
9
  getDb,
10
+ getLeadAgent,
10
11
  getLogsByTaskId,
11
12
  getPausedTasksForAgent,
12
13
  getTaskById,
13
14
  getTasksCount,
15
+ getUserById,
14
16
  pauseTask,
15
17
  resumeTask,
16
18
  updateAgentStatusFromCapacity,
@@ -20,7 +22,13 @@ import {
20
22
  } from "../be/db";
21
23
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
22
24
  import { telemetry } from "../telemetry";
23
- import { ProviderNameSchema } from "../types";
25
+ import {
26
+ type AgentTaskSource,
27
+ AgentTaskSourceSchema,
28
+ type AgentTaskStatus,
29
+ AgentTaskStatusSchema,
30
+ ProviderNameSchema,
31
+ } from "../types";
24
32
  import { route } from "./route-def";
25
33
  import { json, jsonError } from "./utils";
26
34
 
@@ -33,16 +41,22 @@ const listTasks = route({
33
41
  summary: "List tasks with filters",
34
42
  tags: ["Tasks"],
35
43
  query: z.object({
44
+ /** Single status, or comma-separated list (e.g. "failed,cancelled"). */
36
45
  status: z.string().optional(),
37
46
  agentId: z.string().optional(),
38
47
  scheduleId: z.string().optional(),
39
48
  search: z.string().optional(),
40
49
  includeHeartbeat: z.enum(["true", "false"]).optional(),
50
+ /** ISO 8601 — return only tasks created on/after this timestamp. */
51
+ createdAfter: z.string().datetime().optional(),
52
+ /** Comma-separated source filter (e.g. `ui,slack`). Omit to include all. */
53
+ source: z.string().optional(),
41
54
  limit: z.coerce.number().int().optional(),
42
55
  offset: z.coerce.number().int().optional(),
43
56
  }),
44
57
  responses: {
45
58
  200: { description: "Paginated task list" },
59
+ 400: { description: "Validation error (e.g. unknown status token)" },
46
60
  },
47
61
  });
48
62
 
@@ -62,9 +76,10 @@ const createTask = route({
62
76
  offeredTo: z.string().optional(),
63
77
  dir: z.string().optional(),
64
78
  parentTaskId: z.string().optional(),
65
- source: z.string().optional(),
79
+ source: AgentTaskSourceSchema.optional(),
66
80
  outputSchema: z.record(z.string(), z.unknown()).optional(),
67
81
  contextKey: z.string().optional(),
82
+ requestedByUserId: z.string().optional(),
68
83
  }),
69
84
  responses: {
70
85
  201: { description: "Task created" },
@@ -239,12 +254,53 @@ export async function handleTasks(
239
254
  if (listTasks.match(req.method, pathSegments)) {
240
255
  const parsed = await listTasks.parse(req, res, pathSegments, queryParams);
241
256
  if (!parsed) return true;
257
+
258
+ // Multi-status CSV: split on `,` and validate each token against the
259
+ // canonical enum. Empty / single-status callers still work.
260
+ let status: AgentTaskStatus | AgentTaskStatus[] | undefined;
261
+ if (parsed.query.status) {
262
+ const tokens = parsed.query.status
263
+ .split(",")
264
+ .map((s) => s.trim())
265
+ .filter(Boolean);
266
+ const validated: AgentTaskStatus[] = [];
267
+ for (const tok of tokens) {
268
+ const result = AgentTaskStatusSchema.safeParse(tok);
269
+ if (!result.success) {
270
+ jsonError(res, `Invalid status token: ${tok}`, 400);
271
+ return true;
272
+ }
273
+ validated.push(result.data);
274
+ }
275
+ status = validated.length === 1 ? validated[0] : validated;
276
+ }
277
+
278
+ let source: AgentTaskSource[] | undefined;
279
+ if (parsed.query.source) {
280
+ const tokens = parsed.query.source
281
+ .split(",")
282
+ .map((s) => s.trim())
283
+ .filter(Boolean);
284
+ const validated: AgentTaskSource[] = [];
285
+ for (const tok of tokens) {
286
+ const result = AgentTaskSourceSchema.safeParse(tok);
287
+ if (!result.success) {
288
+ jsonError(res, `Invalid source token: ${tok}`, 400);
289
+ return true;
290
+ }
291
+ validated.push(result.data);
292
+ }
293
+ if (validated.length > 0) source = validated;
294
+ }
295
+
242
296
  const filters = {
243
- status: (parsed.query.status as import("../types").AgentTaskStatus) || undefined,
297
+ status,
244
298
  agentId: parsed.query.agentId || undefined,
245
299
  scheduleId: parsed.query.scheduleId || undefined,
246
300
  search: parsed.query.search || undefined,
247
301
  includeHeartbeat: parsed.query.includeHeartbeat === "true" || undefined,
302
+ createdAfter: parsed.query.createdAfter || undefined,
303
+ source,
248
304
  limit: parsed.query.limit,
249
305
  offset: parsed.query.offset,
250
306
  };
@@ -258,9 +314,32 @@ export async function handleTasks(
258
314
  const parsed = await createTask.parse(req, res, pathSegments, queryParams);
259
315
  if (!parsed) return true;
260
316
 
317
+ // Tolerant `requestedByUserId`: prevent the deleted-user race from
318
+ // becoming a 500 — if the referenced user doesn't exist, log and drop
319
+ // the field rather than letting the FK fail at INSERT.
320
+ let requestedByUserId = parsed.body.requestedByUserId || undefined;
321
+ if (requestedByUserId && !getUserById(requestedByUserId)) {
322
+ console.warn(
323
+ `[tasks] requestedByUserId ${requestedByUserId} does not exist — coercing to NULL`,
324
+ );
325
+ requestedByUserId = undefined;
326
+ }
327
+
328
+ // Default agent for ingress-created tasks: when no explicit `agentId` is
329
+ // provided, route to the lead so the task has an owner immediately
330
+ // (regardless of whether it's a root or a follow-up under a parentTaskId).
331
+ // Without this, UI composer follow-ups land unassigned and never get
332
+ // picked up. Mirrors Slack's pattern (slack/actions.ts uses lead?.id when
333
+ // there's no working agent).
334
+ let defaultAgentId = parsed.body.agentId || undefined;
335
+ if (!defaultAgentId) {
336
+ const lead = getLeadAgent();
337
+ if (lead) defaultAgentId = lead.id;
338
+ }
339
+
261
340
  try {
262
341
  const task = createTaskWithSiblingAwareness(parsed.body.task, {
263
- agentId: parsed.body.agentId || undefined,
342
+ agentId: defaultAgentId,
264
343
  creatorAgentId: myAgentId || undefined,
265
344
  taskType: parsed.body.taskType || undefined,
266
345
  tags: parsed.body.tags || undefined,
@@ -269,9 +348,10 @@ export async function handleTasks(
269
348
  offeredTo: parsed.body.offeredTo || undefined,
270
349
  dir: parsed.body.dir || undefined,
271
350
  parentTaskId: parsed.body.parentTaskId || undefined,
272
- source: (parsed.body.source as import("../types").AgentTaskSource) || "api",
351
+ source: parsed.body.source || "api",
273
352
  outputSchema: parsed.body.outputSchema || undefined,
274
353
  contextKey: parsed.body.contextKey || undefined,
354
+ requestedByUserId,
275
355
  });
276
356
 
277
357
  ensure({
@@ -0,0 +1,134 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { createUser, getAllUsers, getUserById, updateUser } from "../be/db";
4
+ import { route } from "./route-def";
5
+ import { json, jsonError } from "./utils";
6
+
7
+ // ─── Route Definitions ───────────────────────────────────────────────────────
8
+
9
+ const listUsers = route({
10
+ method: "get",
11
+ path: "/api/users",
12
+ pattern: ["api", "users"],
13
+ summary: "List all users",
14
+ tags: ["Users"],
15
+ responses: {
16
+ 200: { description: "List of users" },
17
+ 401: { description: "Unauthorized" },
18
+ },
19
+ auth: { apiKey: true },
20
+ });
21
+
22
+ const createUserRoute = route({
23
+ method: "post",
24
+ path: "/api/users",
25
+ pattern: ["api", "users"],
26
+ summary: "Create a new user",
27
+ tags: ["Users"],
28
+ body: z.object({
29
+ name: z.string().min(1),
30
+ email: z.string().optional(),
31
+ role: z.string().optional(),
32
+ notes: z.string().optional(),
33
+ slackUserId: z.string().optional(),
34
+ linearUserId: z.string().optional(),
35
+ githubUsername: z.string().optional(),
36
+ gitlabUsername: z.string().optional(),
37
+ emailAliases: z.array(z.string()).optional(),
38
+ preferredChannel: z.string().optional(),
39
+ timezone: z.string().optional(),
40
+ }),
41
+ responses: {
42
+ 200: { description: "User created" },
43
+ 400: { description: "Validation error" },
44
+ 401: { description: "Unauthorized" },
45
+ },
46
+ auth: { apiKey: true },
47
+ });
48
+
49
+ const updateUserRoute = route({
50
+ method: "put",
51
+ path: "/api/users/{id}",
52
+ pattern: ["api", "users", null],
53
+ summary: "Update an existing user (partial — at least one field required)",
54
+ tags: ["Users"],
55
+ params: z.object({ id: z.string() }),
56
+ body: z
57
+ .object({
58
+ name: z.string().min(1).optional(),
59
+ email: z.string().optional(),
60
+ role: z.string().optional(),
61
+ notes: z.string().optional(),
62
+ slackUserId: z.string().optional(),
63
+ linearUserId: z.string().optional(),
64
+ githubUsername: z.string().optional(),
65
+ gitlabUsername: z.string().optional(),
66
+ emailAliases: z.array(z.string()).optional(),
67
+ preferredChannel: z.string().optional(),
68
+ timezone: z.string().optional(),
69
+ })
70
+ .refine((v) => Object.keys(v).length > 0, {
71
+ message: "At least one field must be provided",
72
+ }),
73
+ responses: {
74
+ 200: { description: "User updated" },
75
+ 400: { description: "Validation error or empty body" },
76
+ 401: { description: "Unauthorized" },
77
+ 404: { description: "User not found" },
78
+ },
79
+ auth: { apiKey: true },
80
+ });
81
+
82
+ // ─── Handler ─────────────────────────────────────────────────────────────────
83
+
84
+ export async function handleUsers(
85
+ req: IncomingMessage,
86
+ res: ServerResponse,
87
+ pathSegments: string[],
88
+ queryParams: URLSearchParams,
89
+ ): Promise<boolean> {
90
+ if (listUsers.match(req.method, pathSegments)) {
91
+ const parsed = await listUsers.parse(req, res, pathSegments, queryParams);
92
+ if (!parsed) return true;
93
+ const users = getAllUsers();
94
+ json(res, { users });
95
+ return true;
96
+ }
97
+
98
+ if (createUserRoute.match(req.method, pathSegments)) {
99
+ const parsed = await createUserRoute.parse(req, res, pathSegments, queryParams);
100
+ if (!parsed) return true;
101
+ try {
102
+ const user = createUser(parsed.body);
103
+ json(res, { user });
104
+ } catch (err) {
105
+ jsonError(res, err instanceof Error ? err.message : "Failed to create user", 500);
106
+ }
107
+ return true;
108
+ }
109
+
110
+ if (updateUserRoute.match(req.method, pathSegments)) {
111
+ const parsed = await updateUserRoute.parse(req, res, pathSegments, queryParams);
112
+ if (!parsed) return true;
113
+
114
+ // 404 if user not found before update — keeps the contract honest.
115
+ if (!getUserById(parsed.params.id)) {
116
+ jsonError(res, "User not found", 404);
117
+ return true;
118
+ }
119
+
120
+ try {
121
+ const user = updateUser(parsed.params.id, parsed.body);
122
+ if (!user) {
123
+ jsonError(res, "User not found", 404);
124
+ return true;
125
+ }
126
+ json(res, { user });
127
+ } catch (err) {
128
+ jsonError(res, err instanceof Error ? err.message : "Failed to update user", 500);
129
+ }
130
+ return true;
131
+ }
132
+
133
+ return false;
134
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-5.md §5
3
+ *
4
+ * Worker-side rendering of the "Relevant Past Knowledge" memories block that
5
+ * gets appended to a task's initial prompt. Pure string manipulation — no DB
6
+ * imports — so this file stays inside the worker-side boundary enforced by
7
+ * scripts/check-db-boundary.sh.
8
+ *
9
+ * The conditional hint at the end is gated on `MEMORY_RATERS` containing
10
+ * `explicit-self`. When the gate is closed (the default), the rendered
11
+ * prompt is byte-identical to pre-rater builds — strict backward compat.
12
+ */
13
+
14
+ export type RelevantMemory = {
15
+ id: string;
16
+ name: string;
17
+ content: string;
18
+ similarity: number;
19
+ };
20
+
21
+ const SIMILARITY_THRESHOLD = 0.4;
22
+
23
+ const RATE_TOOL_HINT = `
24
+
25
+ When a memory above genuinely helps you solve this task — or actively
26
+ misleads you — call \`memory_rate\` with the memory id and useful=true/false.
27
+ This trains the swarm to surface better memories next time. Use sparingly:
28
+ 2-5 ratings per task is plenty.`;
29
+
30
+ /**
31
+ * Render the memories prompt section. Returns `null` when there are no
32
+ * memories with `similarity > 0.4` — the caller should then skip the
33
+ * append entirely (matching pre-step-5 behaviour).
34
+ */
35
+ export function renderMemoriesPrompt(memories: RelevantMemory[]): string | null {
36
+ const useful = memories.filter((m) => m.similarity > SIMILARITY_THRESHOLD);
37
+ if (useful.length === 0) return null;
38
+
39
+ const memoryContext = useful
40
+ .map((m) => `- **${m.name}** (id: ${m.id}): ${m.content.substring(0, 300)}`)
41
+ .join("\n");
42
+
43
+ let prompt = `\n\n### Relevant Past Knowledge\n\nThese memories from your previous sessions may be useful. Use \`memory-get\` with the memory ID to retrieve full details.\n\n${memoryContext}\n`;
44
+
45
+ if (isExplicitSelfRaterEnabled()) {
46
+ prompt += RATE_TOOL_HINT;
47
+ }
48
+
49
+ return prompt;
50
+ }
51
+
52
+ /**
53
+ * Exported for tests. Reads `MEMORY_RATERS` lazily so a test can flip the
54
+ * env var between renders without re-importing the module.
55
+ */
56
+ export function isExplicitSelfRaterEnabled(): boolean {
57
+ const ratersEnabled = (process.env.MEMORY_RATERS ?? "")
58
+ .split(",")
59
+ .map((s) => s.trim())
60
+ .filter(Boolean);
61
+ return ratersEnabled.includes("explicit-self");
62
+ }
@@ -11,6 +11,7 @@ import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
11
11
  import { scrubSecrets } from "../utils/secret-scrubber";
12
12
  import type {
13
13
  CostData,
14
+ CredStatus,
14
15
  ProviderAdapter,
15
16
  ProviderEvent,
16
17
  ProviderResult,
@@ -18,6 +19,22 @@ import type {
18
19
  ProviderSessionConfig,
19
20
  } from "./types";
20
21
 
22
+ /**
23
+ * Predicate used by the worker boot loop and the credential-status endpoint.
24
+ * The claude harness needs EITHER `CLAUDE_CODE_OAUTH_TOKEN` (preferred) or
25
+ * `ANTHROPIC_API_KEY` — both are listed as missing when neither is present.
26
+ */
27
+ export function checkClaudeCredentials(env: Record<string, string | undefined>): CredStatus {
28
+ if (env.CLAUDE_CODE_OAUTH_TOKEN || env.ANTHROPIC_API_KEY) {
29
+ return { ready: true, missing: [], satisfiedBy: "env" };
30
+ }
31
+ return {
32
+ ready: false,
33
+ missing: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
34
+ hint: "Set either CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY (one is enough).",
35
+ };
36
+ }
37
+
21
38
  /** Task file data written to /tmp for hook to read */
22
39
  interface TaskFileData {
23
40
  taskId: string;
@@ -177,6 +194,11 @@ class ClaudeSession implements ProviderSession {
177
194
  ENABLE_PROMPT_CACHING_1H: "1",
178
195
  ...(config.env || process.env),
179
196
  TASK_FILE: taskFilePath,
197
+ // Belt-and-braces: TASK_FILE on disk can disappear mid-session (race
198
+ // with task lifecycle), which silently drops the Stop-hook memory
199
+ // rater. The hook prefers these env vars when present. See PR #444.
200
+ AGENT_SWARM_TASK_ID: config.taskId,
201
+ AGENT_SWARM_AGENT_ID: config.agentId,
180
202
  } as Record<string, string>,
181
203
  stdout: "pipe",
182
204
  stderr: "pipe",
@@ -64,6 +64,7 @@ import { computeClaudeManagedCostUsd } from "./claude-managed-models";
64
64
  import { createClaudeManagedSwarmEventHandler } from "./claude-managed-swarm-events";
65
65
  import type {
66
66
  CostData,
67
+ CredStatus,
67
68
  ProviderAdapter,
68
69
  ProviderEvent,
69
70
  ProviderResult,
@@ -71,6 +72,29 @@ import type {
71
72
  ProviderSessionConfig,
72
73
  } from "./types";
73
74
 
75
+ /**
76
+ * Managed-agents needs all four bootstrap values. Unlike vanilla claude there
77
+ * is no oauth fallback — the SDK requires the API key, agent id, environment
78
+ * id, and a public MCP base URL the cloud sandbox can reach.
79
+ */
80
+ export function checkClaudeManagedCredentials(env: Record<string, string | undefined>): CredStatus {
81
+ const required = [
82
+ "ANTHROPIC_API_KEY",
83
+ "MANAGED_AGENT_ID",
84
+ "MANAGED_ENVIRONMENT_ID",
85
+ "MCP_BASE_URL",
86
+ ] as const;
87
+ const missing = required.filter((key) => !env[key]);
88
+ if (missing.length === 0) {
89
+ return { ready: true, missing: [], satisfiedBy: "env" };
90
+ }
91
+ return {
92
+ ready: false,
93
+ missing,
94
+ hint: "Run `bun run src/cli.tsx claude-managed-setup` once to provision MANAGED_AGENT_ID and MANAGED_ENVIRONMENT_ID, then set ANTHROPIC_API_KEY and MCP_BASE_URL (must be HTTPS-public).",
95
+ };
96
+ }
97
+
74
98
  // Re-export the type aliases at module level so adjacent files / tests can use
75
99
  // the short names without re-discovering the long Beta-prefixed ones. Kept on
76
100
  // `void` lines so unused-import lints stay quiet for the type imports above.
@@ -45,6 +45,7 @@
45
45
  * `Turn`, events, or items — the SDK already exports them as a tagged union.
46
46
  */
47
47
 
48
+ import { existsSync as nodeExistsSync } from "node:fs";
48
49
  import os from "node:os";
49
50
  import { join } from "node:path";
50
51
  import {
@@ -73,6 +74,8 @@ import { resolveCodexPrompt } from "./codex-skill-resolver";
73
74
  import { createCodexSwarmEventHandler } from "./codex-swarm-events";
74
75
  import type {
75
76
  CostData,
77
+ CredCheckOptions,
78
+ CredStatus,
76
79
  ProviderAdapter,
77
80
  ProviderEvent,
78
81
  ProviderResult,
@@ -83,6 +86,45 @@ import type {
83
86
  /** Alias for the SDK's (unexported) `CodexConfigObject` type. */
84
87
  type CodexConfig = NonNullable<CodexOptions["config"]>;
85
88
 
89
+ /**
90
+ * Codex satisfies its credential requirement by ANY of:
91
+ * 1. `~/.codex/auth.json` already exists on disk (the canonical state once
92
+ * `codex login` has run).
93
+ * 2. `OPENAI_API_KEY` is set — the entrypoint will run
94
+ * `codex login --with-api-key` to materialise auth.json on the next boot.
95
+ * 3. `CODEX_OAUTH` is set in the env (typically pulled from swarm_config) —
96
+ * the entrypoint restores it to disk.
97
+ *
98
+ * Cases 2/3 return `satisfiedBy: 'side-effect-pending'` because the worker
99
+ * process can't proceed until the entrypoint side-effect has materialised the
100
+ * file. The boot loop treats this as ready (the side-effect is the
101
+ * entrypoint's job, and re-running it is idempotent).
102
+ */
103
+ export function checkCodexCredentials(
104
+ env: Record<string, string | undefined>,
105
+ opts: CredCheckOptions = {},
106
+ ): CredStatus {
107
+ const homeDir = opts.homeDir ?? env.HOME ?? "/root";
108
+ const existsSync = opts.fs?.existsSync ?? nodeExistsSync;
109
+ const authFile = `${homeDir}/.codex/auth.json`;
110
+ if (existsSync(authFile)) {
111
+ return { ready: true, missing: [], satisfiedBy: "file" };
112
+ }
113
+ if (env.OPENAI_API_KEY || env.CODEX_OAUTH) {
114
+ return {
115
+ ready: true,
116
+ missing: [],
117
+ satisfiedBy: "side-effect-pending",
118
+ hint: "Credential present in env; entrypoint will materialise ~/.codex/auth.json on next boot.",
119
+ };
120
+ }
121
+ return {
122
+ ready: false,
123
+ missing: ["OPENAI_API_KEY", "CODEX_OAUTH", authFile],
124
+ hint: "Set OPENAI_API_KEY (entrypoint runs `codex login --with-api-key`), or store CODEX_OAUTH in swarm_config, or place a pre-authenticated `~/.codex/auth.json` in the worker home.",
125
+ };
126
+ }
127
+
86
128
  /**
87
129
  * Shape returned by `GET /api/agents/:id/mcp-servers?resolveSecrets=true`.
88
130
  * Mirrors `pi-mono-adapter.ts:430-439` and `claude-adapter.ts:59-72`, plus
@@ -648,7 +690,7 @@ class CodexSession implements ProviderSession {
648
690
  // `contextPercent` is on a 0-100 scale across all providers — claude
649
691
  // emits `(used / total) * 100`, pi-mono passes through `usage.percent`
650
692
  // which is already 0-100. The dashboard at
651
- // new-ui/src/pages/tasks/[id]/page.tsx renders it via `.toFixed(0)`
693
+ // ui/src/pages/tasks/[id]/page.tsx renders it via `.toFixed(0)`
652
694
  // expecting an integer percent, so a 0-1 fraction would render as
653
695
  // "0%" instead of e.g. "40%".
654
696
  this.emit({
@@ -24,6 +24,7 @@ import { getOrCreatePlaybook } from "./devin-playbooks";
24
24
  import { resolveDevinPrompt } from "./devin-skill-resolver";
25
25
  import type {
26
26
  CostData,
27
+ CredStatus,
27
28
  ProviderAdapter,
28
29
  ProviderEvent,
29
30
  ProviderResult,
@@ -32,6 +33,23 @@ import type {
32
33
  ProviderTraits,
33
34
  } from "./types";
34
35
 
36
+ /**
37
+ * Devin requires both an API key and an org id — there is no file-based
38
+ * fallback like the local-CLI providers offer.
39
+ */
40
+ export function checkDevinCredentials(env: Record<string, string | undefined>): CredStatus {
41
+ const required = ["DEVIN_API_KEY", "DEVIN_ORG_ID"] as const;
42
+ const missing = required.filter((key) => !env[key]);
43
+ if (missing.length === 0) {
44
+ return { ready: true, missing: [], satisfiedBy: "env" };
45
+ }
46
+ return {
47
+ ready: false,
48
+ missing,
49
+ hint: "Set DEVIN_API_KEY and DEVIN_ORG_ID. Both come from app.devin.ai → Settings → API.",
50
+ };
51
+ }
52
+
35
53
  /** Default polling interval in milliseconds. */
36
54
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
37
55
 
@@ -1,5 +1,12 @@
1
+ export {
2
+ checkProviderCredentials,
3
+ REQUIRED_CRED_VARS_BY_PROVIDER,
4
+ type SupportedProvider,
5
+ } from "../commands/provider-credentials";
1
6
  export type {
2
7
  CostData,
8
+ CredCheckOptions,
9
+ CredStatus,
3
10
  ProviderAdapter,
4
11
  ProviderEvent,
5
12
  ProviderResult,
@@ -18,6 +18,8 @@ import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
18
18
  import { scrubSecrets } from "../utils/secret-scrubber";
19
19
  import type {
20
20
  CostData,
21
+ CredCheckOptions,
22
+ CredStatus,
21
23
  ProviderAdapter,
22
24
  ProviderEvent,
23
25
  ProviderResult,
@@ -26,6 +28,64 @@ import type {
26
28
  ProviderTraits,
27
29
  } from "./types";
28
30
 
31
+ /**
32
+ * Map opencode model strings to the env var that satisfies them. Opencode
33
+ * uses the same `provider/model-id` shape as pi-mono — the prefix tells us
34
+ * which key the user must supply.
35
+ */
36
+ function opencodeModelToCredKey(modelStr: string | undefined): string | null {
37
+ if (!modelStr) return null;
38
+ if (modelStr.includes("/")) {
39
+ const provider = modelStr.slice(0, modelStr.indexOf("/")).toLowerCase();
40
+ if (provider === "anthropic") return "ANTHROPIC_API_KEY";
41
+ if (provider === "openrouter") return "OPENROUTER_API_KEY";
42
+ if (provider === "openai") return "OPENAI_API_KEY";
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Opencode is satisfied by ANY of:
49
+ * 1. `~/.local/share/opencode/auth.json` exists (the file `opencode auth login`
50
+ * writes).
51
+ * 2. `MODEL_OVERRIDE` resolves to a provider-prefixed model — only that
52
+ * provider's key is required.
53
+ * 3. Otherwise any one of OPENROUTER_API_KEY / ANTHROPIC_API_KEY /
54
+ * OPENAI_API_KEY suffices.
55
+ */
56
+ export function checkOpencodeCredentials(
57
+ env: Record<string, string | undefined>,
58
+ opts: CredCheckOptions = {},
59
+ ): CredStatus {
60
+ const homeDir = opts.homeDir ?? env.HOME ?? "/root";
61
+ const probe = opts.fs?.existsSync ?? existsSync;
62
+ const authFile = `${homeDir}/.local/share/opencode/auth.json`;
63
+ if (probe(authFile)) {
64
+ return { ready: true, missing: [], satisfiedBy: "file" };
65
+ }
66
+
67
+ const requiredKey = opencodeModelToCredKey(env.MODEL_OVERRIDE);
68
+ if (requiredKey) {
69
+ if (env[requiredKey]) {
70
+ return { ready: true, missing: [], satisfiedBy: "env" };
71
+ }
72
+ return {
73
+ ready: false,
74
+ missing: [requiredKey, authFile],
75
+ hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} requires ${requiredKey}; or run \`opencode auth login\` to create ${authFile}.`,
76
+ };
77
+ }
78
+
79
+ if (env.OPENROUTER_API_KEY || env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY) {
80
+ return { ready: true, missing: [], satisfiedBy: "env" };
81
+ }
82
+ return {
83
+ ready: false,
84
+ missing: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", authFile],
85
+ hint: "Set one of OPENROUTER_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY (any one suffices), or run `opencode auth login` to create ~/.local/share/opencode/auth.json.",
86
+ };
87
+ }
88
+
29
89
  function isAssistantMessage(msg: unknown): msg is AssistantMessage {
30
90
  return (
31
91
  typeof msg === "object" &&