@desplega.ai/agent-swarm 2.2.0 → 2.4.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 (266) hide show
  1. package/README.md +1 -0
  2. package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-kedstxx6.js} +6 -6
  3. package/dist/{actions-w4pnb8vz.js → actions-vget6r1k.js} +18 -16
  4. package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-xeb5mktv.js} +2 -2
  5. package/dist/{app-zneem7g9.js → app-mqap8t8m.js} +3 -3
  6. package/dist/{assistant-wbzd2rnr.js → assistant-h0gv97mv.js} +20 -18
  7. package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-qr8a894z.js} +1 -1
  8. package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-rj85jjhw.js} +1 -1
  9. package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
  10. package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-xs9a8dx1.js} +4 -4
  11. package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-bm23cye7.js} +5 -5
  12. package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-1mwrsm9d.js} +2 -2
  13. package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-gkfgks4j.js} +1 -1
  14. package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-apb4ypz3.js} +5 -5
  15. package/dist/cli-02vhhssk.js +848 -0
  16. package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
  17. package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
  18. package/dist/{cli-8c1m9mhy.js → cli-1cevz46c.js} +5 -5
  19. package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
  20. package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
  21. package/dist/{cli-vmbakbq0.js → cli-2rwet3r7.js} +1 -1
  22. package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
  23. package/dist/{cli-tba8fxf9.js → cli-4fhyq711.js} +45 -36
  24. package/dist/{cli-ak4c12mh.js → cli-5ff2sjyt.js} +1 -1
  25. package/dist/{cli-6q7pyd3k.js → cli-5s5hx57y.js} +5 -3
  26. package/dist/{cli-h23mr2s6.js → cli-6ftrrwm1.js} +9 -371
  27. package/dist/{cli-9ac0mkg5.js → cli-8b9wvjps.js} +1 -1
  28. package/dist/cli-8cxkw1f5.js +382 -0
  29. package/dist/{cli-xhgtay7w.js → cli-8rwvksbc.js} +2 -2
  30. package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
  31. package/dist/{cli-ksrphveb.js → cli-a1esmmee.js} +14 -2
  32. package/dist/{cli-xnp2vkx7.js → cli-avmqhatn.js} +7 -7
  33. package/dist/{cli-xx13gqf3.js → cli-b80a1100.js} +1 -1
  34. package/dist/{cli-z1hh99j0.js → cli-bfc3k1x5.js} +3 -3
  35. package/dist/{cli-0k9rcwfy.js → cli-bhwa8rzc.js} +33 -15
  36. package/dist/{cli-8cvvytad.js → cli-bn26xbcs.js} +2 -2
  37. package/dist/{cli-k28p6xh7.js → cli-c8fwpgqb.js} +1 -1
  38. package/dist/{cli-rc3r0bnh.js → cli-ccn0eybf.js} +1 -1
  39. package/dist/{cli-9td5ss65.js → cli-dsgj3e7a.js} +2 -2
  40. package/dist/{cli-jrk8mj0t.js → cli-ecshf504.js} +2 -2
  41. package/dist/{cli-raejy0mp.js → cli-eqgfs202.js} +1 -1
  42. package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
  43. package/dist/{cli-c9vrwcpb.js → cli-ffbrkh1r.js} +74 -35
  44. package/dist/{cli-mnry16kh.js → cli-kk2r80n5.js} +4 -4
  45. package/dist/{cli-c0dpzwnx.js → cli-kz116kvw.js} +3 -3
  46. package/dist/{cli-e5y285rg.js → cli-mvg2hf8p.js} +4 -4
  47. package/dist/{cli-2jm992rm.js → cli-mz1ezzzh.js} +2 -2
  48. package/dist/{cli-dw0njd9q.js → cli-nme7wery.js} +6 -6
  49. package/dist/{cli-mvsfbbgq.js → cli-nrrg0fvy.js} +18 -4
  50. package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
  51. package/dist/{cli-4c7k75rq.js → cli-rg7ntypm.js} +378 -84
  52. package/dist/{cli-z3dk189k.js → cli-rsd7ga7v.js} +10 -8
  53. package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
  54. package/dist/{cli-8w65vwee.js → cli-tg9ksbn8.js} +1 -1
  55. package/dist/{cli-th8krqbf.js → cli-tgvpmnwz.js} +2 -2
  56. package/dist/{cli-hwc4a9xm.js → cli-tj4j2ts4.js} +6 -6
  57. package/dist/{cli-jhhb2v9n.js → cli-ttaz6c7m.js} +2 -2
  58. package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
  59. package/dist/{cli-4g629sgr.js → cli-wrv09v7q.js} +20 -1
  60. package/dist/{cli-cby329ny.js → cli-wyrr38xm.js} +2 -2
  61. package/dist/{cli-5ks6x36q.js → cli-x1p5dhq2.js} +3 -3
  62. package/dist/{cli-bddxamg7.js → cli-x719j3vs.js} +220 -629
  63. package/dist/{cli-ztcxv5nv.js → cli-xfe63zx3.js} +1 -1
  64. package/dist/{cli-q6gw45wj.js → cli-ywyrffff.js} +1 -1
  65. package/dist/{cli-p46xdr1m.js → cli-zvze9rbm.js} +1 -1
  66. package/dist/cli.js +15 -15
  67. package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-pp2gq8gr.js} +3 -3
  68. package/dist/{codex-hook-x6tm6y5m.js → codex-hook-qwzccd64.js} +1 -1
  69. package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-0vzn09t8.js} +3 -3
  70. package/dist/{commands-ktnqwrv4.js → commands-psqhkttb.js} +2 -2
  71. package/dist/{controller-nex2he8v.js → controller-t20vz2k8.js} +2 -2
  72. package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-gkxmqw9d.js} +24 -20
  73. package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-3v542qe5.js} +1 -1
  74. package/dist/{db-jvr86v7f.js → db-2xbyenjr.js} +2 -2
  75. package/dist/{db-19tw9r5s.js → db-nc1rd869.js} +3 -2
  76. package/dist/{dispatcher-d3mnsg2y.js → dispatcher-ewceb8ts.js} +17 -15
  77. package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-75xwh78t.js} +1 -1
  78. package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-xsc47jwf.js} +1 -1
  79. package/dist/{execute-bcqc69a5.js → execute-gzmw1mnw.js} +1 -1
  80. package/dist/{external-channel-44cwer32.js → external-channel-a5dsmxn4.js} +2 -2
  81. package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
  82. package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
  83. package/dist/{grok-adapter-ysp907f6.js → grok-adapter-vj88xr23.js} +7 -7
  84. package/dist/handlers-zbs90bny.js +39 -0
  85. package/dist/{heartbeat-2etacsqq.js → heartbeat-t2m9r3wa.js} +20 -18
  86. package/dist/{hook-mzxxs5n2.js → hook-8femx1bg.js} +4 -4
  87. package/dist/{hook-3kvmax6d.js → hook-xa677f8r.js} +5 -5
  88. package/dist/{http-fn978m0g.js → http-xpmmq028.js} +940 -269
  89. package/dist/{index-e4tsa1h7.js → index-8pwc1jc8.js} +17 -15
  90. package/dist/{index-w2wr89xd.js → index-cdzyfrsp.js} +1 -1
  91. package/dist/{index-1r8hw4vd.js → index-dg2tmj99.js} +17 -15
  92. package/dist/index-nxa6me0q.js +41 -0
  93. package/dist/{index-039x2sag.js → index-s7ckdajq.js} +42 -14
  94. package/dist/{index-wgsw9xcz.js → index-syztqdt6.js} +1 -1
  95. package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
  96. package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
  97. package/dist/{keepalive-d4nzs76x.js → keepalive-3nr4st3r.js} +3 -3
  98. package/dist/{lead-8h5n9k5f.js → lead-td8h2sfn.js} +33 -31
  99. package/dist/{maintenance-ea6jfw42.js → maintenance-verwpe2c.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
  101. package/dist/{onboard-pvk7hdb2.js → onboard-aytqedmq.js} +2 -2
  102. package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
  103. package/dist/{otel-impl-qb7nhgdp.js → otel-impl-hckw4wt1.js} +1 -1
  104. package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-hxby0ce1.js} +6 -6
  105. package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-5nvme3sm.js} +3 -3
  106. package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-pd8c26m1.js} +5 -3
  107. package/dist/{rbac-roles-9648nh00.js → rbac-roles-vbpy21mg.js} +2 -2
  108. package/dist/{render-v2-xkze1wef.js → render-v2-7nqgvk2q.js} +3 -3
  109. package/dist/{resume-tq0bbvt5.js → resume-b6djcj3c.js} +6 -6
  110. package/dist/{runtime-ar8310ba.js → runtime-txrxc9za.js} +1 -1
  111. package/dist/session-access-fc3f9y12.js +25 -0
  112. package/dist/{setup-rnrazxvd.js → setup-n39szm6b.js} +1 -1
  113. package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
  114. package/dist/{work-objects-e75q2rqs.js → work-objects-jem2fre0.js} +20 -18
  115. package/dist/{worker-4ww4hjef.js → worker-wr6xgfr2.js} +33 -31
  116. package/openapi.json +1011 -115
  117. package/package.json +2 -2
  118. package/src/apps/action-caller.ts +6 -0
  119. package/src/apps/script-types.ts +16 -3
  120. package/src/be/asset-key-audit.ts +4 -13
  121. package/src/be/db/active-sessions.ts +5 -0
  122. package/src/be/db/session-visibility.ts +29 -0
  123. package/src/be/db/tasks/read.ts +61 -3
  124. package/src/be/db/tasks/write.ts +15 -6
  125. package/src/be/db.ts +149 -66
  126. package/src/be/events.ts +11 -0
  127. package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
  128. package/src/be/migrations/230_session_shares.sql +23 -0
  129. package/src/be/rbac-roles.ts +2 -0
  130. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
  131. package/src/be/session-shares.ts +244 -0
  132. package/src/be/swarm-config-guard.ts +23 -2
  133. package/src/commands/runner.ts +11 -0
  134. package/src/heartbeat/heartbeat.ts +25 -7
  135. package/src/http/active-sessions.ts +13 -2
  136. package/src/http/agents.ts +10 -3
  137. package/src/http/all-routes.ts +1 -0
  138. package/src/http/api-keys.ts +3 -1
  139. package/src/http/approval-requests.ts +2 -1
  140. package/src/http/apps.ts +36 -11
  141. package/src/http/assets.ts +26 -4
  142. package/src/http/config.ts +3 -0
  143. package/src/http/context.ts +2 -0
  144. package/src/http/core.ts +39 -2
  145. package/src/http/events.ts +6 -2
  146. package/src/http/favorite-owner.ts +1 -1
  147. package/src/http/fs.ts +38 -3
  148. package/src/http/inbox-state.ts +58 -8
  149. package/src/http/index.ts +3 -1
  150. package/src/http/memory.ts +3 -1
  151. package/src/http/ranking.ts +367 -0
  152. package/src/http/realtime.ts +26 -21
  153. package/src/http/route-def.ts +77 -11
  154. package/src/http/script-runs.ts +2 -1
  155. package/src/http/scripts.ts +8 -0
  156. package/src/http/session-access.ts +179 -0
  157. package/src/http/session-data.ts +10 -2
  158. package/src/http/sessions.ts +212 -12
  159. package/src/http/stats.ts +7 -3
  160. package/src/http/tasks.ts +83 -7
  161. package/src/http/used-by.ts +11 -3
  162. package/src/kv-reserved-namespaces.ts +1 -0
  163. package/src/prompts/session-templates.ts +3 -1
  164. package/src/providers/amp-adapter.ts +1 -1
  165. package/src/ranking/telemetry-ranking.ts +147 -0
  166. package/src/ranking/usage.ts +57 -0
  167. package/src/rbac/admission.ts +22 -7
  168. package/src/rbac/legacy-policy.ts +2 -0
  169. package/src/rbac/permissions.ts +9 -0
  170. package/src/realtime/auth.ts +69 -0
  171. package/src/realtime/tickets.ts +4 -1
  172. package/src/realtime/transport.ts +77 -9
  173. package/src/slack/blocks.ts +18 -0
  174. package/src/slack/dm.ts +29 -0
  175. package/src/telemetry-contract/EVENTS.md +3 -0
  176. package/src/telemetry-contract/SOURCE.json +37 -4
  177. package/src/telemetry-contract/catalog.json +69 -0
  178. package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
  179. package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
  180. package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
  181. package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
  182. package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
  183. package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
  184. package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
  185. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
  186. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
  187. package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
  188. package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
  189. package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
  190. package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
  191. package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
  192. package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
  193. package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
  194. package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
  195. package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
  196. package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
  197. package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
  198. package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
  199. package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
  200. package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
  201. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
  202. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
  203. package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
  204. package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
  205. package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
  206. package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
  207. package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
  208. package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
  209. package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
  210. package/src/telemetry-contract/ranking/taxonomy.json +8 -0
  211. package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
  212. package/src/telemetry-contract/types.gen.ts +708 -1
  213. package/src/telemetry.ts +26 -2
  214. package/src/tests/apps-rbac.test.ts +189 -2
  215. package/src/tests/apps-script-types.test.ts +16 -3
  216. package/src/tests/asset-key-api.test.ts +128 -0
  217. package/src/tests/asset-key-invariants.test.ts +179 -3
  218. package/src/tests/asset-key-migration.test.ts +35 -0
  219. package/src/tests/check-pr-body.test.ts +23 -0
  220. package/src/tests/check-ranking-entry.test.ts +146 -0
  221. package/src/tests/db-extraction-contract.test.ts +1 -0
  222. package/src/tests/db-task-read-contract.test.ts +1 -0
  223. package/src/tests/docs-index-footprint.test.js +2 -2
  224. package/src/tests/heartbeat-checklist.test.ts +88 -0
  225. package/src/tests/heartbeat-jev-gate.test.ts +394 -10
  226. package/src/tests/inbox-state.test.ts +167 -0
  227. package/src/tests/kv-tool.test.ts +15 -0
  228. package/src/tests/org-identity.test.ts +44 -0
  229. package/src/tests/ranking-listing.test.ts +112 -0
  230. package/src/tests/ranking-route.test.ts +319 -0
  231. package/src/tests/ranking-usage.test.ts +50 -0
  232. package/src/tests/rbac-admission-e2e.test.ts +8 -0
  233. package/src/tests/rbac-admission.test.ts +38 -0
  234. package/src/tests/rbac-charact-http.test.ts +4 -3
  235. package/src/tests/rbac-engine.test.ts +2 -0
  236. package/src/tests/rbac-roles.test.ts +2 -0
  237. package/src/tests/realtime-room-surfaces.test.ts +3 -1
  238. package/src/tests/realtime-session-join-race.test.ts +142 -0
  239. package/src/tests/realtime-tickets.test.ts +24 -17
  240. package/src/tests/realtime-transport.test.ts +91 -0
  241. package/src/tests/script-connections.test.ts +2 -0
  242. package/src/tests/session-route-posture.test.ts +639 -0
  243. package/src/tests/session-sharing-routes.test.ts +282 -0
  244. package/src/tests/session-sharing.test.ts +526 -0
  245. package/src/tests/sessions-detail-batch.test.ts +7 -1
  246. package/src/tests/slack-tool-blocks.test.ts +101 -0
  247. package/src/tests/slack-update.test.ts +55 -0
  248. package/src/tests/slack-user-dm.test.ts +177 -0
  249. package/src/tests/used-by-route.test.ts +10 -0
  250. package/src/tools/slack-post.ts +18 -12
  251. package/src/tools/slack-read.ts +13 -1
  252. package/src/tools/slack-reply.ts +7 -10
  253. package/src/tools/slack-start-thread.ts +18 -11
  254. package/src/tools/slack-update.ts +2 -9
  255. package/src/types.ts +37 -0
  256. package/src/utils/org-identity.ts +33 -0
  257. package/templates/skills/apps/SKILL.md +17 -0
  258. package/templates/skills/apps/content.md +17 -0
  259. package/templates/skills/code-quality/content.md +2 -2
  260. package/templates/skills/pages/content.md +4 -0
  261. package/templates/skills/swarm-scripts/SKILL.md +6 -0
  262. package/templates/skills/swarm-scripts/content.md +6 -0
  263. package/dist/handlers-t5pbabwf.js +0 -37
  264. package/dist/index-qyjbb7br.js +0 -39
  265. package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
  266. /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
package/src/http/tasks.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  } from "../be/db";
38
38
  import { modelProfileRefError, taskProfileError } from "../be/db/model-profiles";
39
39
  import { explicitModelErrorForAgent } from "../be/model-validation";
40
+ import { hasSessionAccess, type SessionAccess } from "../be/session-shares";
40
41
  import {
41
42
  getTaskSteeringFields,
42
43
  markSteeringUndeliverable,
@@ -80,7 +81,15 @@ import { getRequestAuth } from "../utils/request-auth-context";
80
81
  import { scrubSecrets } from "../utils/secret-scrubber";
81
82
  import { rejectGuest } from "./request-principal";
82
83
  import { buildTaskRequestedBy, TaskRequestedBySchema } from "./requested-by";
83
- import { route } from "./route-def";
84
+ import { bodyTask, route } from "./route-def";
85
+ import {
86
+ canReadSession,
87
+ readableByFor,
88
+ resolveSessionActor,
89
+ SESSION_READ_DENIED,
90
+ SESSION_WRITE_DENIED,
91
+ sessionAccessFor,
92
+ } from "./session-access";
84
93
  import { jsonError, parseBody } from "./utils";
85
94
 
86
95
  // ─── Response Schemas ────────────────────────────────────────────────────────
@@ -207,7 +216,7 @@ const listTasks = route({
207
216
  pattern: ["api", "tasks"],
208
217
  summary: "List tasks with filters",
209
218
  description:
210
- "Returns tasks with the full `task` text replaced by a bounded `taskPreview` and completion/integration blobs dropped by default — list views only need the preview. Pass `fields=full` to restore the full `AgentTask`, or `fields=timeline` for the narrow shape the dashboard timeline draws. Fetch a single task in full via `GET /api/tasks/{id}`. `total` (the filtered row count, ignoring limit/offset) is computed only with `includeTotal=true`.",
219
+ "Returns tasks with the full `task` text replaced by a bounded `taskPreview` and completion/integration blobs dropped by default — list views only need the preview. Pass `fields=full` to restore the full `AgentTask`, or `fields=timeline` for the narrow shape the dashboard timeline draws. Every shape gives a user caller only tasks of sessions they can read. Fetch a single task in full via `GET /api/tasks/{id}`. `total` (the filtered row count, ignoring limit/offset) is computed only with `includeTotal=true`.",
211
220
  tags: ["Tasks"],
212
221
  query: z.object({
213
222
  /** Single status, or comma-separated list (e.g. "failed,cancelled"). */
@@ -263,6 +272,7 @@ const createTask = route({
263
272
  method: "post",
264
273
  path: "/api/tasks",
265
274
  pattern: ["api", "tasks"],
275
+ session: bodyTask("parentTaskId"),
266
276
  summary: "Create a new task",
267
277
  tags: ["Tasks"],
268
278
  body: z
@@ -340,6 +350,7 @@ const promoteDraftTaskRoute = route({
340
350
  method: "post",
341
351
  path: "/api/tasks/{id}/promote-draft",
342
352
  pattern: ["api", "tasks", null, "promote-draft"],
353
+ session: { taskParam: "id" },
343
354
  summary: "Promote a draft task out of the pre-dispatch draft state",
344
355
  description:
345
356
  "Transitions a `draft` task (#1240 — created with attachments still uploading) to its normal dispatch-eligible status: `offered` if it was offered to an agent, `pending` if it has an owning agent (the common case — UI-composer tasks default to Lead), otherwise `unassigned`. Called by the UI composer once its attachment upload batch settles, whether every file uploaded, some failed, or all failed — a draft must never be stranded by an upload error. Idempotent: calling it on a task that already left `draft` returns the current task unchanged rather than erroring, so a retried request is safe.",
@@ -357,6 +368,7 @@ const updateSession = route({
357
368
  method: "put",
358
369
  path: "/api/tasks/{id}/session",
359
370
  pattern: ["api", "tasks", null, "session"],
371
+ session: { taskParam: "id" },
360
372
  summary: "Update provider session ID and harness metadata for a task",
361
373
  tags: ["Tasks"],
362
374
  params: z.object({ id: z.string() }),
@@ -392,6 +404,7 @@ const cancelTaskRoute = route({
392
404
  method: "post",
393
405
  path: "/api/tasks/{id}/cancel",
394
406
  pattern: ["api", "tasks", null, "cancel"],
407
+ session: { taskParam: "id" },
395
408
  summary: "Cancel a pending or in-progress task",
396
409
  tags: ["Tasks"],
397
410
  params: z.object({ id: z.string() }),
@@ -407,6 +420,7 @@ const steerTaskRoute = route({
407
420
  method: "post",
408
421
  path: "/api/tasks/{id}/steer",
409
422
  pattern: ["api", "tasks", null, "steer"],
423
+ session: { taskParam: "id" },
410
424
  summary: "Deliver a steering message to a running task",
411
425
  tags: ["Tasks"],
412
426
  params: z.object({ id: z.string() }),
@@ -439,6 +453,7 @@ const getTaskSteeringMessagesRoute = route({
439
453
  method: "get",
440
454
  path: "/api/tasks/{id}/steering-messages",
441
455
  pattern: ["api", "tasks", null, "steering-messages"],
456
+ session: { taskParam: "id" },
442
457
  summary: "List steering messages for a task",
443
458
  tags: ["Tasks"],
444
459
  params: z.object({ id: z.string() }),
@@ -467,6 +482,7 @@ const markSteeringDeliveredRoute = route({
467
482
  method: "post",
468
483
  path: "/api/steering-messages/{id}/delivered",
469
484
  pattern: ["api", "steering-messages", null, "delivered"],
485
+ session: { steeringParam: "id" },
470
486
  summary: "Mark a steering message delivered",
471
487
  tags: ["Tasks"],
472
488
  params: z.object({ id: z.string() }),
@@ -492,6 +508,7 @@ const markSteeringHandledRoute = route({
492
508
  method: "post",
493
509
  path: "/api/steering-messages/{id}/handled",
494
510
  pattern: ["api", "steering-messages", null, "handled"],
511
+ session: { steeringParam: "id" },
495
512
  summary: "Mark a steering message handled",
496
513
  description:
497
514
  "Optionally accepts a JSON body `{ note?: string }` — a short acceptance note describing how the steering was incorporated, persisted as `handledNote`.",
@@ -517,6 +534,7 @@ const markSteeringUndeliverableRoute = route({
517
534
  method: "post",
518
535
  path: "/api/steering-messages/{id}/undeliverable",
519
536
  pattern: ["api", "steering-messages", null, "undeliverable"],
537
+ session: { steeringParam: "id" },
520
538
  summary: "Promote an undeliverable steering message",
521
539
  tags: ["Tasks"],
522
540
  params: z.object({ id: z.string() }),
@@ -542,6 +560,7 @@ const getTask = route({
542
560
  method: "get",
543
561
  path: "/api/tasks/{id}",
544
562
  pattern: ["api", "tasks", null],
563
+ session: { taskParam: "id" },
545
564
  summary: "Get task details with logs and attachments",
546
565
  description:
547
566
  "Returns the full `AgentTask` row decorated with `logs` (capped by `logsLimit`) and `attachments` (pointer-based artifacts stored on the task, ordered by `created_at`).",
@@ -553,6 +572,7 @@ const getTask = route({
553
572
  }),
554
573
  responses: {
555
574
  200: { description: "Task with logs and attachments", schema: GetTaskResponseSchema },
575
+ 403: { description: "A user caller cannot read the task's session" },
556
576
  404: { description: "Task not found" },
557
577
  },
558
578
  });
@@ -561,6 +581,7 @@ const updateTaskProgressRoute = route({
561
581
  method: "post",
562
582
  path: "/api/tasks/{id}/progress",
563
583
  pattern: ["api", "tasks", null, "progress"],
584
+ session: { taskParam: "id" },
564
585
  summary: "Update task progress text",
565
586
  tags: ["Tasks"],
566
587
  params: z.object({ id: z.string() }),
@@ -579,6 +600,7 @@ const finishTask = route({
579
600
  method: "post",
580
601
  path: "/api/tasks/{id}/finish",
581
602
  pattern: ["api", "tasks", null, "finish"],
603
+ session: { taskParam: "id" },
582
604
  summary: "Mark task as completed or failed (runner endpoint)",
583
605
  tags: ["Tasks"],
584
606
  params: z.object({ id: z.string() }),
@@ -631,6 +653,7 @@ const pauseTaskRoute = route({
631
653
  method: "post",
632
654
  path: "/api/tasks/{id}/pause",
633
655
  pattern: ["api", "tasks", null, "pause"],
656
+ session: { taskParam: "id" },
634
657
  summary: "Pause an in-progress task",
635
658
  tags: ["Tasks"],
636
659
  params: z.object({ id: z.string() }),
@@ -646,6 +669,7 @@ const resumeTaskRoute = route({
646
669
  method: "post",
647
670
  path: "/api/tasks/{id}/resume",
648
671
  pattern: ["api", "tasks", null, "resume"],
672
+ session: { taskParam: "id" },
649
673
  summary: "Resume a paused task",
650
674
  tags: ["Tasks"],
651
675
  params: z.object({ id: z.string() }),
@@ -661,6 +685,7 @@ const supersedeTaskRoute = route({
661
685
  method: "post",
662
686
  path: "/api/tasks/{id}/supersede",
663
687
  pattern: ["api", "tasks", null, "supersede"],
688
+ session: { taskParam: "id" },
664
689
  summary: "Supersede an in-progress task (terminate + spawn resume follow-up)",
665
690
  description:
666
691
  'Marks the original task `superseded` (terminal) and creates a fresh `taskType="resume"` follow-up so a worker can pick up the work in a new provider session. Workflow-step tasks (those with `workflowRunStepId`) are carved out: the original is marked `failed` with reason `superseded_workflow_task` and no follow-up is created — the workflow engine\'s retry/failure policy applies.',
@@ -683,6 +708,7 @@ const updateTaskVcsRoute = route({
683
708
  method: "patch",
684
709
  path: "/api/tasks/{id}/vcs",
685
710
  pattern: ["api", "tasks", null, "vcs"],
711
+ session: { taskParam: "id" },
686
712
  summary: "Update VCS (PR/MR) info for a task",
687
713
  tags: ["Tasks"],
688
714
  params: z.object({ id: z.string() }),
@@ -704,6 +730,7 @@ const updateTaskTitleRoute = route({
704
730
  method: "patch",
705
731
  path: "/api/tasks/{id}/title",
706
732
  pattern: ["api", "tasks", null, "title"],
733
+ session: { taskParam: "id" },
707
734
  summary: "Set or clear a task's display title (session rename)",
708
735
  description:
709
736
  "Sets a human-facing display title override on a task. The sessions UI only reads this from root tasks (session list items), but titles on child tasks are harmless. Pass `title: null` (or an empty string) to clear the override and fall back to the task prompt.",
@@ -722,22 +749,37 @@ const updateTaskTitleRoute = route({
722
749
  },
723
750
  });
724
751
 
752
+ /** True unless the request acts for a user who lacks write access to the task's session. */
753
+ async function hasSessionWrite(
754
+ req: IncomingMessage,
755
+ myAgentId: string | undefined,
756
+ taskId: string,
757
+ ): Promise<boolean> {
758
+ const access = await sessionAccessFor(await resolveSessionActor(req, myAgentId), taskId);
759
+ return hasSessionAccess(access, "write");
760
+ }
761
+
725
762
  async function canSteerTask(
726
763
  req: IncomingMessage,
727
764
  myAgentId: string | undefined,
728
765
  task: AgentTask,
766
+ sessionAccess: SessionAccess = "none",
729
767
  ): Promise<boolean> {
768
+ const auth = getRequestAuth(req);
730
769
  const resource: RbacResource = {
731
770
  kind: "task",
732
771
  taskId: task.id,
733
- requestedByUserId: task.requestedByUserId,
772
+ // A user with a write share steers as if the session were their own; the
773
+ // verb gate (and any role grant) still applies. Only an actual share
774
+ // counts: `owner` from an ownerless root must not widen steering.
775
+ requestedByUserId:
776
+ auth?.kind === "user" && sessionAccess === "write" ? auth.userId : task.requestedByUserId,
734
777
  creatorAgentId: task.creatorAgentId,
735
778
  agentId: task.agentId,
736
779
  };
737
780
  let principal: RbacPrincipal;
738
781
  let verb: "task.steer.any" | "task.steer.own";
739
782
 
740
- const auth = getRequestAuth(req);
741
783
  if (auth?.kind === "operator") {
742
784
  principal = { kind: "operator" };
743
785
  verb = "task.steer.own";
@@ -906,6 +948,9 @@ export async function handleTasks(
906
948
  if (validated.length > 0) source = validated;
907
949
  }
908
950
 
951
+ // Every shape carries task text, so a user sees only sessions they can
952
+ // read, and `total` counts the same rows.
953
+ const actor = await resolveSessionActor(req, myAgentId);
909
954
  const filters = {
910
955
  status,
911
956
  agentId: parsed.query.agentId || undefined,
@@ -925,6 +970,7 @@ export async function handleTasks(
925
970
  orderBy: parsed.query.orderBy,
926
971
  limit: parsed.query.limit,
927
972
  offset: parsed.query.offset,
973
+ readableBy: actor.kind === "user" ? { userId: actor.userId } : undefined,
928
974
  };
929
975
  // List responses default to slim (full `task` text → bounded `taskPreview`,
930
976
  // heavy blobs dropped); `?fields=full` restores the full `AgentTask`.
@@ -1013,6 +1059,11 @@ export async function handleTasks(
1013
1059
  jsonError(res, `You don't have context for parent task ${parentTask.id}.`, 403);
1014
1060
  return true;
1015
1061
  }
1062
+ // A follow-up in someone else's session needs a write share.
1063
+ if (parentTask && !(await hasSessionWrite(req, myAgentId, parentTask.id))) {
1064
+ jsonError(res, SESSION_WRITE_DENIED, 403);
1065
+ return true;
1066
+ }
1016
1067
  // A chat-sdk contextKey binds the task to a chat thread that its assignee and creator
1017
1068
  // may then read and post to. An agent may only name a thread other than the parent's
1018
1069
  // with the lead's chat post permission.
@@ -1285,7 +1336,13 @@ export async function handleTasks(
1285
1336
  jsonError(res, "Task not found", 404);
1286
1337
  return true;
1287
1338
  }
1288
- if (!(await canSteerTask(req, myAgentId, task))) {
1339
+ const sessionActor = await resolveSessionActor(req, myAgentId);
1340
+ const steerAccess = await sessionAccessFor(sessionActor, task.id);
1341
+ if (!hasSessionAccess(steerAccess, "write")) {
1342
+ jsonError(res, SESSION_WRITE_DENIED, 403);
1343
+ return true;
1344
+ }
1345
+ if (!(await canSteerTask(req, myAgentId, task, steerAccess))) {
1289
1346
  jsonError(res, "Forbidden: caller cannot steer this task", 403);
1290
1347
  return true;
1291
1348
  }
@@ -1338,6 +1395,10 @@ export async function handleTasks(
1338
1395
  jsonError(res, "Task not found", 404);
1339
1396
  return true;
1340
1397
  }
1398
+ if (!(await canReadSession(req, myAgentId, parsed.params.id))) {
1399
+ jsonError(res, SESSION_READ_DENIED, 403);
1400
+ return true;
1401
+ }
1341
1402
  getTaskSteeringMessagesRoute.respond(res, 200, {
1342
1403
  messages: await getSteeringMessagesForTask(parsed.params.id),
1343
1404
  });
@@ -1357,7 +1418,12 @@ export async function handleTasks(
1357
1418
  return true;
1358
1419
  }
1359
1420
 
1421
+ // A user token naming an agent in X-Agent-ID still reads only its sessions.
1360
1422
  if (parsed.query.taskId) {
1423
+ if (!(await canReadSession(req, myAgentId, parsed.query.taskId))) {
1424
+ jsonError(res, SESSION_READ_DENIED, 403);
1425
+ return true;
1426
+ }
1361
1427
  const task = await getTaskById(parsed.query.taskId);
1362
1428
  const messages =
1363
1429
  task?.agentId === myAgentId ? await getPendingSteeringForTask(parsed.query.taskId) : [];
@@ -1365,8 +1431,9 @@ export async function handleTasks(
1365
1431
  return true;
1366
1432
  }
1367
1433
 
1434
+ const actor = await resolveSessionActor(req, myAgentId);
1368
1435
  getPendingSteeringMessagesRoute.respond(res, 200, {
1369
- messages: await getPendingSteeringForAgent(myAgentId),
1436
+ messages: await getPendingSteeringForAgent(myAgentId, readableByFor(actor)),
1370
1437
  });
1371
1438
  return true;
1372
1439
  }
@@ -1529,6 +1596,11 @@ export async function handleTasks(
1529
1596
  jsonError(res, "Task not found", 404);
1530
1597
  return true;
1531
1598
  }
1599
+ const readAccess = await sessionAccessFor(await resolveSessionActor(req, myAgentId), task.id);
1600
+ if (!hasSessionAccess(readAccess, "read")) {
1601
+ jsonError(res, "You don't have access to this session. Ask the owner to share it.", 403);
1602
+ return true;
1603
+ }
1532
1604
 
1533
1605
  const logs = await getLogsByTaskId(parsed.params.id, parsed.query.logsLimit ?? 200);
1534
1606
  const attachments = await getTaskAttachments(parsed.params.id);
@@ -1758,7 +1830,11 @@ export async function handleTasks(
1758
1830
  jsonError(res, "Missing X-Agent-ID header", 400);
1759
1831
  return true;
1760
1832
  }
1761
- const pausedTasks = await getPausedTasksForAgent(myAgentId);
1833
+ // A user token naming an agent in X-Agent-ID still reads only its sessions.
1834
+ const pausedTasks = await getPausedTasksForAgent(
1835
+ myAgentId,
1836
+ readableByFor(await resolveSessionActor(req, myAgentId)),
1837
+ );
1762
1838
  const tasks = await Promise.all(
1763
1839
  pausedTasks.map(async (task) => {
1764
1840
  const requestedBy = await buildTaskRequestedBy(task);
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { getSwarmConfigs, upsertSwarmConfig } from "../be/db";
4
4
  import { isTelemetryEnabled, trackRelayed } from "../telemetry";
5
+ import { orgIdentityStatus } from "../utils/org-identity";
5
6
  import { normalizeOrgWebsite, TELEMETRY_PROXY_ORIGIN } from "../utils/org-website";
6
7
  import { ensureConfigAdmin } from "./config";
7
8
  import { scheduleIntegrationsReload } from "./core";
@@ -46,7 +47,8 @@ const usedBy = route({
46
47
 
47
48
  // ─── Helpers ─────────────────────────────────────────────────────────────────
48
49
 
49
- async function globalConfig(key: string): Promise<string | undefined> {
50
+ /** A global config value, falling back to the process env. */
51
+ export async function globalConfig(key: string): Promise<string | undefined> {
50
52
  const stored = (await getSwarmConfigs({ scope: "global", key }))?.[0]?.value?.trim();
51
53
  return stored || process.env[key]?.trim() || undefined;
52
54
  }
@@ -79,8 +81,14 @@ export async function handleUsedBy(
79
81
  if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
80
82
 
81
83
  const { granted } = parsed.body;
82
- const currentName = await globalConfig("SWARM_ORG_NAME");
83
- const currentWebsite = normalizeOrgWebsite(await globalConfig("SWARM_ORG_WEBSITE"));
84
+ // The shared identity reader. The grant keeps its own rule (any non-empty
85
+ // name), so it does not apply the ranking's placeholder-name check.
86
+ const current = orgIdentityStatus({
87
+ SWARM_ORG_NAME: await globalConfig("SWARM_ORG_NAME"),
88
+ SWARM_ORG_WEBSITE: await globalConfig("SWARM_ORG_WEBSITE"),
89
+ });
90
+ const currentName = current.name ?? undefined;
91
+ const currentWebsite = current.website;
84
92
  const grantedName = granted ? undefined : await globalConfig(GRANTED_NAME_KEY);
85
93
  const grantedWebsite = granted
86
94
  ? null
@@ -8,6 +8,7 @@ const RESERVED_FAMILIES = new Map([
8
8
  ["apps", "namespace is reserved for swarm apps; use the app row endpoints"],
9
9
  // step-9: Comb's "Send to swarm" claims (`comb:sent`).
10
10
  ["comb", "namespace is reserved for Comb; use POST /api/comb/review-batches"],
11
+ ["heartbeat-checklist", "namespace is reserved for heartbeat checklist receipts"],
11
12
  ["worker", "namespace is reserved for worker nudges"],
12
13
  // Durable dedupe for SLACK_BLOCK_EXTERNAL_CHANNELS alerts (src/slack/external-channel.ts).
13
14
  ["slack-external-alert", "namespace is reserved for Slack external channel alerts"],
@@ -555,12 +555,14 @@ registerTemplate({
555
555
  header: "",
556
556
  defaultBody: `{{prompt}}
557
557
 
558
- [App action] app={{app_id}} action={{action_name}} input={{input_json}}`,
558
+ [App action] app={{app_id}} action={{action_name}} input={{input_json}}
559
+ Caller (server-verified): {{caller_json}}`,
559
560
  variables: [
560
561
  { name: "prompt", description: "The task prompt configured on the app action" },
561
562
  { name: "app_id", description: "The app identifier" },
562
563
  { name: "action_name", description: "The invoked action name" },
563
564
  { name: "input_json", description: "The action input serialized as JSON" },
565
+ { name: "caller_json", description: "The server-verified action caller serialized as JSON" },
564
566
  ],
565
567
  category: "task_lifecycle",
566
568
  });
@@ -44,7 +44,7 @@ import type {
44
44
  * default; the image installs exactly this version and the per-task settings
45
45
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
46
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1792238457-g6ec988";
47
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1792324855-g9c86a5";
48
48
 
49
49
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
50
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The `telemetry_ranking` listing: what an install opted into the public ranking
3
+ * shows on agent-swarm.dev/ranking. Stored stringified in the global
4
+ * `swarm_config` key `telemetry_ranking` and sent as the `listing` property of
5
+ * `ranking.opted_in`.
6
+ *
7
+ * The proxy owns the contract (`src/telemetry-contract/ranking/`, vendored by
8
+ * `bun run sync:telemetry-contract`). This schema and the proxy's Go decoder are
9
+ * both held to its fixtures. Pure: imported by the API and the dashboard.
10
+ */
11
+ import { z } from "zod";
12
+ import { RANKING_TAXONOMY } from "../telemetry-contract/ranking-taxonomy.gen";
13
+ import type { OrgIdentityStatus } from "../utils/org-identity";
14
+ import { normalizeOrgWebsite } from "../utils/org-website";
15
+
16
+ export { RANKING_TAXONOMY };
17
+
18
+ export const TELEMETRY_RANKING_KEY = "telemetry_ranking";
19
+ export const PUBLIC_RANKING_ENABLED_KEY = "PUBLIC_RANKING_ENABLED";
20
+ export const TELEMETRY_RANKING_MAX_BYTES = 2048;
21
+
22
+ export const RANKING_CATEGORIES = Object.keys(RANKING_TAXONOMY) as [
23
+ keyof typeof RANKING_TAXONOMY,
24
+ ...(keyof typeof RANKING_TAXONOMY)[],
25
+ ];
26
+ export const RANKING_CLAIM_PATTERN = /^rk_[0-9a-f]{32}$/;
27
+
28
+ const hostname = z.string().refine((v) => normalizeOrgWebsite(v) === v, "bare hostname");
29
+ const nullableText = (max: number) => z.string().trim().min(1).max(max).nullable();
30
+
31
+ function inCategory(category: string, subcategory: string | null): boolean {
32
+ if (subcategory === null) return true;
33
+ const subs: readonly string[] = RANKING_TAXONOMY[category as keyof typeof RANKING_TAXONOMY];
34
+ return subs?.includes(subcategory) ?? false;
35
+ }
36
+
37
+ export const telemetryRankingV1 = z
38
+ .strictObject({
39
+ v: z.literal(1),
40
+ claim: z.string().regex(RANKING_CLAIM_PATTERN),
41
+ name: z.string().trim().min(1).max(60),
42
+ domain: hostname,
43
+ category: z.enum(RANKING_CATEGORIES),
44
+ subcategory: z
45
+ .string()
46
+ .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/)
47
+ .max(40)
48
+ .nullable(),
49
+ description: nullableText(160),
50
+ sourceCodeUrl: z
51
+ .url({ protocol: /^https$/ })
52
+ .max(200)
53
+ .nullable(),
54
+ updatedAt: z.iso.datetime(),
55
+ })
56
+ .refine((l) => inCategory(l.category, l.subcategory), {
57
+ path: ["subcategory"],
58
+ message: "not in this category",
59
+ });
60
+
61
+ /** Discriminated on `v`; a v2 is added here, never by editing v1. */
62
+ export const telemetryRankingSchema = z.discriminatedUnion("v", [telemetryRankingV1]);
63
+ export type TelemetryRanking = z.infer<typeof telemetryRankingSchema>;
64
+
65
+ export function parseTelemetryRanking(raw: string): TelemetryRanking {
66
+ if (new TextEncoder().encode(raw).length > TELEMETRY_RANKING_MAX_BYTES) {
67
+ throw new Error(`telemetry_ranking exceeds ${TELEMETRY_RANKING_MAX_BYTES} bytes`);
68
+ }
69
+ return telemetryRankingSchema.parse(JSON.parse(raw));
70
+ }
71
+
72
+ /** The stored listing, or null when it is missing or no longer valid. */
73
+ export function readTelemetryRanking(raw: string | null | undefined): TelemetryRanking | null {
74
+ if (!raw?.trim()) return null;
75
+ try {
76
+ return parseTelemetryRanking(raw);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** What the admin picks in the ranking form. Name and domain are never typed here. */
83
+ export const rankingFormSchema = z.object({
84
+ category: z.enum(RANKING_CATEGORIES),
85
+ subcategory: z.string().nullable().optional(),
86
+ description: z.string().nullable().optional(),
87
+ sourceCodeUrl: z.string().nullable().optional(),
88
+ });
89
+ export type RankingForm = z.infer<typeof rankingFormSchema>;
90
+
91
+ /** `rk_` + 32 lowercase hex characters from the platform CSPRNG. */
92
+ export function mintRankingClaim(): string {
93
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
94
+ return `rk_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
95
+ }
96
+
97
+ const blankToNull = (value: string | null | undefined) => value?.trim() || null;
98
+
99
+ /**
100
+ * Build and validate the listing. Name and domain are copied from the org
101
+ * identity (`SWARM_ORG_NAME`, `SWARM_ORG_WEBSITE`); throws when the identity is
102
+ * not set or a form field breaks the contract.
103
+ */
104
+ export function buildListing(
105
+ identity: OrgIdentityStatus,
106
+ form: RankingForm,
107
+ claim: string,
108
+ now: Date = new Date(),
109
+ ): TelemetryRanking {
110
+ if (!identity.ok || !identity.name || !identity.website) {
111
+ throw new Error(`Org identity is missing: ${identity.missing.join(", ")}`);
112
+ }
113
+ const listing = {
114
+ v: 1 as const,
115
+ claim,
116
+ name: identity.name,
117
+ domain: identity.website,
118
+ category: form.category,
119
+ subcategory: blankToNull(form.subcategory),
120
+ description: blankToNull(form.description),
121
+ sourceCodeUrl: blankToNull(form.sourceCodeUrl),
122
+ updatedAt: now.toISOString(),
123
+ };
124
+ return parseTelemetryRanking(JSON.stringify(listing));
125
+ }
126
+
127
+ /** The `community/ranking/<domain>.json` file that approves this install. */
128
+ export function rankingEntryFile(listing: Pick<TelemetryRanking, "domain" | "claim">): string {
129
+ return `${JSON.stringify({ $schema: "./schema.json", domain: listing.domain, claim: listing.claim }, null, 2)}\n`;
130
+ }
131
+
132
+ export const RANKING_REPO = "desplega-ai/agent-swarm";
133
+
134
+ /** GitHub's "new file" page, prefilled with the entry, as a PR from the admin's fork. */
135
+ export function rankingPrUrl(listing: Pick<TelemetryRanking, "domain" | "claim">): string {
136
+ const params = new URLSearchParams({
137
+ filename: `community/ranking/${listing.domain}.json`,
138
+ value: rankingEntryFile(listing),
139
+ message: `chore(ranking): add ${listing.domain}`,
140
+ });
141
+ return `https://github.com/${RANKING_REPO}/new/main?${params.toString()}`;
142
+ }
143
+
144
+ /** GitHub's delete page for an entry, to leave the ranking for good. */
145
+ export function rankingLeaveUrl(domain: string): string {
146
+ return `https://github.com/${RANKING_REPO}/delete/main/community/ranking/${domain}.json`;
147
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Worker side of the public ranking: the `ranking.usage` properties for one
3
+ * session. Pure, so the runner and its test share the gate.
4
+ */
5
+ import { isEnvFlagEnabled } from "../utils/env-flag";
6
+ import { orgIdentityStatus } from "../utils/org-identity";
7
+
8
+ export interface SessionTokens {
9
+ inputTokens?: number | null;
10
+ outputTokens?: number | null;
11
+ cacheReadTokens?: number | null;
12
+ cacheWriteTokens?: number | null;
13
+ }
14
+
15
+ export interface RankingUsageProps {
16
+ /** The same model and provider `session.cost` reports for the session. */
17
+ model: string;
18
+ provider: string;
19
+ totalTokens: number;
20
+ inputTokens: number;
21
+ outputTokens: number;
22
+ cacheReadTokens?: number;
23
+ cacheWriteTokens?: number;
24
+ }
25
+
26
+ const tokens = (value: number | null | undefined) =>
27
+ typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
28
+
29
+ /**
30
+ * Null unless telemetry is on, `PUBLIC_RANKING_ENABLED` is set and both org
31
+ * identity envs are set. Carries token counts plus the model and provider
32
+ * (for the per-model chart), never the task, agent or cost.
33
+ */
34
+ export function rankingUsageProps(
35
+ cost: SessionTokens,
36
+ session: { model: string; provider: string },
37
+ env: NodeJS.ProcessEnv = process.env,
38
+ ): RankingUsageProps | null {
39
+ if (!isEnvFlagEnabled("ANONYMIZED_TELEMETRY", true, env)) return null;
40
+ if (!isEnvFlagEnabled("PUBLIC_RANKING_ENABLED", false, env)) return null;
41
+ const identity = { SWARM_ORG_NAME: env.SWARM_ORG_NAME, SWARM_ORG_WEBSITE: env.SWARM_ORG_WEBSITE };
42
+ if (!orgIdentityStatus(identity).ok) return null;
43
+ const input = tokens(cost.inputTokens);
44
+ const output = tokens(cost.outputTokens);
45
+ const cacheRead = tokens(cost.cacheReadTokens);
46
+ const cacheWrite = tokens(cost.cacheWriteTokens);
47
+ return {
48
+ model: session.model,
49
+ provider: session.provider,
50
+ // The unified sum, as computeContextUsedUnified counts it.
51
+ totalTokens: input + cacheRead + cacheWrite + output,
52
+ inputTokens: input,
53
+ outputTokens: output,
54
+ ...(cost.cacheReadTokens != null ? { cacheReadTokens: cacheRead } : {}),
55
+ ...(cost.cacheWriteTokens != null ? { cacheWriteTokens: cacheWrite } : {}),
56
+ };
57
+ }
@@ -8,7 +8,20 @@ export type AdmissionGrant = {
8
8
  };
9
9
  };
10
10
 
11
- export type AdmissionRbac = { permission: PermissionVerb } | { ungated: string };
11
+ /**
12
+ * `alsoAdmits` names verbs that admit the route as well as `permission`, for a
13
+ * route that serves two features (each handler check still names its own verb).
14
+ */
15
+ export type AdmissionRbac =
16
+ | { permission: PermissionVerb; alsoAdmits?: readonly PermissionVerb[] }
17
+ | { ungated: string };
18
+
19
+ function grantedVerb(
20
+ rbac: { permission: PermissionVerb; alsoAdmits?: readonly PermissionVerb[] },
21
+ grant: AdmissionGrant,
22
+ ): PermissionVerb | undefined {
23
+ return [rbac.permission, ...(rbac.alsoAdmits ?? [])].find((verb) => grant.verbs.has(verb));
24
+ }
12
25
 
13
26
  export type AdmissionDecision =
14
27
  | { allow: true; verb?: PermissionVerb }
@@ -29,10 +42,11 @@ export function decideAdmission(input: {
29
42
  }
30
43
 
31
44
  if (input.rbac && "permission" in input.rbac) {
32
- const verb = input.rbac.permission;
33
- if (input.grant.verbs.has(verb)) {
34
- return { allow: true, verb };
45
+ const granted = grantedVerb(input.rbac, input.grant);
46
+ if (granted) {
47
+ return { allow: true, verb: granted };
35
48
  }
49
+ const verb = input.rbac.permission;
36
50
  return { allow: false, reason: `admission: missing permission '${verb}'`, verb };
37
51
  }
38
52
 
@@ -54,10 +68,11 @@ export function decideToolAdmission(input: {
54
68
  }
55
69
 
56
70
  if (input.rbac && "permission" in input.rbac) {
57
- const verb = input.rbac.permission;
58
- if (input.grant.verbs.has(verb)) {
59
- return { allow: true, verb };
71
+ const granted = grantedVerb(input.rbac, input.grant);
72
+ if (granted) {
73
+ return { allow: true, verb: granted };
60
74
  }
75
+ const verb = input.rbac.permission;
61
76
  return { allow: false, reason: `admission: missing permission '${verb}'`, verb };
62
77
  }
63
78
 
@@ -285,6 +285,8 @@ export const LEGACY_POLICY = {
285
285
  "task.action.own": requesterOwnsTask,
286
286
  "task.fs.mutate": taskFsMutate,
287
287
  "task.parent.attach": taskFsMutate,
288
+ "session.share": operatorOrUser,
289
+ "session.presence": operatorOrUser,
288
290
  "task.progress.write": taskProgressWrite,
289
291
  "favorite.write.own": anyAuthenticated,
290
292
  "memory.learning.inject": leadOnly,
@@ -89,6 +89,15 @@ export const PERMISSIONS = {
89
89
  "Write a task's progress, status, or output (its assignee, a lead, a human, or anyone while unassigned).",
90
90
  namespace: "task",
91
91
  },
92
+ "session.share": {
93
+ description:
94
+ "Change who can view or edit a session the principal owns (the owner check runs in the handler).",
95
+ namespace: "task",
96
+ },
97
+ "session.presence": {
98
+ description: "Join the presence room of a session the principal can view.",
99
+ namespace: "task",
100
+ },
92
101
  "favorite.write.own": {
93
102
  description: "Set a favorite the caller owns.",
94
103
  namespace: "favorite",