@desplega.ai/agent-swarm 1.161.0 → 1.162.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 (224) hide show
  1. package/README.md +3 -2
  2. package/dist/{acp-adapter-xx489vvb.js → acp-adapter-74m73ae9.js} +1 -1
  3. package/dist/{actions-ykjkn0m9.js → actions-4sr14gnt.js} +34 -31
  4. package/dist/{anthropic-messages-5qx80j9k.js → anthropic-messages-8xxrzky9.js} +22 -26
  5. package/dist/{app-0hdqa4k1.js → app-pt68fbwp.js} +13 -11
  6. package/dist/{artifact-3v5t9jtg.js → artifact-fey7dvj9.js} +3 -2
  7. package/dist/{assistant-am2g5g8a.js → assistant-cv3jytks.js} +37 -34
  8. package/dist/{azure-openai-responses-fszbkp0e.js → azure-openai-responses-f5cqsyj8.js} +15 -10
  9. package/dist/{boot-reembed-xy9t7avg.js → boot-reembed-4ht4gqth.js} +13 -11
  10. package/dist/{boot-reembed-gt5gjkt4.js → boot-reembed-nxpcvpj9.js} +12 -10
  11. package/dist/{boot-scrub-logs-9x0985y5.js → boot-scrub-logs-k93kyw8k.js} +9 -7
  12. package/dist/{boot-seeds-trv0d4vg.js → boot-seeds-1skcrs0e.js} +13 -10
  13. package/dist/{claude-adapter-jb6e1e54.js → claude-adapter-r5g0tzge.js} +23 -20
  14. package/dist/{claude-managed-setup-d1q9hv2b.js → claude-managed-setup-pkyrgshb.js} +3 -2
  15. package/dist/{claude-sdk-session-zga700s2.js → claude-sdk-session-jxyxnrwd.js} +5565 -5512
  16. package/dist/{cli-we0q7t0e.js → cli-2zy95vhm.js} +137 -64
  17. package/dist/{cli-35pj2yva.js → cli-4cwxn7gp.js} +38 -11
  18. package/dist/{cli-ad4qxm6j.js → cli-4dcpaefp.js} +2 -2
  19. package/dist/{cli-82c1pacn.js → cli-4x0csdjq.js} +71 -37
  20. package/dist/{cli-sqm3r5ps.js → cli-5bcd7sfy.js} +14 -18
  21. package/dist/{cli-d56tgd45.js → cli-5k6k8ta3.js} +2 -2
  22. package/dist/{cli-1er0rhz8.js → cli-5xhpb3kv.js} +15 -15
  23. package/dist/{cli-yrg7c73k.js → cli-7af8yhav.js} +10 -183
  24. package/dist/{cli-r6fsxc76.js → cli-7tre70zp.js} +513 -961
  25. package/dist/{cli-bh7jed3k.js → cli-8ev9xqnx.js} +34 -18
  26. package/dist/cli-bsky8g7w.js +449 -0
  27. package/dist/{cli-hn04t9db.js → cli-cebmgwsc.js} +14 -10
  28. package/dist/{cli-cfkrztza.js → cli-cvatsam7.js} +2 -2
  29. package/dist/{cli-2658f6at.js → cli-cvk6yzw5.js} +6 -8
  30. package/dist/{cli-ptr8h7kp.js → cli-eg0b3p0k.js} +5 -218
  31. package/dist/{cli-zb6xbk61.js → cli-g739zqzt.js} +4 -4
  32. package/dist/{cli-dv6cdjs2.js → cli-gd4favx9.js} +28 -7
  33. package/dist/{cli-5p3x4zna.js → cli-gpqpmns7.js} +2 -2
  34. package/dist/{cli-5nbmpy8w.js → cli-hb5c2x5y.js} +1 -1
  35. package/dist/{cli-jpf7e1zq.js → cli-hmsw1gpy.js} +3 -3
  36. package/dist/{cli-1cz0c18q.js → cli-jf7wmv1r.js} +2521 -733
  37. package/dist/{cli-qwsnmjdd.js → cli-jxs2ygaw.js} +168 -61
  38. package/dist/cli-kdkwd3yr.js +615 -0
  39. package/dist/{cli-hgmnf38m.js → cli-kh04eqwe.js} +1 -1
  40. package/dist/{cli-0383bp7f.js → cli-kp42yek0.js} +425 -90
  41. package/dist/{cli-ybgv4kw2.js → cli-mbssg1xv.js} +9 -2
  42. package/dist/{cli-jsp14b6c.js → cli-mzfth0bp.js} +38 -1
  43. package/dist/{cli-8g6mc14k.js → cli-nq7yqw7b.js} +16 -5
  44. package/dist/{cli-ee8bc46j.js → cli-nt8hecg9.js} +33 -32
  45. package/dist/{cli-9s68w752.js → cli-p0r8sxj5.js} +1 -1
  46. package/dist/{cli-58e8yr0d.js → cli-pkd6s2wy.js} +4 -2
  47. package/dist/{cli-17xpf7cm.js → cli-q24re23n.js} +6 -4
  48. package/dist/cli-qjjvzwzd.js +222 -0
  49. package/dist/cli-qrgt5bqf.js +189 -0
  50. package/dist/{cli-et8m9xsv.js → cli-rfjw9jw2.js} +22 -10
  51. package/dist/{cli-798vkbks.js → cli-rj0gqwwp.js} +2 -2
  52. package/dist/{cli-121ph74b.js → cli-rnyzn44p.js} +1 -1
  53. package/dist/{cli-vnnz5ncg.js → cli-s3hwpt9v.js} +95 -16
  54. package/dist/{cli-7119ja3w.js → cli-sptrj5f5.js} +2 -2
  55. package/dist/{cli-a8c82p2t.js → cli-sqvfa090.js} +13 -6
  56. package/dist/{cli-phfg8vy8.js → cli-swfmp1fx.js} +26 -26
  57. package/dist/{cli-nsdxtcbg.js → cli-tcen9p5w.js} +8 -5
  58. package/dist/{cli-9m03pzga.js → cli-vc9bp061.js} +5 -5
  59. package/dist/{cli-rtx9dygp.js → cli-vxe6qa4h.js} +1 -1
  60. package/dist/{cli-ecfxwnyb.js → cli-vy3ed88x.js} +1 -1
  61. package/dist/{cli-0c7ata9h.js → cli-vyqs9ya8.js} +6 -6
  62. package/dist/cli-wbvnn6tk.js +861 -0
  63. package/dist/{cli-kgd3p0bm.js → cli-wjgs5wxe.js} +6 -6
  64. package/dist/cli-wxkkd0e0.js +33 -0
  65. package/dist/{cli-akfj32vj.js → cli-xqpk3rp7.js} +5 -4
  66. package/dist/cli-zp38xvtm.js +9487 -0
  67. package/dist/{cli-3zc55ctx.js → cli-zvhd3f64.js} +2 -2
  68. package/dist/{cli-ze1d3wzv.js → cli-zxqx25nz.js} +5 -5
  69. package/dist/cli.js +32 -25
  70. package/dist/{cloudflare-workers-ai-system-one-k6fbxw9d.js → cloudflare-workers-ai-system-one-k8ddqjkq.js} +8 -6
  71. package/dist/{codex-adapter-04v97vep.js → codex-adapter-esvmnhn7.js} +13 -13
  72. package/dist/{codex-hook-20me6byw.js → codex-hook-m73jmqgx.js} +19 -3
  73. package/dist/{codex-session-runner-ax69mqq3.js → codex-session-runner-ban9excr.js} +13 -13
  74. package/dist/{commands-18gy08n1.js → commands-75413p2n.js} +9 -7
  75. package/dist/{db-d2pvjpyt.js → db-7f5pecwg.js} +11 -7
  76. package/dist/{dispatcher-8wbwyzjg.js → dispatcher-fh9at8nz.js} +33 -30
  77. package/dist/{dsh-adapter-ysqk9snv.js → dsh-adapter-wpze65d4.js} +3 -3
  78. package/dist/{e2b-esmt1r16.js → e2b-gjgdjp91.js} +10 -9
  79. package/dist/{pi-logo-animation-e9a0t68z.js → easter-egg-3d-3w5c2n4c.js} +122 -68
  80. package/dist/{execute-cmagrbrz.js → execute-f50wg9nh.js} +29 -6
  81. package/dist/{google-generative-ai-k6n5nbpp.js → google-generative-ai-jxxpnegy.js} +2 -2
  82. package/dist/{google-vertex-44xxtcvp.js → google-vertex-x7q1angm.js} +5 -5
  83. package/dist/{handlers-6b69m5kp.js → handlers-w13787bb.js} +36 -33
  84. package/dist/{hook-srnkt0hb.js → hook-46g1jrvx.js} +28 -15
  85. package/dist/{hook-k3mydf2n.js → hook-4tpts2ry.js} +20 -17
  86. package/dist/{http-hrf80ca1.js → http-19pzbfrt.js} +964 -176
  87. package/dist/{index-9rxdvj6w.js → index-8fd4dm4h.js} +34 -31
  88. package/dist/{index-1asbaj62.js → index-dqmp15bq.js} +35 -32
  89. package/dist/{index-45evq4ee.js → index-qb4q1y3e.js} +33 -30
  90. package/dist/{index-k4nj18qs.js → index-s75wc4wd.js} +12 -10
  91. package/dist/{index-xmevk2b9.js → index-scptvdxg.js} +27 -25
  92. package/dist/{keepalive-sfdz6ksp.js → keepalive-xa5ts9cd.js} +11 -9
  93. package/dist/{lead-ej91e9hw.js → lead-qp4xkvjq.js} +45 -41
  94. package/dist/{maintenance-80dnd7ke.js → maintenance-rm5885hm.js} +13 -11
  95. package/dist/{mistral-conversations-pc3ntnxt.js → mistral-conversations-rw7fbt22.js} +4 -4
  96. package/dist/{node-a5e00qhp.js → node-8zkdq42h.js} +834 -833
  97. package/dist/{oauth-refresh-sweep-m1etk2mx.js → oauth-refresh-sweep-1csdfebf.js} +10 -8
  98. package/dist/{onboard-0q8sdfqc.js → onboard-r6b78cs4.js} +10 -10
  99. package/dist/{openai-codex-responses-n7n683p2.js → openai-codex-responses-efvrgrq3.js} +4 -4
  100. package/dist/{openai-completions-jpaj5g2b.js → openai-completions-bbbrrz4z.js} +12 -8
  101. package/dist/{openai-responses-9a3nme9j.js → openai-responses-9jjyrcm1.js} +15 -10
  102. package/dist/{opencode-adapter-4wcgmvgf.js → opencode-adapter-kb3g6pkf.js} +2 -2
  103. package/dist/{otel-impl-hnqm9mxq.js → otel-impl-0y9knpky.js} +5 -5
  104. package/dist/{pi-mono-adapter-d6sqm9wr.js → pi-mono-adapter-z6s1bme2.js} +100 -19
  105. package/dist/{pricing-refresh-y60z64pm.js → pricing-refresh-mth26tgp.js} +11 -9
  106. package/dist/{rbac-roles-8pa0754b.js → rbac-roles-kwntb23q.js} +13 -9
  107. package/dist/rbac-roles-vpk4ebm8.js +45 -0
  108. package/dist/{registry-v5wytg0s.js → registry-km6t1jd2.js} +1 -1
  109. package/dist/{render-v2-418s1vj7.js → render-v2-hcm7h00y.js} +13 -11
  110. package/dist/{runtime-3pknc168.js → runtime-vfjsfsc5.js} +4 -4
  111. package/dist/{setup-yghdkj4x.js → setup-hbyzd65h.js} +2 -2
  112. package/dist/{virtual-modules-jcceevt5.js → virtual-modules-9bv7ry91.js} +9 -8
  113. package/dist/{work-objects-j1sz20v9.js → work-objects-m09sxnhe.js} +36 -33
  114. package/dist/{worker-d4gcvkjt.js → worker-0h35rvk4.js} +45 -41
  115. package/openapi.json +491 -12
  116. package/package.json +13 -6
  117. package/src/be/api-drain.ts +184 -0
  118. package/src/be/db/tasks/read.ts +31 -6
  119. package/src/be/db/tasks/write.ts +5 -4
  120. package/src/be/db.ts +42 -18
  121. package/src/be/memory/link-resolver.ts +29 -3
  122. package/src/be/migrations/192_favorites_agent_fs_path.sql +36 -0
  123. package/src/be/onboarding.ts +7 -1
  124. package/src/be/seed-prompt-templates.ts +1 -0
  125. package/src/be/swarm-config-guard.ts +35 -16
  126. package/src/be/task-citations.ts +11 -5
  127. package/src/be/users.ts +3 -0
  128. package/src/cli.tsx +3 -2
  129. package/src/comb/agent-fs.ts +39 -0
  130. package/src/comb/config.ts +37 -0
  131. package/src/comb/markers.ts +37 -0
  132. package/src/comb/review-batch.ts +486 -0
  133. package/src/comb/templates.ts +88 -0
  134. package/src/commands/context-preamble.ts +4 -1
  135. package/src/commands/hook.ts +2 -1
  136. package/src/commands/runner.ts +262 -68
  137. package/src/commands/templates.ts +10 -0
  138. package/src/extensions/contract-types.generated.ts +1 -1
  139. package/src/fs/agent-fs-provider.ts +106 -2
  140. package/src/heartbeat/heartbeat.ts +4 -0
  141. package/src/hooks/codex-hook.ts +25 -1
  142. package/src/hooks/hook.ts +13 -0
  143. package/src/hooks/pr-body-guard.ts +983 -0
  144. package/src/http/all-routes.ts +1 -0
  145. package/src/http/assets.ts +5 -17
  146. package/src/http/comb.ts +131 -0
  147. package/src/http/core.ts +2 -1
  148. package/src/http/fs.ts +9 -17
  149. package/src/http/index.ts +23 -9
  150. package/src/http/onboarding.ts +21 -2
  151. package/src/http/poll.ts +30 -3
  152. package/src/http/realtime.ts +62 -0
  153. package/src/http/request-principal.ts +22 -0
  154. package/src/http/status.ts +34 -2
  155. package/src/kv-reserved-namespaces.ts +10 -5
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/pi-mono-adapter.ts +123 -0
  158. package/src/rbac/legacy-policy.ts +1 -0
  159. package/src/rbac/permissions.ts +4 -0
  160. package/src/realtime/auth.ts +63 -1
  161. package/src/realtime/tickets.ts +52 -0
  162. package/src/realtime/transport.ts +51 -22
  163. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -1
  164. package/src/telemetry-context.ts +175 -0
  165. package/src/telemetry-contract/EVENTS.md +81 -0
  166. package/src/telemetry-contract/SOURCE.json +9 -0
  167. package/src/telemetry-contract/catalog.json +1335 -0
  168. package/src/telemetry-contract/index.ts +60 -0
  169. package/src/telemetry-contract/types.gen.ts +1971 -0
  170. package/src/telemetry-identity.ts +129 -0
  171. package/src/telemetry-snapshot.ts +211 -0
  172. package/src/telemetry-trigger.ts +125 -0
  173. package/src/telemetry-types.typetest.ts +40 -0
  174. package/src/telemetry.ts +271 -67
  175. package/src/tests/api-drain-e2e.test.ts +249 -0
  176. package/src/tests/api-drain-worker-e2e.test.ts +216 -0
  177. package/src/tests/api-drain.test.ts +351 -0
  178. package/src/tests/comb-config-guard.test.ts +38 -0
  179. package/src/tests/comb-links.test.ts +162 -0
  180. package/src/tests/comb-markers.test.ts +68 -0
  181. package/src/tests/comb-review-batch.test.ts +770 -0
  182. package/src/tests/comb-status.test.ts +152 -0
  183. package/src/tests/db-task-read-contract.test.ts +2 -0
  184. package/src/tests/db-task-write-contract.test.ts +1 -0
  185. package/src/tests/docs-index-footprint.test.js +3 -3
  186. package/src/tests/dsh-adapter.test.ts +1 -1
  187. package/src/tests/favorites-agent-fs-path.test.ts +226 -0
  188. package/src/tests/memory-link-resolver.test.ts +34 -0
  189. package/src/tests/onboarding.test.ts +108 -0
  190. package/src/tests/pi-mono-adapter.test.ts +265 -2
  191. package/src/tests/pr-body-leaks.test.ts +789 -0
  192. package/src/tests/preload.ts +8 -0
  193. package/src/tests/rbac-engine.test.ts +19 -0
  194. package/src/tests/realtime-room-auth.test.ts +104 -1
  195. package/src/tests/realtime-tickets.test.ts +74 -0
  196. package/src/tests/realtime-transport.test.ts +131 -4
  197. package/src/tests/runner-api-drain.test.ts +301 -0
  198. package/src/tests/runner-fallback-output.test.ts +61 -0
  199. package/src/tests/runner-fenced-json-output.test.ts +178 -0
  200. package/src/tests/script-connections.test.ts +2 -0
  201. package/src/tests/send-task-requested-by.test.ts +133 -15
  202. package/src/tests/task-lifecycle-telemetry.test.ts +8 -3
  203. package/src/tests/telemetry-context.test.ts +314 -0
  204. package/src/tests/telemetry-init.test.ts +51 -19
  205. package/src/tests/telemetry-snapshot.test.ts +229 -0
  206. package/src/tests/telemetry-trigger.test.ts +200 -0
  207. package/src/tests/template-registry-helpers.ts +1 -0
  208. package/src/tests/workflow-agent-task-retry.test.ts +363 -0
  209. package/src/tests/workflow-definition-telemetry.test.ts +4 -4
  210. package/src/tools/poll-task.ts +20 -1
  211. package/src/tools/send-task.ts +9 -1
  212. package/src/tools/task-action.ts +11 -0
  213. package/src/types.ts +2 -1
  214. package/src/utils/api-drain.ts +24 -0
  215. package/src/utils/constants.ts +57 -16
  216. package/src/utils/pr-body-leaks.ts +172 -0
  217. package/src/utils/task-attachment-links.ts +14 -8
  218. package/src/workflows/recovery.ts +26 -1
  219. package/src/workflows/resume.ts +31 -4
  220. package/src/workflows/task-step-routing.ts +75 -2
  221. package/tsconfig.json +3 -0
  222. package/dist/cli-tjf4cn8z.js +0 -9119
  223. package/dist/cli-tsk3nz8a.js +0 -582
  224. package/dist/{cli-t6v2ah3r.js → cli-twrh04ap.js} +3 -3
@@ -16,6 +16,7 @@ import "./assets";
16
16
  import "./budgets";
17
17
  import "./codex-oauth-device";
18
18
  import "./codex-oauth-keep-warm";
19
+ import "./comb";
19
20
  import "./config";
20
21
  import "./context";
21
22
  import "./core";
@@ -5,7 +5,6 @@ import { auditAssetKeys } from "../be/asset-key-audit";
5
5
  import { AssetKeyAuthorizationError, authorizeAssetKeyWrite } from "../be/asset-key-auth";
6
6
  import { resolveHttpAuditUserId } from "../be/audit-user";
7
7
  import {
8
- getAgentById,
9
8
  getDb,
10
9
  getDbClient,
11
10
  getTaskById,
@@ -14,7 +13,7 @@ import {
14
13
  upsertAssetKeyMapping,
15
14
  } from "../be/db";
16
15
  import { getScriptById } from "../be/scripts/db";
17
- import { can, type RbacPrincipal, type RbacResource } from "../rbac";
16
+ import { can, type RbacResource } from "../rbac";
18
17
  import {
19
18
  type AssetEntityType,
20
19
  AssetEntityTypeSchema,
@@ -23,6 +22,7 @@ import {
23
22
  AssetSummarySchema,
24
23
  } from "../types";
25
24
  import { getRequestAuth } from "../utils/request-auth-context";
25
+ import { requestPrincipal } from "./request-principal";
26
26
  import { route } from "./route-def";
27
27
  import { jsonError } from "./utils";
28
28
 
@@ -176,18 +176,6 @@ const moveAssetRoute = route({
176
176
  },
177
177
  });
178
178
 
179
- async function assetMovePrincipal(
180
- req: IncomingMessage,
181
- myAgentId: string | undefined,
182
- ): Promise<RbacPrincipal | null> {
183
- const auth = getRequestAuth(req);
184
- if (auth?.kind === "operator") return { kind: "operator" };
185
- if (auth?.kind === "user") return { kind: "user", userId: auth.userId };
186
- if (!myAgentId) return null;
187
- const agent = await getAgentById(myAgentId);
188
- return { kind: "agent", agentId: myAgentId, isLead: agent?.isLead ?? false };
189
- }
190
-
191
179
  async function canMutateTaskNamespace(
192
180
  task: { id: string; agentId: string | null; creatorAgentId?: string },
193
181
  myAgentId: string | undefined,
@@ -199,7 +187,7 @@ async function canMutateTaskNamespace(
199
187
  agentId: task.agentId,
200
188
  creatorAgentId: task.creatorAgentId,
201
189
  };
202
- const principal = await assetMovePrincipal(req, myAgentId);
190
+ const principal = await requestPrincipal(req, myAgentId);
203
191
  if (!principal) return false;
204
192
  return can({ principal, verb: "task.fs.mutate", resource, source: "http" }).allow;
205
193
  }
@@ -209,7 +197,7 @@ async function canManageAppNamespace(
209
197
  req: IncomingMessage,
210
198
  myAgentId: string | undefined,
211
199
  ): Promise<boolean> {
212
- const principal = await assetMovePrincipal(req, myAgentId);
200
+ const principal = await requestPrincipal(req, myAgentId);
213
201
  return (
214
202
  !!principal &&
215
203
  can({ principal, verb: "app.manage", resource: { kind: "app", appId: id }, source: "http" })
@@ -222,7 +210,7 @@ async function canManageScriptNamespace(
222
210
  req: IncomingMessage,
223
211
  myAgentId: string | undefined,
224
212
  ): Promise<boolean> {
225
- const principal = await assetMovePrincipal(req, myAgentId);
213
+ const principal = await requestPrincipal(req, myAgentId);
226
214
  if (!principal) return false;
227
215
  if (principal.kind === "operator") return true;
228
216
  if (principal.kind !== "agent") return false;
@@ -0,0 +1,131 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { resolveHttpAuditUserId } from "../be/audit-user";
4
+ import { getCombConfig } from "../comb/config";
5
+ import { REVIEW_BATCH_MAX, ReviewBatchError, sendReviewBatch } from "../comb/review-batch";
6
+ import { can } from "../rbac";
7
+ import { TaskCreationBlockedError } from "../tasks/errors";
8
+ import { scrubSecrets } from "../utils/secret-scrubber";
9
+ import { requestPrincipal } from "./request-principal";
10
+ import { route } from "./route-def";
11
+ import { jsonError } from "./utils";
12
+
13
+ const skippedSchema = z.object({
14
+ id: z.string(),
15
+ reason: z.enum(["not-found", "reply", "resolved", "already-sent"]),
16
+ /** The task an "already-sent" comment went to, when known. */
17
+ taskId: z.string().optional(),
18
+ });
19
+
20
+ const resultSchema = z.object({
21
+ /** The new task. Null on a 200: the batch only posted missing "sent" replies again. */
22
+ taskId: z.string().nullable(),
23
+ sent: z.array(z.string()),
24
+ skipped: z.array(skippedSchema),
25
+ /** Comments of an earlier send whose missing "sent" reply this batch posted again. */
26
+ repaired: z.array(z.object({ id: z.string(), taskId: z.string() })),
27
+ });
28
+
29
+ const batchErrorSchema = z.object({ error: z.string(), skipped: z.array(skippedSchema) });
30
+
31
+ /** A drive path: starts with "/", no "." or ".." segment. */
32
+ function isDrivePath(path: string): boolean {
33
+ return (
34
+ path.startsWith("/") && !path.split("/").some((segment) => segment === "." || segment === "..")
35
+ );
36
+ }
37
+
38
+ const reviewBatchRoute = route({
39
+ method: "post",
40
+ path: "/api/comb/review-batches",
41
+ pattern: ["api", "comb", "review-batches"],
42
+ summary: "Send agent-fs comments to the swarm as one lead task",
43
+ description:
44
+ "Comb's 'Send to swarm'. The server reads each comment again from agent-fs with its bootstrap key, skips replies, resolved comments, and comments already sent, and creates ONE task for the lead. Each sent comment gets a `[comb:sent task=<id>]` reply from the swarm service account. A comment is sent at most once. When an earlier send lost its reply, the batch posts that reply again (`repaired`). Answers 404 while COMB_ENABLED is off.",
45
+ tags: ["Comb"],
46
+ body: z.object({
47
+ orgId: z.string().min(1),
48
+ driveId: z.string().min(1),
49
+ // agent-fs comment ids are UUIDs, so a malformed id never costs an agent-fs read.
50
+ commentIds: z.array(z.uuid()).min(1).max(REVIEW_BATCH_MAX),
51
+ /** The file or folder the batch was sent from (a Comb path such as "/docs/"). */
52
+ scopePath: z
53
+ .string()
54
+ .min(1)
55
+ .max(1024)
56
+ .refine(isDrivePath, "scopePath must start with '/' and have no '.' or '..' segment"),
57
+ }),
58
+ responses: {
59
+ 200: {
60
+ description: "No new task: the batch only posted missing 'sent' replies again",
61
+ schema: resultSchema,
62
+ },
63
+ 201: { description: "Task created", schema: resultSchema },
64
+ 400: { description: "Invalid body, or not the swarm drive" },
65
+ 403: { description: "Caller cannot create tasks" },
66
+ 404: { description: "Comb is not enabled" },
67
+ 409: {
68
+ description: "Nothing to send (every comment was skipped)",
69
+ schema: batchErrorSchema,
70
+ },
71
+ 422: { description: "Task creation blocked by an extension" },
72
+ 502: { description: "agent-fs could not read a comment" },
73
+ 503: {
74
+ description:
75
+ "agent-fs or the swarm drive is not set up, or an operator disabled a Comb review template",
76
+ schema: batchErrorSchema,
77
+ },
78
+ },
79
+ rbac: { permission: "task.create.own" },
80
+ });
81
+
82
+ export async function handleComb(
83
+ req: IncomingMessage,
84
+ res: ServerResponse,
85
+ pathSegments: string[],
86
+ queryParams: URLSearchParams,
87
+ myAgentId?: string,
88
+ ): Promise<boolean> {
89
+ if (!reviewBatchRoute.match(req.method, pathSegments)) return false;
90
+ // Comb off: the route does not exist. No body parsing, no RBAC answer.
91
+ if (!getCombConfig().enabled) {
92
+ jsonError(res, "Comb is not enabled", 404);
93
+ return true;
94
+ }
95
+
96
+ const parsed = await reviewBatchRoute.parse(req, res, pathSegments, queryParams);
97
+ if (!parsed) return true;
98
+ const principal = await requestPrincipal(req, myAgentId);
99
+ if (
100
+ !principal ||
101
+ !can({ principal, verb: "task.create.own", resource: { kind: "none" }, source: "http" }).allow
102
+ ) {
103
+ jsonError(res, "Not authorized to create tasks", 403);
104
+ return true;
105
+ }
106
+
107
+ try {
108
+ const result = await sendReviewBatch({
109
+ ...parsed.body,
110
+ requestedByUserId: await resolveHttpAuditUserId(req, myAgentId),
111
+ });
112
+ reviewBatchRoute.respond(res, result.taskId === null ? 200 : 201, result);
113
+ } catch (error) {
114
+ if (error instanceof ReviewBatchError) {
115
+ if (error.status === 409 || error.status === 503) {
116
+ reviewBatchRoute.respond(res, error.status, {
117
+ error: error.message,
118
+ skipped: error.skipped,
119
+ });
120
+ } else {
121
+ jsonError(res, error.message, error.status);
122
+ }
123
+ } else if (error instanceof TaskCreationBlockedError) {
124
+ jsonError(res, error.reason, 422);
125
+ } else {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ jsonError(res, scrubSecrets(`Failed to send comments to the swarm: ${message}`), 500);
128
+ }
129
+ }
130
+ return true;
131
+ }
package/src/http/core.ts CHANGED
@@ -360,7 +360,8 @@ const pingRoute = route({
360
360
  `Refreshes the calling agent's status. ${RUNTIME_HEADER_DOC} With multi-runtime mode on, ` +
361
361
  "the header must identify a live runtime of this agent; an absent, unknown, offline, or " +
362
362
  "foreign identifier makes the call a no-op instead of an error, so workers predating the " +
363
- "flag keep running.",
363
+ "flag keep running. While the API is draining after SIGTERM, every response (this one " +
364
+ "included) carries `X-Swarm-Draining: 1`: workers hand off in-flight tasks and take no new work.",
364
365
  tags: ["Core"],
365
366
  auth: { apiKey: true, agentId: true },
366
367
  headers: runtimeInstanceHeader("refresh a runtime's liveness"),
package/src/http/fs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { deleteTaskAttachment, getAgentById, getTaskAttachments, getTaskById } from "../be/db";
3
+ import { deleteTaskAttachment, getTaskAttachments, getTaskById } from "../be/db";
4
4
  import {
5
5
  ensureAgentFsCredentialsForAgent,
6
6
  inviteEmailToSharedOrg,
@@ -8,11 +8,12 @@ import {
8
8
  import { MAX_TASK_ATTACHMENT_BYTES, recordTaskAttachmentUpload } from "../be/task-attachment-store";
9
9
  import { type FileObject, type FileScope, FilesError, normalizeFilesError } from "../fs/provider";
10
10
  import { getFileStorageProvider } from "../fs/registry";
11
- import { can, type RbacPrincipal, type RbacResource } from "../rbac";
11
+ import { can, type RbacResource } from "../rbac";
12
12
  import { type TaskAttachment, TaskAttachmentSchema } from "../types";
13
13
  import { attachmentContentDisposition } from "../utils/content-disposition";
14
- import { getCurrentRequestAuth, getRequestAuth } from "../utils/request-auth-context";
14
+ import { getCurrentRequestAuth } from "../utils/request-auth-context";
15
15
  import { scrubSecrets } from "../utils/secret-scrubber";
16
+ import { requestPrincipal } from "./request-principal";
16
17
  import { route } from "./route-def";
17
18
  import { BODY_TOO_LARGE, enforceContentLengthCap, jsonError } from "./utils";
18
19
 
@@ -522,20 +523,11 @@ async function canMutateTask(
522
523
  // Decision order preserved (plan Appendix A row 36): operator/user request
523
524
  // auth short-circuits BEFORE agent identity — an operator bearer with a
524
525
  // non-owner X-Agent-ID is still allowed. The agent branches only bind when
525
- // the request-auth context is unset.
526
- const auth = getRequestAuth(req);
527
- let principal: RbacPrincipal;
528
- if (auth?.kind === "operator") {
529
- principal = { kind: "operator" };
530
- } else if (auth?.kind === "user") {
531
- principal = { kind: "user", userId: auth.userId };
532
- } else {
533
- // A missing caller identity cannot be lead/assignee/creator — same denial
534
- // as before (no separate "agent not found" branch).
535
- if (!myAgentId) return false;
536
- const agent = await getAgentById(myAgentId);
537
- principal = { kind: "agent", agentId: myAgentId, isLead: agent?.isLead ?? false };
538
- }
526
+ // the request-auth context is unset. A missing caller identity cannot be
527
+ // lead/assignee/creator: same denial as before (no separate "agent not
528
+ // found" branch).
529
+ const principal = await requestPrincipal(req, myAgentId);
530
+ if (!principal) return false;
539
531
  return can({ principal, verb: "task.fs.mutate", resource, source: "http" }).allow;
540
532
  }
541
533
 
package/src/http/index.ts CHANGED
@@ -9,6 +9,7 @@ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/se
9
9
  import { getEnabledCapabilities, hasCapability } from "@/server";
10
10
  import { initAgentMail } from "../agentmail";
11
11
  import { initAzureDevOps } from "../azure-devops";
12
+ import { drainApi, isApiDraining } from "../be/api-drain";
12
13
  import {
13
14
  closeDb,
14
15
  emitBuiltInIntegrationConnectedOnce,
@@ -53,6 +54,8 @@ import { startScriptRunSupervisor, stopScriptRunSupervisor } from "../script-wor
53
54
  import { getServerSessionsProcessed } from "../server-runtime-counters";
54
55
  import { startSlackApp, stopSlackApp } from "../slack";
55
56
  import { initTelemetry, telemetry } from "../telemetry";
57
+ import { startTelemetryTicker } from "../telemetry-snapshot";
58
+ import { API_DRAINING_HEADER } from "../utils/api-drain";
56
59
  import { getApiKey } from "../utils/api-key";
57
60
  import { getMcpBaseUrl } from "../utils/constants";
58
61
  import { isEnvFlagEnabled } from "../utils/env-flag";
@@ -67,6 +70,7 @@ import { handleAssets } from "./assets";
67
70
  import { handleBudgets } from "./budgets";
68
71
  import { handleCodexOAuthDevice } from "./codex-oauth-device";
69
72
  import { handleCodexOAuthKeepWarm } from "./codex-oauth-keep-warm";
73
+ import { handleComb } from "./comb";
70
74
  import { handleConfig } from "./config";
71
75
  import { handleContext } from "./context";
72
76
  import { handleCore, loadGlobalConfigsIntoEnv } from "./core";
@@ -104,7 +108,7 @@ import { handlePagesPublic } from "./pages-public";
104
108
  import { handlePoll } from "./poll";
105
109
  import { handlePricing } from "./pricing";
106
110
  import { handlePromptTemplates } from "./prompt-templates";
107
- import { handleRealtimeAsset } from "./realtime";
111
+ import { handleRealtimeAsset, handleRealtimeTicket } from "./realtime";
108
112
  import { handleRepos } from "./repos";
109
113
  import { handleRooms } from "./rooms";
110
114
  import { describeRequestRoute } from "./route-def";
@@ -318,6 +322,8 @@ const httpServer = createHttpServer(async (req, res) => {
318
322
  // nest under it instead of attaching to the root with no parent.
319
323
  const handleRequest = async () => {
320
324
  setCorsHeaders(req, res);
325
+ // Tells polling workers to hand off in-flight tasks while this API still serves.
326
+ if (isApiDraining()) res.setHeader(API_DRAINING_HEADER, "1");
321
327
 
322
328
  const queryParams = parseQueryParams(req.url || "");
323
329
  const myAgentId = req.headers["x-agent-id"] as string | undefined;
@@ -348,8 +354,10 @@ const httpServer = createHttpServer(async (req, res) => {
348
354
  () => handleApps(req, res, pathSegments, queryParams, myAgentId),
349
355
  () => handleConfig(req, res, pathSegments, queryParams),
350
356
  () => handleFs(req, res, pathSegments, queryParams, myAgentId),
357
+ () => handleComb(req, res, pathSegments, queryParams, myAgentId),
351
358
  () => handleKv(req, res, pathSegments, queryParams),
352
359
  () => handleRooms(req, res, pathSegments, queryParams),
360
+ () => handleRealtimeTicket(req, res, pathSegments, queryParams),
353
361
  () => handleRealtimeAsset(req, res),
354
362
  () => handleIntegrations(req, res, pathSegments, queryParams),
355
363
  () => handlePromptTemplates(req, res, pathSegments, queryParams),
@@ -469,6 +477,10 @@ async function shutdown() {
469
477
  // Stop the out-of-band queue alarm before disconnecting its Slack notifier.
470
478
  stopQueueStallAlarm();
471
479
 
480
+ // Dispatch has stopped. Keep serving, bounded, while workers hand off their
481
+ // in-flight tasks; new work waits for the next API (see src/be/api-drain.ts).
482
+ await drainApi();
483
+
472
484
  // Stop durable script workflow subprocesses
473
485
  await stopScriptRunSupervisor();
474
486
 
@@ -684,15 +696,17 @@ httpServer
684
696
  // The api-server is the sole authority for the install identity — pass
685
697
  // generateIfMissing so it mints a new install ID on first boot. Workers
686
698
  // must NOT mint (see src/commands/runner.ts).
687
- await initTelemetry(
688
- "api-server",
689
- async (key) => (await getSwarmConfigs({ scope: "global", key }))?.[0]?.value,
690
- async (key, value) => {
691
- await upsertSwarmConfig({ scope: "global", key, value });
692
- },
693
- { generateIfMissing: true },
694
- );
699
+ const telemetryGetConfig = async (key: string) =>
700
+ (await getSwarmConfigs({ scope: "global", key }))?.[0]?.value;
701
+ const telemetrySetConfig = async (key: string, value: string) => {
702
+ await upsertSwarmConfig({ scope: "global", key, value });
703
+ };
704
+ await initTelemetry("api-server", telemetryGetConfig, telemetrySetConfig, {
705
+ generateIfMissing: true,
706
+ });
695
707
  telemetry.server("started", { port });
708
+ // Org email domain (hourly + on user changes) and the daily org.snapshot.
709
+ startTelemetryTicker({ getConfig: telemetryGetConfig, setConfig: telemetrySetConfig });
696
710
  if (process.env.GITHUB_TOKEN) {
697
711
  await emitBuiltInIntegrationConnectedOnce("github");
698
712
  }
@@ -128,10 +128,27 @@ function memoryErrorStatus(error: unknown): number | undefined {
128
128
  return Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
129
129
  }
130
130
 
131
+ /**
132
+ * A 404 names the model or deployment only when the provider says so: OpenAI
133
+ * answers `model_not_found`, Azure `DeploymentNotFound`. A bare 404 (Azure's
134
+ * `Resource not found`, a proxy page) means the path itself does not exist.
135
+ */
136
+ function notFoundNamesModel(error: unknown): boolean {
137
+ if (typeof error !== "object" || error === null) return false;
138
+ const { code, type, error: body } = error as { code?: unknown; type?: unknown; error?: unknown };
139
+ const bodyMessage =
140
+ typeof body === "object" && body !== null ? (body as { message?: unknown }).message : undefined;
141
+ const text = [code, type, bodyMessage]
142
+ .filter((part): part is string => typeof part === "string")
143
+ .join(" ")
144
+ .toLowerCase();
145
+ return text.includes("model") || text.includes("deployment");
146
+ }
147
+
131
148
  function classifyMemoryError(error: unknown): OnboardingErrorClass {
132
149
  const status = memoryErrorStatus(error);
133
150
  if (status === 401 || status === 403) return "auth";
134
- if (status === 404) return "model";
151
+ if (status === 404) return notFoundNamesModel(error) ? "model" : "endpoint";
135
152
  if (status === 408) return "timeout";
136
153
  const text =
137
154
  error instanceof Error
@@ -157,7 +174,9 @@ function memoryErrorMessage(errorClass: OnboardingErrorClass, status?: number):
157
174
  case "auth":
158
175
  return `The endpoint rejected the key${statusSuffix}.`;
159
176
  case "model":
160
- return `The endpoint could not find the embedding model${statusSuffix}.`;
177
+ return `The endpoint could not find the embedding model or deployment${statusSuffix}.`;
178
+ case "endpoint":
179
+ return `The endpoint path was not found${statusSuffix}. Check the base URL.`;
161
180
  case "timeout":
162
181
  return "The endpoint timed out.";
163
182
  case "network":
package/src/http/poll.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { ensure } from "@desplega.ai/business-use";
3
3
  import { z } from "zod";
4
+ import { isApiDraining } from "../be/api-drain";
4
5
  import { canClaim } from "../be/budget-admission";
5
6
  import {
6
7
  type BudgetRefusalContext,
@@ -37,7 +38,7 @@ import { poolTaskRunsOnHarness } from "../be/model-validation";
37
38
  import { touchRuntimeInstance } from "../be/multi-runtime";
38
39
  import { hasCapability } from "../server";
39
40
  import { fetchChannelActivity } from "../slack/channel-activity";
40
- import { telemetry } from "../telemetry";
41
+ import { emitTaskTelemetry, resolveTriggerSurface } from "../telemetry-trigger";
41
42
  import {
42
43
  AgentTaskSchema,
43
44
  BudgetRefusedTriggerSchema,
@@ -123,6 +124,9 @@ const PollTaskOfferedTriggerSchema = z.object({
123
124
  const PollTaskAssignedTriggerSchema = z.object({
124
125
  type: z.literal("task_assigned"),
125
126
  taskId: z.string(),
127
+ // Surface that started the whole task chain (root task's source, mapped to
128
+ // the telemetry catalog). Workers tag their session telemetry with it.
129
+ triggerSurface: z.string().optional(),
126
130
  task: AgentTaskSchema.extend({
127
131
  attachments: z.array(PollTriggerAttachmentSchema),
128
132
  }),
@@ -172,6 +176,9 @@ const pollTriggers = route({
172
176
  path: "/api/poll",
173
177
  pattern: ["api", "poll"],
174
178
  summary: "Poll for triggers (tasks, mentions)",
179
+ description:
180
+ "While the API is draining after SIGTERM it dispatches nothing: the answer is `{ trigger: null }` " +
181
+ "and carries `X-Swarm-Draining: 1`, the signal for a worker to hand off in-flight tasks.",
175
182
  tags: ["Poll"],
176
183
  auth: { apiKey: true, agentId: true },
177
184
  headers: runtimeInstanceHeader("poll for work").extend({
@@ -369,6 +376,13 @@ export async function handlePoll(
369
376
  return { trigger: null };
370
377
  }
371
378
 
379
+ // A draining API dispatches nothing: offers, assignments and pool claims
380
+ // wait for the next API. Workers learn to hand off from the
381
+ // X-Swarm-Draining header the HTTP pipeline adds (src/be/api-drain.ts).
382
+ if (isApiDraining()) {
383
+ return { trigger: null };
384
+ }
385
+
372
386
  // Check for offered tasks first (highest priority for both workers and leads)
373
387
  // Atomically claim the task for review to prevent duplicate processing.
374
388
  // Capacity is checked in the same transaction as the claim: with
@@ -470,7 +484,7 @@ export async function handlePoll(
470
484
  conditions: [{ timeout_ms: 300_000 }], // 5 min: polling interval + queue wait
471
485
  });
472
486
 
473
- telemetry.taskEvent("started", {
487
+ void emitTaskTelemetry("started", {
474
488
  taskId: pendingTask.id,
475
489
  source: pendingTask.source,
476
490
  agentId: myAgentId,
@@ -480,11 +494,18 @@ export async function handlePoll(
480
494
  // Resolve requesting user if available (UNKNOWN sentinel handling
481
495
  // lives in buildTriggerRequestedBy).
482
496
  const assignedRequestedBy = await buildTriggerRequestedBy(pendingTask);
497
+ // The surface that started the whole chain, so the worker can tag
498
+ // its session events with it (workers cannot read the task tree).
499
+ const assignedTriggerSurface = await resolveTriggerSurface(
500
+ pendingTask.id,
501
+ pendingTask.source,
502
+ );
483
503
 
484
504
  return {
485
505
  trigger: {
486
506
  type: "task_assigned",
487
507
  taskId: pendingTask.id,
508
+ triggerSurface: assignedTriggerSurface,
488
509
  task: {
489
510
  ...pendingTask,
490
511
  ...(await claimModelFields(pendingTask, agent)),
@@ -612,17 +633,22 @@ export async function handlePoll(
612
633
  // Post-commit (see the `started` path above): a rolled-back
613
634
  // claim must not report the task as claimed.
614
635
  getDbClient().afterCommit(() => {
615
- telemetry.taskEvent("claimed", {
636
+ void emitTaskTelemetry("claimed", {
616
637
  taskId: claimed.id,
617
638
  source: claimed.source,
618
639
  agentId: myAgentId,
619
640
  });
620
641
  });
621
642
  const claimedRequestedBy = await buildTriggerRequestedBy(claimed);
643
+ const claimedTriggerSurface = await resolveTriggerSurface(
644
+ claimed.id,
645
+ claimed.source,
646
+ );
622
647
  return {
623
648
  trigger: {
624
649
  type: "task_assigned",
625
650
  taskId: claimed.id,
651
+ triggerSurface: claimedTriggerSurface,
626
652
  task: {
627
653
  ...claimed,
628
654
  ...(await claimModelFields(claimed, agent)),
@@ -672,6 +698,7 @@ export async function handlePoll(
672
698
  // Throttled to avoid Slack API rate limits (~50 calls/min).
673
699
  if (
674
700
  result.trigger === null &&
701
+ !isApiDraining() &&
675
702
  process.env.LEAD_MONITOR_CHANNELS === "true" &&
676
703
  Date.now() - lastChannelActivityCheckAt >= CHANNEL_ACTIVITY_INTERVAL_MS
677
704
  ) {
@@ -1,6 +1,29 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { getCombConfig } from "../comb/config";
4
+ import { can } from "../rbac";
2
5
  import bundle from "../realtime/browser.generated.txt" with { type: "text" };
6
+ import { issueRealtimeTicket } from "../realtime/tickets";
7
+ import { getRequestAuth } from "../utils/request-auth-context";
3
8
  import { route } from "./route-def";
9
+ import { jsonError } from "./utils";
10
+
11
+ const realtimeTicketRoute = route({
12
+ method: "post",
13
+ path: "/api/realtime/ticket",
14
+ pattern: ["api", "realtime", "ticket"],
15
+ summary: "Issue a one-shot dashboard realtime ticket",
16
+ tags: ["Rooms"],
17
+ rbac: { permission: "comb.presence" },
18
+ responses: {
19
+ 200: {
20
+ description: "One-shot realtime ticket",
21
+ schema: z.object({ ticket: z.string(), expiresAt: z.number().int() }),
22
+ },
23
+ 403: { description: "Comb presence is not allowed" },
24
+ 404: { description: "Comb is not enabled" },
25
+ },
26
+ });
4
27
 
5
28
  route({
6
29
  method: "get",
@@ -14,6 +37,45 @@ route({
14
37
  },
15
38
  });
16
39
 
40
+ /** `POST /api/realtime/ticket`: a one-shot ticket for a dashboard realtime socket (Comb presence). */
41
+ export async function handleRealtimeTicket(
42
+ req: IncomingMessage,
43
+ res: ServerResponse,
44
+ pathSegments: string[],
45
+ queryParams: URLSearchParams,
46
+ ): Promise<boolean> {
47
+ if (!realtimeTicketRoute.match(req.method, pathSegments)) return false;
48
+ // Comb presence is the only ticket consumer, so the route is off with Comb.
49
+ if (!getCombConfig().enabled) {
50
+ jsonError(res, "Comb is not enabled", 404);
51
+ return true;
52
+ }
53
+ const parsed = await realtimeTicketRoute.parse(req, res, pathSegments, queryParams);
54
+ if (!parsed) return true;
55
+ const auth = getRequestAuth(req);
56
+ if (!auth || auth.kind === "agent") {
57
+ jsonError(res, "Comb presence requires dashboard authentication", 403);
58
+ return true;
59
+ }
60
+ const principal =
61
+ auth.kind === "operator"
62
+ ? ({ kind: "operator" } as const)
63
+ : ({ kind: "user", userId: auth.userId } as const);
64
+ if (
65
+ !can({
66
+ principal,
67
+ verb: "comb.presence",
68
+ resource: { kind: "none" },
69
+ source: "http",
70
+ }).allow
71
+ ) {
72
+ jsonError(res, "Comb presence requires the comb.presence permission", 403);
73
+ return true;
74
+ }
75
+ realtimeTicketRoute.respond(res, 200, issueRealtimeTicket(auth));
76
+ return true;
77
+ }
78
+
17
79
  export async function handleRealtimeAsset(
18
80
  req: IncomingMessage,
19
81
  res: ServerResponse,
@@ -0,0 +1,22 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import { getAgentById } from "../be/db";
3
+ import type { RbacPrincipal } from "../rbac";
4
+ import { getRequestAuth } from "../utils/request-auth-context";
5
+
6
+ /**
7
+ * The RBAC principal of an HTTP request, for a handler-side `can()` check.
8
+ * Operator and user request auth win over the X-Agent-ID header (an operator
9
+ * bearer with any agent id stays the operator). An agent id that is not
10
+ * registered is a non-lead agent. Null when the request has neither.
11
+ */
12
+ export async function requestPrincipal(
13
+ req: IncomingMessage,
14
+ myAgentId: string | undefined,
15
+ ): Promise<RbacPrincipal | null> {
16
+ const auth = getRequestAuth(req);
17
+ if (auth?.kind === "operator") return { kind: "operator" };
18
+ if (auth?.kind === "user") return { kind: "user", userId: auth.userId };
19
+ if (!myAgentId) return null;
20
+ const agent = await getAgentById(myAgentId);
21
+ return { kind: "agent", agentId: myAgentId, isLead: agent?.isLead ?? false };
22
+ }