@desplega.ai/agent-swarm 2.7.0 → 2.7.1

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 (236) hide show
  1. package/dist/{acp-adapter-ma1js5n7.js → acp-adapter-8dn5tq56.js} +6 -6
  2. package/dist/{actions-1vhmtvg9.js → actions-3r6gw7dy.js} +18 -18
  3. package/dist/{amp-adapter-sxqqvmdk.js → amp-adapter-wjtq413p.js} +3 -3
  4. package/dist/{anthropic-messages-txs190dd.js → anthropic-messages-n4cby1rz.js} +4 -4
  5. package/dist/{app-mwx1d5a9.js → app-d53yhykk.js} +3 -3
  6. package/dist/{assistant-yx1p6g82.js → assistant-ksrawj3k.js} +20 -20
  7. package/dist/{boot-reembed-7h78vmvv.js → boot-reembed-fxfw17m2.js} +10 -3
  8. package/dist/{boot-reembed-5h37a2c2.js → boot-reembed-k5y5xbj0.js} +15 -8
  9. package/dist/{boot-scrub-sweep-8mawd4gw.js → boot-scrub-sweep-se41j9fd.js} +3 -3
  10. package/dist/{boot-seeds-ca84dhnn.js → boot-seeds-r3kwwv8b.js} +4 -4
  11. package/dist/{claude-adapter-wqt4hfkq.js → claude-adapter-ahmyf231.js} +7 -7
  12. package/dist/{claude-managed-adapter-6fjqtr2z.js → claude-managed-adapter-5knamegc.js} +3 -3
  13. package/dist/{claude-managed-setup-rqv98sea.js → claude-managed-setup-mdc7dy6b.js} +2 -2
  14. package/dist/{claude-sdk-session-f29f3b2x.js → claude-sdk-session-n5wen9zk.js} +8728 -8599
  15. package/dist/{cli-m5e802r7.js → cli-0dqtv2jg.js} +2 -2
  16. package/dist/{cli-e1595bgx.js → cli-0tww7dnz.js} +2 -2
  17. package/dist/{cli-2y7v133v.js → cli-0w4e4v58.js} +7 -7
  18. package/dist/{cli-d20qvpr2.js → cli-2sjm3t4r.js} +1 -1
  19. package/dist/{cli-gf2850q7.js → cli-3cywb44q.js} +18 -1
  20. package/dist/{cli-8wkb09q5.js → cli-3k9jej0c.js} +2 -2
  21. package/dist/{cli-twctnkng.js → cli-3v2cyqb8.js} +3 -3
  22. package/dist/{cli-he9gbdx0.js → cli-5qt9vzmq.js} +39 -20
  23. package/dist/{cli-a6ds2epe.js → cli-5rp1w4cr.js} +1 -1
  24. package/dist/{cli-6srv653c.js → cli-6vwjsetx.js} +4 -4
  25. package/dist/{cli-1hhbzkbb.js → cli-6w2yrrc0.js} +8 -8
  26. package/dist/{cli-f12mr20z.js → cli-75tdydgv.js} +1 -1
  27. package/dist/{cli-j3y0x2n4.js → cli-8feswf2v.js} +43 -33
  28. package/dist/{cli-07q03xm2.js → cli-8wd3mbyj.js} +1 -1
  29. package/dist/{cli-ymjpkxgn.js → cli-9csfthbw.js} +8 -6
  30. package/dist/{cli-f1e0ptjj.js → cli-b41p764a.js} +21 -2
  31. package/dist/{cli-kv7sbc2c.js → cli-c1t533hh.js} +3 -3
  32. package/dist/{cli-54sj64fc.js → cli-c28jxpcv.js} +1 -1
  33. package/dist/{cli-8mbc9za9.js → cli-cb50g9cd.js} +1 -1
  34. package/dist/{cli-c668vvcf.js → cli-d8gaphtr.js} +1 -1
  35. package/dist/{cli-86ehn4s5.js → cli-dgtfysne.js} +2 -2
  36. package/dist/{cli-sjctjr48.js → cli-dscnhn18.js} +1 -1
  37. package/dist/{cli-j0ppywdr.js → cli-dv11pgpv.js} +12 -12
  38. package/dist/{cli-1m9x8dr5.js → cli-ekptgk2m.js} +5 -5
  39. package/dist/{cli-9gsm2rzp.js → cli-f3mzy662.js} +17 -17
  40. package/dist/{cli-s0mgx214.js → cli-fk7k8s9x.js} +2 -2
  41. package/dist/{cli-vbhgkw9h.js → cli-h3e7bhvn.js} +3 -3
  42. package/dist/{cli-yd2nreqw.js → cli-hkxqdmnb.js} +2 -2
  43. package/dist/{cli-8wmj18cz.js → cli-hpkvgdad.js} +7 -4
  44. package/dist/{cli-wqr7cea4.js → cli-hq2v4rr5.js} +37 -9
  45. package/dist/{cli-sgqm2582.js → cli-k1zb7raw.js} +5 -5
  46. package/dist/{cli-4rvr7zfx.js → cli-k4awgvzm.js} +5 -0
  47. package/dist/{cli-ay40dnj3.js → cli-k7d3hvya.js} +2 -2
  48. package/dist/{cli-j9bpmykh.js → cli-kbtdftbw.js} +5 -5
  49. package/dist/{cli-x37r7qwf.js → cli-kj69b9tw.js} +25 -5
  50. package/dist/{cli-gsfk58nw.js → cli-kk9jy4nm.js} +1 -1
  51. package/dist/{cli-39aet655.js → cli-m52t4cta.js} +1 -1
  52. package/dist/{cli-h5jeg46p.js → cli-mdadewqp.js} +5 -5
  53. package/dist/{cli-a89zdqz9.js → cli-nf6k60sn.js} +1 -1
  54. package/dist/{cli-v6shg7na.js → cli-nzdf09js.js} +1 -1
  55. package/dist/{cli-rr7zx19j.js → cli-prcyzecg.js} +12 -12
  56. package/dist/{cli-6a9qfa8b.js → cli-pzafyqtk.js} +118 -48
  57. package/dist/{cli-48gxjmcz.js → cli-qvaa1gjp.js} +87 -24
  58. package/dist/{cli-qj442fws.js → cli-rg4b2g6j.js} +1 -1
  59. package/dist/{cli-aypd09nf.js → cli-rm66vqdz.js} +1 -1
  60. package/dist/{cli-9pshx6rd.js → cli-s504c3w7.js} +1 -1
  61. package/dist/{cli-ptfq202x.js → cli-swkx9jm2.js} +3 -3
  62. package/dist/{cli-m30k9777.js → cli-thsaq4pd.js} +16 -16
  63. package/dist/{cli-60sty27p.js → cli-twjnyvs8.js} +2 -2
  64. package/dist/{cli-rd5m70gj.js → cli-typdpe8g.js} +4 -4
  65. package/dist/{cli-yxbsmx56.js → cli-vg8pwmsd.js} +6 -6
  66. package/dist/{cli-s09j9vp5.js → cli-vhpckzvp.js} +1 -1
  67. package/dist/{cli-dv97kdzv.js → cli-wdxgsdz5.js} +2 -2
  68. package/dist/{cli-7a6p4e65.js → cli-wtjg6je3.js} +1 -1
  69. package/dist/{cli-b3yzyc57.js → cli-x3cngb1h.js} +44 -12
  70. package/dist/{cli-6mgdba42.js → cli-xbz6mcg4.js} +2 -2
  71. package/dist/{cli-2pvzmas1.js → cli-y4tdxsqb.js} +3 -3
  72. package/dist/{cli-f4dbh7a5.js → cli-yqrsp3ny.js} +7 -7
  73. package/dist/cli.js +27 -19
  74. package/dist/{codex-adapter-zk59x2zh.js → codex-adapter-7wepzq15.js} +5 -5
  75. package/dist/{codex-hook-xyjhvxwd.js → codex-hook-pbdh4xhx.js} +2 -2
  76. package/dist/{codex-session-runner-kh6s4j3n.js → codex-session-runner-02sb49e4.js} +5 -5
  77. package/dist/{commands-68et1qv2.js → commands-3rcspxct.js} +2 -2
  78. package/dist/{controller-fyrbnzg4.js → controller-8ar4avd6.js} +3 -3
  79. package/dist/{controllers-connect-nep9h2xv.js → controllers-connect-ajvh9h0v.js} +19 -19
  80. package/dist/{cursor-adapter-yss3pg7t.js → cursor-adapter-gd0sa7bh.js} +1 -1
  81. package/dist/{db-28gjyvxj.js → db-dv9j7ts5.js} +2 -2
  82. package/dist/{db-8ftwvzba.js → db-z26vgdvj.js} +3 -3
  83. package/dist/{dispatcher-hayexmkr.js → dispatcher-8m0htjq8.js} +17 -17
  84. package/dist/{dsh-adapter-46wawff9.js → dsh-adapter-y8gyk130.js} +1 -1
  85. package/dist/{e2b-fn3zacvt.js → e2b-74vg9b7e.js} +1 -1
  86. package/dist/{e2b-222hsajc.js → e2b-jyay8wzn.js} +1 -1
  87. package/dist/{easter-egg-3d-8d1e0h4m.js → easter-egg-3d-28f880xx.js} +1 -1
  88. package/dist/{execute-cjrfxfnm.js → execute-9seb79vh.js} +1 -1
  89. package/dist/{external-channel-xbmxcg09.js → external-channel-smz7jyth.js} +2 -2
  90. package/dist/{getMachineId-bsd-brn0xym3.js → getMachineId-bsd-whfre6gr.js} +3 -3
  91. package/dist/{getMachineId-darwin-t339sfxm.js → getMachineId-darwin-5zjynb19.js} +3 -3
  92. package/dist/{getMachineId-linux-5eq95d8b.js → getMachineId-linux-8mksrrsq.js} +3 -3
  93. package/dist/{getMachineId-unsupported-qavrtc7j.js → getMachineId-unsupported-fxtqsdt2.js} +3 -3
  94. package/dist/{getMachineId-win-q7fe1wx7.js → getMachineId-win-nzfmz857.js} +3 -3
  95. package/dist/{grok-adapter-ag4wks2a.js → grok-adapter-z0nnvtbn.js} +7 -7
  96. package/dist/handlers-pspryh6g.js +41 -0
  97. package/dist/{heartbeat-psfvcgwp.js → heartbeat-f928aqyp.js} +20 -20
  98. package/dist/{hook-cyq4mryn.js → hook-d7c605r4.js} +7 -7
  99. package/dist/{hook-8pz04697.js → hook-q9hv5xf9.js} +5 -5
  100. package/dist/{http-haj1nd6k.js → http-q6j4k989.js} +934 -180
  101. package/dist/{index-z5h22pes.js → index-5nb266jr.js} +17 -17
  102. package/dist/{index-ysd45kgc.js → index-8fxt5eah.js} +10 -10
  103. package/dist/{index-zgvqzjsw.js → index-cekzwhez.js} +5 -5
  104. package/dist/{index-zyq8ct52.js → index-ch0c1xff.js} +17 -17
  105. package/dist/{index-syvhnvtw.js → index-ebnjhxh1.js} +1 -1
  106. package/dist/{index-y1ajx04a.js → index-ed8dy8rn.js} +20 -20
  107. package/dist/{index-jwt2bj6f.js → index-ejqdkkqx.js} +11 -10
  108. package/dist/{index-dr94zbnh.js → index-farm1zsn.js} +32661 -29443
  109. package/dist/{index-q52f47pe.js → index-yn1yxg85.js} +1 -1
  110. package/dist/{keepalive-xdvsamvj.js → keepalive-zzc3hks1.js} +3 -3
  111. package/dist/{lead-hqg57kdp.js → lead-c0pjcaw4.js} +36 -36
  112. package/dist/{maintenance-4y2xzh3a.js → maintenance-kqsxzpzx.js} +1 -1
  113. package/dist/{oauth-refresh-sweep-jd4r7zbs.js → oauth-refresh-sweep-7qt0g55z.js} +2 -2
  114. package/dist/{onboard-18447q9q.js → onboard-k7x13fqg.js} +118 -5
  115. package/dist/{opencode-adapter-26b2frdf.js → opencode-adapter-tqvrtter.js} +5 -5
  116. package/dist/{otel-impl-f9tgr8yw.js → otel-impl-sqkf6k3b.js} +97 -97
  117. package/dist/{pi-mono-adapter-qn6qbga1.js → pi-mono-adapter-m2qnafxm.js} +63 -9
  118. package/dist/{pricing-refresh-dw2tgb53.js → pricing-refresh-wkpwhgvs.js} +3 -3
  119. package/dist/{rbac-roles-s4nwj2m3.js → rbac-roles-dpn0c0wj.js} +7 -4
  120. package/dist/{rbac-roles-aygm2760.js → rbac-roles-wk7xd6a6.js} +2 -2
  121. package/dist/{render-v2-yfvdfr0x.js → render-v2-ex2m4g2k.js} +3 -3
  122. package/dist/{resume-1bmvjbf3.js → resume-725g9b0b.js} +7 -7
  123. package/dist/{runtime-f7tpzedy.js → runtime-kajas15g.js} +1 -1
  124. package/dist/{session-access-r5garej6.js → session-access-0j2ezjje.js} +8 -4
  125. package/dist/{session-source-backfill-424wzv0r.js → session-source-backfill-62202awe.js} +2 -2
  126. package/dist/{setup-8tygvxkq.js → setup-9sj11ax6.js} +1 -1
  127. package/dist/{virtual-modules-xsw323b3.js → virtual-modules-c6fcq6zr.js} +2 -2
  128. package/dist/{work-objects-ggnghpgf.js → work-objects-0y07495y.js} +20 -20
  129. package/dist/{worker-sqjd1nqf.js → worker-d4q9sqbc.js} +36 -36
  130. package/openapi.json +1070 -9
  131. package/package.json +3 -3
  132. package/src/be/connector-codes.ts +65 -22
  133. package/src/be/memory/boot-reembed.ts +4 -1
  134. package/src/be/memory/constants.ts +6 -0
  135. package/src/be/memory/embedding-recovery.ts +48 -0
  136. package/src/be/memory/index-content.ts +5 -2
  137. package/src/be/memory/providers/openai-embedding.ts +65 -14
  138. package/src/be/memory/task-learning.ts +110 -8
  139. package/src/be/memory/types.ts +13 -2
  140. package/src/be/migrations/246_connector_code_kind.sql +11 -0
  141. package/src/be/onboarding.ts +125 -15
  142. package/src/be/rbac-roles.ts +4 -1
  143. package/src/be/scripts/embeddings.ts +2 -2
  144. package/src/be/seed/agent-fs-provision.ts +75 -14
  145. package/src/be/swarm-config-guard.ts +3 -0
  146. package/src/cli.tsx +9 -0
  147. package/src/comb/config.ts +6 -1
  148. package/src/commands/onboard/agent-fs-version.ts +5 -0
  149. package/src/commands/onboard/compose-generator.ts +112 -1
  150. package/src/commands/onboard/env-generator.ts +9 -0
  151. package/src/commands/onboard/tei-image.ts +12 -0
  152. package/src/commands/onboard/types.ts +7 -0
  153. package/src/commands/onboard.tsx +11 -1
  154. package/src/commands/runner.ts +14 -1
  155. package/src/http/agentfs-proxy.ts +369 -0
  156. package/src/http/all-routes.ts +1 -0
  157. package/src/http/codex-oauth-device.ts +8 -1
  158. package/src/http/fs.ts +64 -10
  159. package/src/http/inbox-state.ts +27 -1
  160. package/src/http/index.ts +4 -1
  161. package/src/http/memory.ts +7 -4
  162. package/src/http/onboarding.ts +139 -11
  163. package/src/http/session-access.ts +24 -0
  164. package/src/http/sessions.ts +10 -6
  165. package/src/http/tasks.ts +85 -11
  166. package/src/http/telemetry-ui.ts +1 -1
  167. package/src/http/user-roles.ts +4 -1
  168. package/src/http/users.ts +272 -3
  169. package/src/http/utils.ts +22 -3
  170. package/src/prompts/base-prompt.ts +24 -9
  171. package/src/prompts/session-templates.ts +26 -2
  172. package/src/providers/amp-adapter.ts +1 -1
  173. package/src/providers/codex-adapter.ts +6 -1
  174. package/src/providers/cursor-adapter.ts +1 -1
  175. package/src/providers/pi-mono-adapter.ts +58 -3
  176. package/src/rbac/legacy-policy.ts +1 -0
  177. package/src/rbac/permissions.ts +4 -0
  178. package/src/tests/agent-fs-provision-seeder.test.ts +261 -92
  179. package/src/tests/agentfs-proxy.test.ts +1106 -0
  180. package/src/tests/base-prompt.test.ts +69 -2
  181. package/src/tests/codex-adapter.test.ts +46 -8
  182. package/src/tests/codex-oauth-device.test.ts +12 -1
  183. package/src/tests/comb-config-guard.test.ts +20 -0
  184. package/src/tests/comb-links.test.ts +79 -1
  185. package/src/tests/comb-status.test.ts +44 -0
  186. package/src/tests/docs-index-footprint.test.js +3 -3
  187. package/src/tests/fixtures/onboard/baseline-states.ts +68 -0
  188. package/src/tests/fixtures/onboard/content.compose.yml.txt +190 -0
  189. package/src/tests/fixtures/onboard/content.env.txt +29 -0
  190. package/src/tests/fixtures/onboard/dev.compose.yml.txt +155 -0
  191. package/src/tests/fixtures/onboard/dev.env.txt +28 -0
  192. package/src/tests/fixtures/onboard/full-all.compose.yml.txt +582 -0
  193. package/src/tests/fixtures/onboard/full-all.env.txt +59 -0
  194. package/src/tests/fixtures/onboard/full.compose.yml.txt +435 -0
  195. package/src/tests/fixtures/onboard/full.env.txt +36 -0
  196. package/src/tests/fixtures/onboard/research.compose.yml.txt +155 -0
  197. package/src/tests/fixtures/onboard/research.env.txt +28 -0
  198. package/src/tests/fixtures/onboard/solo.compose.yml.txt +85 -0
  199. package/src/tests/fixtures/onboard/solo.env.txt +26 -0
  200. package/src/tests/http-log-scrubbing.test.ts +34 -1
  201. package/src/tests/http-task-write-ownership.test.ts +5 -1
  202. package/src/tests/invites-http.test.ts +464 -0
  203. package/src/tests/memory-embedding-input-cap.test.ts +122 -1
  204. package/src/tests/memory-embedding-recovery.test.ts +137 -0
  205. package/src/tests/onboard-compose.test.ts +142 -0
  206. package/src/tests/onboard-env.test.ts +24 -0
  207. package/src/tests/onboard-non-interactive.test.ts +29 -0
  208. package/src/tests/onboard-tei-image.test.ts +36 -0
  209. package/src/tests/onboarding.test.ts +451 -4
  210. package/src/tests/openai-embedding-provider.test.ts +105 -2
  211. package/src/tests/pi-mono-session-cost-on-abort.test.ts +270 -0
  212. package/src/tests/preload-env.test.ts +4 -0
  213. package/src/tests/preload.ts +4 -0
  214. package/src/tests/prompt-template-session.test.ts +19 -3
  215. package/src/tests/rbac-admission-e2e.test.ts +22 -6
  216. package/src/tests/rbac-admission.test.ts +342 -4
  217. package/src/tests/rbac-engine.test.ts +1 -0
  218. package/src/tests/rbac-roles.test.ts +9 -1
  219. package/src/tests/send-task-requester-gate.test.ts +64 -3
  220. package/src/tests/sessions-move.test.ts +39 -1
  221. package/src/tests/store-progress-learning.test.ts +95 -2
  222. package/src/tests/whoami-route.test.ts +41 -0
  223. package/src/tools/inject-learning.ts +1 -1
  224. package/src/tools/memory-edit.ts +1 -1
  225. package/src/tools/memory-search.ts +1 -1
  226. package/src/tools/store-progress.ts +5 -1
  227. package/src/tools/utils.ts +12 -1
  228. package/src/utils/constants.ts +39 -0
  229. package/src/utils/task-attachment-links.ts +15 -3
  230. package/templates/skills/swarm-docs/content.md +1 -1
  231. package/dist/handlers-tzmevqb5.js +0 -41
  232. package/dist/{azure-openai-responses-9kfwhmn7.js → azure-openai-responses-43asa23s.js} +0 -0
  233. package/dist/{node-vxx896fa.js → node-t28cc56b.js} +0 -0
  234. package/dist/{openai-codex-responses-79s8xnbh.js → openai-codex-responses-jhr3sjtm.js} +3 -3
  235. package/dist/{openai-completions-tdbyts0x.js → openai-completions-gs6yn37k.js} +3 -3
  236. /package/dist/{openai-responses-dm3t5vqp.js → openai-responses-jqm9vwwk.js} +0 -0
package/src/http/users.ts CHANGED
@@ -20,6 +20,8 @@
20
20
  * DELETE /api/users/:id/mcp-tokens/:tokenId
21
21
  * POST /api/users/:id/connector-codes
22
22
  * POST /api/connector/exchange (public: the code is the credential)
23
+ * POST /api/invites
24
+ * POST /api/invites/exchange (public: the code is the credential)
23
25
  * POST /api/users/:id/merge
24
26
  * GET /api/users/:id/events
25
27
  * POST /api/users/:id/identities
@@ -31,7 +33,10 @@ import { z } from "zod";
31
33
  import {
32
34
  createConnectorCode,
33
35
  DEFAULT_CONNECTOR_CODE_LABEL,
36
+ DEFAULT_INVITE_CODE_LABEL,
37
+ deleteUnusedInviteCodes,
34
38
  exchangeConnectorCode,
39
+ INVITE_CODE_TTL_MS,
35
40
  } from "../be/connector-codes";
36
41
  import {
37
42
  createUser,
@@ -45,8 +50,15 @@ import {
45
50
  updateUser,
46
51
  upsertBudget,
47
52
  } from "../be/db";
48
- import { assertNotLastAdmin, LastAdminError } from "../be/rbac-roles";
49
53
  import {
54
+ assertNotLastAdmin,
55
+ getUserGrant,
56
+ LastAdminError,
57
+ listUserRoles,
58
+ setUserRoles,
59
+ } from "../be/rbac-roles";
60
+ import {
61
+ findUserByEmail,
50
62
  getUserIdentities,
51
63
  type IdentityEvent,
52
64
  linkIdentity,
@@ -57,12 +69,16 @@ import {
57
69
  revokeToken,
58
70
  unlinkIdentity,
59
71
  } from "../be/users";
72
+ import { isRbacEnabled } from "../rbac";
60
73
  import { UserCommsPrefsSchema, UserSchema } from "../types";
74
+ import { DEFAULT_APP_URL } from "../utils/constants";
75
+ import { assertUrlSafe } from "../utils/egress-guard";
61
76
  import { getRequestAuth } from "../utils/request-auth-context";
62
77
  import { resolveAppUrl, resolveMcpBaseUrl } from "./config-values";
63
78
  import { clientIp, createIpRateLimiter } from "./ip-rate-limit";
64
79
  import { getOperatorActor } from "./operator-actor";
65
80
  import { route } from "./route-def";
81
+ import { ensureRoleManager } from "./user-roles";
66
82
  import { jsonError } from "./utils";
67
83
 
68
84
  // ─── Response composition ────────────────────────────────────────────────────
@@ -172,6 +188,15 @@ const whoamiRoute = route({
172
188
  // `components.schemas.User` to `object | null` for every consumer;
173
189
  // the union keeps nullability local to this response field.
174
190
  user: z.union([UserSchema, z.null()]),
191
+ // Optional so older clients and older servers stay compatible. The
192
+ // dashboard hides what the caller cannot do; the server still gates.
193
+ grants: z
194
+ .object({
195
+ grantsAll: z.boolean(),
196
+ verbs: z.array(z.string()),
197
+ roles: z.array(z.string()),
198
+ })
199
+ .optional(),
175
200
  }),
176
201
  },
177
202
  401: { description: "Unauthorized" },
@@ -448,6 +473,80 @@ const exchangeConnectorCodeRoute = route({
448
473
  },
449
474
  });
450
475
 
476
+ const INVITE_ROLES = ["requester", "viewer", "admin"] as const;
477
+
478
+ const createInviteRoute = route({
479
+ method: "post",
480
+ path: "/api/invites",
481
+ pattern: ["api", "invites"],
482
+ summary: "Invite a teammate with a one-time API link",
483
+ description:
484
+ "Creates an invited user with the given role (requester by default) and returns a 7-day single-use API link. The API redirects to the current APP_URL dashboard with the code in the URL fragment. The browser exchanges the code at POST /api/invites/exchange for a personal MCP token. Re-inviting an invited user replaces the role and link. Active or suspended users return 409 user_exists. No email is sent.",
485
+ tags: ["Users"],
486
+ body: z.object({
487
+ email: z.email(),
488
+ name: z.string().trim().min(1).max(200).optional(),
489
+ role: z.enum(INVITE_ROLES).default("requester"),
490
+ }),
491
+ responses: {
492
+ 201: {
493
+ description: "The invited user, API link, dashboard URL, expiry and API reachability",
494
+ schema: z.object({
495
+ user: ComposedUserSchema.nullable(),
496
+ inviteUrl: z.string(),
497
+ appUrl: z.string(),
498
+ expiresAt: z.string(),
499
+ apiReachable: z.enum(["public", "local"]),
500
+ }),
501
+ },
502
+ 401: { description: "Unauthorized" },
503
+ 403: { description: "Not allowed to manage roles" },
504
+ 409: { description: "The email belongs to an active or suspended user" },
505
+ },
506
+ auth: { apiKey: true },
507
+ rbac: { permission: "user.role.manage" },
508
+ });
509
+
510
+ const openInviteRoute = route({
511
+ method: "get",
512
+ path: "/invite/{code}",
513
+ pattern: ["invite", null],
514
+ summary: "Open an invite in the configured dashboard",
515
+ description:
516
+ "Redirects to the current APP_URL dashboard with the code in the URL fragment. Does not validate or consume the invite.",
517
+ tags: ["Users"],
518
+ params: z.object({ code: z.string() }),
519
+ responses: {
520
+ 302: { description: "Redirect to the dashboard", unstructured: "Dashboard redirect" },
521
+ 404: { description: "Malformed code (code_invalid)" },
522
+ },
523
+ auth: { apiKey: false },
524
+ });
525
+
526
+ const exchangeInviteCodeRoute = route({
527
+ method: "post",
528
+ path: "/api/invites/exchange",
529
+ pattern: ["api", "invites", "exchange"],
530
+ summary: "Exchange an invite code for the invited user's MCP token",
531
+ description:
532
+ "Unauthenticated: the single-use invite code is the credential. Rate limited per IP. Activates the invited user. Unknown, expired, used and connector codes, and codes for users who are no longer invited, all return 404 code_invalid.",
533
+ tags: ["Users"],
534
+ body: z.object({ code: z.string().min(1).max(256) }),
535
+ // Public route: cap the body before it is buffered and parsed.
536
+ maxBodyBytes: 1024,
537
+ responses: {
538
+ 200: {
539
+ description: "Minted MCP token for the invited user",
540
+ schema: z.object({ token: z.string(), userId: z.string() }),
541
+ },
542
+ 404: { description: "Code unknown, malformed, expired, already used or not an invite" },
543
+ 413: { description: "Request body too large" },
544
+ 429: { description: "Rate limited" },
545
+ },
546
+ auth: { apiKey: false },
547
+ rbac: { ungated: "the one-time invite code is the credential" },
548
+ });
549
+
451
550
  const connectorDiscoveryRoute = route({
452
551
  method: "get",
453
552
  path: "/api/connector/discovery",
@@ -681,6 +780,35 @@ export function _resetConnectorExchangeRateLimitForTests(): void {
681
780
  connectorExchangeRateLimiter.reset();
682
781
  }
683
782
 
783
+ /** 10 invite exchanges per minute per client IP. */
784
+ const inviteExchangeRateLimiter = createIpRateLimiter({
785
+ capacity: 10,
786
+ refillPerMs: 10 / 60_000,
787
+ });
788
+
789
+ export function _resetInviteExchangeRateLimitForTests(): void {
790
+ inviteExchangeRateLimiter.reset();
791
+ }
792
+
793
+ /**
794
+ * `local` when teammates' browsers likely cannot reach the API: a loopback or
795
+ * private IP host, a single-label name (a compose service such as
796
+ * `swarm-api`), or a `.local` / `.internal` / `.lan` name.
797
+ */
798
+ function apiReachability(apiUrl: string): "public" | "local" {
799
+ let host: string;
800
+ try {
801
+ host = assertUrlSafe(apiUrl, { allowInsecure: true }).hostname.toLowerCase();
802
+ } catch {
803
+ return "local";
804
+ }
805
+ if (!host.includes(".") && !host.includes(":")) return "local";
806
+ if (/\.(local|internal|lan|home\.arpa)\.?$/.test(host)) return "local";
807
+ return "public";
808
+ }
809
+
810
+ class InviteUserExistsError extends Error {}
811
+
684
812
  // ─── Handler ─────────────────────────────────────────────────────────────────
685
813
 
686
814
  export async function handleUsers(
@@ -695,9 +823,26 @@ export async function handleUsers(
695
823
  if (!parsed) return true;
696
824
  const auth = getRequestAuth(req);
697
825
  if (auth?.kind === "user") {
698
- whoamiRoute.respond(res, 200, { kind: "user", user: auth.user });
826
+ const [grant, roles] = await Promise.all([
827
+ getUserGrant(auth.user.id),
828
+ listUserRoles(auth.user.id),
829
+ ]);
830
+ const grantsAll = !isRbacEnabled() || grant.grantsAll;
831
+ whoamiRoute.respond(res, 200, {
832
+ kind: "user",
833
+ user: auth.user,
834
+ grants: {
835
+ grantsAll,
836
+ verbs: grantsAll ? [] : [...grant.verbs].sort(),
837
+ roles: roles.map((role) => role.name),
838
+ },
839
+ });
699
840
  } else {
700
- whoamiRoute.respond(res, 200, { kind: "operator", user: null });
841
+ whoamiRoute.respond(res, 200, {
842
+ kind: "operator",
843
+ user: null,
844
+ grants: { grantsAll: true, verbs: [], roles: [] },
845
+ });
701
846
  }
702
847
  return true;
703
848
  }
@@ -934,6 +1079,130 @@ export async function handleUsers(
934
1079
  return true;
935
1080
  }
936
1081
 
1082
+ // ─── POST /api/invites ─────────────────────────────────────────────────────
1083
+ if (createInviteRoute.match(req.method, pathSegments)) {
1084
+ const parsed = await createInviteRoute.parse(req, res, pathSegments, queryParams);
1085
+ if (!parsed) return true;
1086
+ // Same gate as PUT /api/users/{id}/roles: an invite assigns a role, so a
1087
+ // custom role holding user.role.manage must not hand out admin.
1088
+ if (!(await ensureRoleManager(req, res))) return true;
1089
+ const actor = getOperatorActor(req, res);
1090
+ if (!actor) return true;
1091
+ const { email, name, role } = parsed.body;
1092
+ const actorUserId = actor.kind === "user" ? actor.id : null;
1093
+
1094
+ let invited: { userId: string; code: string; expiresAt: string };
1095
+ try {
1096
+ invited = await getDbClient().transaction(async () => {
1097
+ const existing = await findUserByEmail(email);
1098
+ if (existing && existing.status !== "invited") throw new InviteUserExistsError();
1099
+ let userId: string;
1100
+ if (existing) {
1101
+ userId = existing.id;
1102
+ await deleteUnusedInviteCodes(userId);
1103
+ } else {
1104
+ const user = await createUser({
1105
+ name: name ?? (email.split("@")[0] || email),
1106
+ email,
1107
+ status: "invited",
1108
+ });
1109
+ userId = user.id;
1110
+ await recordIdentityEvent(userId, "identity_added", actor, null, {
1111
+ email,
1112
+ source: "invite",
1113
+ });
1114
+ }
1115
+ const { before, after } = await setUserRoles(userId, [role], actorUserId);
1116
+ if (before.join(",") !== after.join(",")) {
1117
+ await recordIdentityEvent(
1118
+ userId,
1119
+ "profile_changed",
1120
+ actor,
1121
+ { rbacRoles: before },
1122
+ { rbacRoles: after },
1123
+ );
1124
+ }
1125
+ const { code, expiresAt } = await createConnectorCode(
1126
+ userId,
1127
+ DEFAULT_INVITE_CODE_LABEL,
1128
+ actor,
1129
+ new Date(),
1130
+ { kind: "invite", ttlMs: INVITE_CODE_TTL_MS },
1131
+ );
1132
+ return { userId, code, expiresAt };
1133
+ });
1134
+ } catch (err) {
1135
+ if (err instanceof InviteUserExistsError) {
1136
+ jsonError(res, "user_exists", 409);
1137
+ return true;
1138
+ }
1139
+ // Never echo the error: it must not carry the code or the link.
1140
+ jsonError(res, "Failed to create invite", 500);
1141
+ return true;
1142
+ }
1143
+
1144
+ const apiUrl = await resolveMcpBaseUrl();
1145
+ const appUrl = (await resolveAppUrl()) ?? DEFAULT_APP_URL;
1146
+ const inviteUrl = `${apiUrl}/invite/${invited.code}`;
1147
+ createInviteRoute.respond(res, 201, {
1148
+ user: await composeUser(invited.userId),
1149
+ inviteUrl,
1150
+ appUrl,
1151
+ expiresAt: invited.expiresAt,
1152
+ apiReachable: apiReachability(apiUrl),
1153
+ });
1154
+ return true;
1155
+ }
1156
+
1157
+ // GET /invite/:code only redirects. Link previews must not consume invites.
1158
+ if (openInviteRoute.match(req.method, pathSegments)) {
1159
+ const parsed = await openInviteRoute.parse(req, res, pathSegments, queryParams);
1160
+ if (!parsed) return true;
1161
+ const { code } = parsed.params;
1162
+ if (!/^[A-Za-z0-9_-]{16,256}$/.test(code)) {
1163
+ jsonError(res, "code_invalid", 404);
1164
+ return true;
1165
+ }
1166
+ const apiUrl = await resolveMcpBaseUrl();
1167
+ const appUrl = (await resolveAppUrl()) ?? DEFAULT_APP_URL;
1168
+ res.writeHead(302, {
1169
+ Location: `${appUrl}/join?swarm=${encodeURIComponent(apiUrl)}#code=${code}`,
1170
+ "Cache-Control": "no-store",
1171
+ "Referrer-Policy": "no-referrer",
1172
+ });
1173
+ res.end();
1174
+ return true;
1175
+ }
1176
+
1177
+ // ─── POST /api/invites/exchange ────────────────────────────────────────────
1178
+ if (exchangeInviteCodeRoute.match(req.method, pathSegments)) {
1179
+ if (!inviteExchangeRateLimiter.take(clientIp(req))) {
1180
+ res.setHeader("Retry-After", "6");
1181
+ jsonError(res, "rate_limited", 429);
1182
+ return true;
1183
+ }
1184
+ const parsed = await exchangeInviteCodeRoute.parse(req, res, pathSegments, queryParams);
1185
+ if (!parsed) return true;
1186
+
1187
+ // Same generated shape as connector codes; reject anything else before hashing.
1188
+ if (!CONNECTOR_CODE_PATTERN.test(parsed.body.code)) {
1189
+ jsonError(res, "code_invalid", 404);
1190
+ return true;
1191
+ }
1192
+
1193
+ try {
1194
+ const result = await exchangeConnectorCode(parsed.body.code, { kind: "invite" });
1195
+ if (!result) {
1196
+ jsonError(res, "code_invalid", 404);
1197
+ return true;
1198
+ }
1199
+ exchangeInviteCodeRoute.respond(res, 200, result);
1200
+ } catch {
1201
+ jsonError(res, "Failed to exchange code", 500);
1202
+ }
1203
+ return true;
1204
+ }
1205
+
937
1206
  // ─── DELETE /api/users/:id/mcp-tokens/:tokenId ────────────────────────────
938
1207
  if (revokeUserMcpTokenRoute.match(req.method, pathSegments)) {
939
1208
  const parsed = await revokeUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
package/src/http/utils.ts CHANGED
@@ -255,19 +255,38 @@ export function wireHttpSpanLifecycle(
255
255
  });
256
256
  }
257
257
 
258
+ /**
259
+ * Redact invite codes and agent-fs share tokens from logs and traces.
260
+ * Share paths also accept the `/agentfs` proxy prefix.
261
+ */
262
+ export function redactShareTokenInPath(pathname: string): string {
263
+ const segments = pathname.split("/");
264
+ if (segments[1] === "invite" && segments[2]) {
265
+ segments[2] = "[REDACTED]";
266
+ return segments.join("/");
267
+ }
268
+ const at = segments[1] === "agentfs" ? 2 : 1;
269
+ if ((segments[at] === "share" || segments[at] === "site") && segments[at + 1]) {
270
+ segments[at + 1] = "[REDACTED]";
271
+ return segments.join("/");
272
+ }
273
+ return pathname;
274
+ }
275
+
258
276
  export function safeRequestUrlForLog(rawUrl: string | undefined): string {
259
277
  if (!rawUrl) return "";
260
278
 
261
279
  try {
262
280
  const url = new URL(rawUrl, "http://localhost");
281
+ const pathname = redactShareTokenInPath(url.pathname);
263
282
  const params = Array.from(url.searchParams.keys());
264
- if (params.length === 0) return url.pathname;
283
+ if (params.length === 0) return pathname;
265
284
 
266
285
  const redactedQuery = params.map((key) => `${key}=[REDACTED]`).join("&");
267
- return `${url.pathname}?${redactedQuery}`;
286
+ return `${pathname}?${redactedQuery}`;
268
287
  } catch {
269
288
  const pathOnly = rawUrl.split("?")[0] || rawUrl;
270
- return scrubSecrets(pathOnly);
289
+ return scrubSecrets(redactShareTokenInPath(pathOnly));
271
290
  }
272
291
  }
273
292
 
@@ -22,6 +22,7 @@
22
22
  import type { ProviderTraits } from "../providers/types";
23
23
  import { getSlackConfiguration } from "../slack/config";
24
24
  import type { ProviderName } from "../types";
25
+ import { resolveAgentFsLinkBase } from "../utils/constants";
25
26
  import { isSteeringEnabled } from "../utils/steering-enabled";
26
27
  import { matchesDefaultClaudeMd, matchesDefaultIdentityMd } from "./defaults";
27
28
  import { resolveTemplateAsync } from "./resolver";
@@ -74,6 +75,26 @@ function claudeMdSourcePath(provider: string): string {
74
75
  /** Providers that get the repo CLAUDE.md inlined until native loading is verified. */
75
76
  const REPO_CLAUDE_MD_INLINE_PROVIDERS: ReadonlySet<string> = new Set(["opencode"]);
76
77
 
78
+ /**
79
+ * Template event for the tool-discovery line. Amp, Codex and OpenCode run MCP
80
+ * tools inside their own code sandbox (`code_exec`, `exec`, `execute`), each
81
+ * with its own call shape, so the generic lines are wrong for them.
82
+ */
83
+ function toolDiscoveryEvent(provider: string, hasToolSearch: boolean): string {
84
+ switch (provider) {
85
+ case "amp":
86
+ return "system.agent.tool_discovery.amp";
87
+ case "codex":
88
+ return "system.agent.tool_discovery.codex";
89
+ case "opencode":
90
+ return "system.agent.tool_discovery.opencode";
91
+ default:
92
+ return hasToolSearch
93
+ ? "system.agent.tool_discovery.search"
94
+ : "system.agent.tool_discovery.direct";
95
+ }
96
+ }
97
+
77
98
  export function areSlackPromptToolsEnabled(
78
99
  env: NodeJS.ProcessEnv = process.env,
79
100
  serverCapabilities?: string[],
@@ -229,7 +250,8 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
229
250
  const agentFsConfigured = hasLocalEnv && Boolean(process.env.AGENT_FS_API_URL);
230
251
  const outputsResult = await resolveTemplateAsync(
231
252
  agentFsConfigured ? "system.agent.outputs" : "system.agent.outputs.no_agent_fs",
232
- {},
253
+ // Unset AGENT_FS_LINK_TARGET keeps the literal the agent expands in its shell.
254
+ { fileLinkBase: resolveAgentFsLinkBase("prompt") ?? `\${AGENT_FS_LIVE_URL}` },
233
255
  );
234
256
  prompt += outputsResult.text;
235
257
  }
@@ -257,14 +279,7 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
257
279
 
258
280
  // I. Tools and skills. Skipped without MCP: the discovery tools are MCP tools.
259
281
  if (hasMcp) {
260
- // Amp's MCP tools sit behind its own `tool_search` + `code_exec` with
261
- // underscored names, so the generic "load with your tool search" line is not enough.
262
- const discoveryEvent =
263
- provider === "amp"
264
- ? "system.agent.tool_discovery.amp"
265
- : hasToolSearch
266
- ? "system.agent.tool_discovery.search"
267
- : "system.agent.tool_discovery.direct";
282
+ const discoveryEvent = toolDiscoveryEvent(provider, hasToolSearch);
268
283
  const discoveryResult = await resolveTemplateAsync(discoveryEvent, {});
269
284
  const toolsResult = await resolveTemplateAsync(
270
285
  "system.agent.tools_skills",
@@ -216,9 +216,15 @@ agent-fs is the shared drive between agents and people. Documents (reports, dige
216
216
  Pages: only for polished artifacts shared with a wider audience or explicitly requested; see \`pages\` skill.
217
217
  A tool a person will use, with data and actions: build an app. See the \`apps\` skill.
218
218
  Link env: \`APP_URL\` for pages, \`MCP_BASE_URL\` for the API, \`AGENT_FS_LIVE_URL\` for files. Report missing variables.
219
- Give people the live app link \`\${AGENT_FS_LIVE_URL}/file/~/<org>/<drive>/<path>\`. Make an \`agent-fs share-create\` link only when the requester asks for one or the reader has no agent-fs login.
219
+ Give people the live app link \`{{fileLinkBase}}/file/~/<org>/<drive>/<path>\`. Make an \`agent-fs share-create\` link only when the requester asks for one or the reader has no agent-fs login.
220
220
  `,
221
- variables: [],
221
+ variables: [
222
+ {
223
+ name: "fileLinkBase",
224
+ description:
225
+ "Base URL of people-facing file links (AGENT_FS_LINK_TARGET); the literal AGENT_FS_LIVE_URL shell reference when unset",
226
+ },
227
+ ],
222
228
  category: "system",
223
229
  });
224
230
 
@@ -442,6 +448,24 @@ registerTemplate({
442
448
  category: "system",
443
449
  });
444
450
 
451
+ registerTemplate({
452
+ eventType: "system.agent.tool_discovery.codex",
453
+ header: "",
454
+ defaultBody:
455
+ "Swarm tools are not in your tool list. Call them from `exec` on the `tools` object, with dashes turned into underscores: `store-progress` is `await tools.mcp__agent_swarm__store_progress({ ... })`. Call a tool you know by that name directly. To find one, filter `ALL_TOOLS` by `name` and `description`.",
456
+ variables: [],
457
+ category: "system",
458
+ });
459
+
460
+ registerTemplate({
461
+ eventType: "system.agent.tool_discovery.opencode",
462
+ header: "",
463
+ defaultBody:
464
+ 'Swarm tools are not in your tool list. Call them from `execute` under `tools.swarm`, by their exact names: `store-progress` is `await tools.swarm["store-progress"]({ ... })`. To find one, use `search({ query })` inside `execute`.',
465
+ variables: [],
466
+ category: "system",
467
+ });
468
+
445
469
  registerTemplate({
446
470
  eventType: "system.agent.tool_discovery.direct",
447
471
  header: "",
@@ -45,7 +45,7 @@ import type {
45
45
  * default; the image installs exactly this version and the per-task settings
46
46
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
47
47
  */
48
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1793025586-g9211b5";
48
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1793073650-g23dd39";
49
49
 
50
50
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
51
51
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -1613,8 +1613,13 @@ export class CodexSession implements ProviderSession {
1613
1613
  "invalid 'refresh_token'",
1614
1614
  ];
1615
1615
  if (authPatterns.some((p) => normalized.includes(p))) {
1616
+ const slot = this.config.codexSlot;
1617
+ const guidance =
1618
+ slot === undefined
1619
+ ? "Codex authentication failed — check OPENAI_API_KEY or ChatGPT login."
1620
+ : `Codex pool slot ${slot} authentication failed — re-run agent-swarm codex-login --slot ${slot}.`;
1616
1621
  return {
1617
- message: `[auth-error] Codex authentication failed — check OPENAI_API_KEY or ChatGPT login. Original error: ${raw}`,
1622
+ message: `[auth-error] ${guidance} Original error: ${raw}`,
1618
1623
  category: "authentication_failed",
1619
1624
  };
1620
1625
  }
@@ -35,7 +35,7 @@ import type {
35
35
  } from "./types";
36
36
 
37
37
  /** Pinned in package.json and Dockerfile.worker; bump all three together. */
38
- export const CURSOR_SDK_VERSION = "1.0.40";
38
+ export const CURSOR_SDK_VERSION = "1.1.0";
39
39
 
40
40
  /** MCP server name; Cursor reports its tools as `mcp` calls with this `providerIdentifier`. */
41
41
  const CURSOR_MCP_SERVER_NAME = "agent-swarm";
@@ -1041,6 +1041,8 @@ export class PiMonoSession implements ProviderSession {
1041
1041
  private emptyTurnReprompted = false;
1042
1042
  /** `abort()` was called: never prompt a session someone is trying to stop. */
1043
1043
  private abortRequested = false;
1044
+ /** Pending `waitForIdle()` resolvers released when `agent_end` fires or `abort()` is called. */
1045
+ private idleResolvers = new Set<() => void>();
1044
1046
  /** Removes this session's own models.json once the session ends. */
1045
1047
  private disposeModels: () => Promise<void>;
1046
1048
  /** Fed each assistant request's prompt size; steers the one-time context-bound note. */
@@ -1249,6 +1251,14 @@ export class PiMonoSession implements ProviderSession {
1249
1251
  case "tool_execution_end":
1250
1252
  if (this.pendingTerminalStoreProgress.delete(event.toolCallId) && !event.isError) {
1251
1253
  this.terminalStoreProgressDone = true;
1254
+ const snapshotCost = this.safeBuildCostData(false);
1255
+ if (snapshotCost) {
1256
+ this.emit({
1257
+ type: "result",
1258
+ cost: snapshotCost,
1259
+ isError: false,
1260
+ });
1261
+ }
1252
1262
  }
1253
1263
  this.emit({
1254
1264
  type: "raw_log",
@@ -1354,6 +1364,24 @@ export class PiMonoSession implements ProviderSession {
1354
1364
  };
1355
1365
  } catch (err) {
1356
1366
  const errorMessage = err instanceof Error ? err.message : String(err);
1367
+ if (this.abortRequested && this.terminalStoreProgressDone) {
1368
+ const cost = this.safeBuildCostData(false);
1369
+ if (cost) {
1370
+ this.emit({
1371
+ type: "result",
1372
+ cost,
1373
+ isError: false,
1374
+ });
1375
+ }
1376
+ return {
1377
+ exitCode: 0,
1378
+ sessionId: this._sessionId,
1379
+ cost,
1380
+ output: this.lastAssistantText || undefined,
1381
+ isError: false,
1382
+ appliedReasoningEffort: this.appliedReasoningEffort,
1383
+ };
1384
+ }
1357
1385
  // Defense-in-depth: AWS SDK failures surface as structured events (handled
1358
1386
  // above in runSession), not thrown exceptions, so this catch is for genuine
1359
1387
  // unexpected throws (MCP / transport / etc). Still classify in case an AWS
@@ -1368,9 +1396,11 @@ export class PiMonoSession implements ProviderSession {
1368
1396
  }
1369
1397
  this.emit({ type: "raw_stderr", content: `[pi-mono] Error: ${errorMessage}\n` });
1370
1398
 
1399
+ const cost = this.safeBuildCostData(true);
1371
1400
  return {
1372
1401
  exitCode: 1,
1373
1402
  sessionId: this._sessionId,
1403
+ cost,
1374
1404
  isError: true,
1375
1405
  errorCategory: awsCatchError?.category,
1376
1406
  failureReason: scrubSecrets(awsCatchError?.message ?? errorMessage),
@@ -1453,16 +1483,38 @@ export class PiMonoSession implements ProviderSession {
1453
1483
  return;
1454
1484
  }
1455
1485
 
1456
- // Subscribe and wait for agent_end
1486
+ let settled = false;
1487
+ const finish = () => {
1488
+ if (settled) return;
1489
+ settled = true;
1490
+ unsub();
1491
+ this.idleResolvers.delete(finish);
1492
+ resolve();
1493
+ };
1494
+
1495
+ // Subscribe and wait for agent_end (or abort)
1457
1496
  const unsub = this.agentSession.subscribe((event) => {
1458
1497
  if (event.type === "agent_end") {
1459
- unsub();
1460
- resolve();
1498
+ finish();
1461
1499
  }
1462
1500
  });
1501
+ this.idleResolvers.add(finish);
1502
+ if (this.abortRequested) {
1503
+ finish();
1504
+ }
1463
1505
  });
1464
1506
  }
1465
1507
 
1508
+ private safeBuildCostData(isError: boolean): CostData | undefined {
1509
+ try {
1510
+ const stats = this.agentSession.getSessionStats();
1511
+ if (!stats) return undefined;
1512
+ return { ...this.buildCostData(stats), isError };
1513
+ } catch {
1514
+ return undefined;
1515
+ }
1516
+ }
1517
+
1466
1518
  private buildCostData(stats: SessionStats): CostData {
1467
1519
  return {
1468
1520
  sessionId: "", // Runner overrides with runner session ID
@@ -1502,6 +1554,9 @@ export class PiMonoSession implements ProviderSession {
1502
1554
 
1503
1555
  async abort(): Promise<void> {
1504
1556
  this.abortRequested = true;
1557
+ for (const resolve of [...this.idleResolvers]) {
1558
+ resolve();
1559
+ }
1505
1560
  await this.agentSession.abort();
1506
1561
  }
1507
1562
 
@@ -340,6 +340,7 @@ export const LEGACY_POLICY = {
340
340
  "mcp-oauth.authorize.any": anyAuthenticated,
341
341
  "kv.write.any": leadOrOwnNamespace,
342
342
  "comb.presence": operatorOrUser,
343
+ "fs.join.own": anyAuthenticated,
343
344
  "page.delete.any": leadOrResourceOwner,
344
345
  "app.manage": anyAuthenticated,
345
346
  "app.use": anyAuthenticated,
@@ -314,6 +314,10 @@ export const PERMISSIONS = {
314
314
  description: "Use dashboard presence rooms for Comb drives.",
315
315
  namespace: "comb",
316
316
  },
317
+ "fs.join.own": {
318
+ description: "Join the swarm's shared agent-fs drive with your own account email.",
319
+ namespace: "fs",
320
+ },
317
321
  "page.delete.any": {
318
322
  description: "Delete a page the caller does not own.",
319
323
  namespace: "page",