@desplega.ai/agent-swarm 2.7.4 → 2.7.5

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 (253) hide show
  1. package/README.md +1 -1
  2. package/dist/acp-adapter-g3ads55s.js +15 -0
  3. package/dist/{actions-gfrr9vb7.js → actions-zwfykqqk.js} +18 -18
  4. package/dist/{amp-adapter-0s58gm46.js → amp-adapter-z3s0ssdt.js} +3 -3
  5. package/dist/{app-stvq692g.js → app-vmp4fgep.js} +3 -3
  6. package/dist/{assistant-5s37fr9n.js → assistant-vwde653f.js} +28 -21
  7. package/dist/{boot-reembed-yzy1841n.js → boot-reembed-375t7tbk.js} +1 -1
  8. package/dist/{boot-reembed-cv82gz7y.js → boot-reembed-gq9rcpnj.js} +1 -1
  9. package/dist/{boot-scrub-sweep-8k4q95ap.js → boot-scrub-sweep-nd938kyf.js} +4 -4
  10. package/dist/{boot-seeds-ftt9htah.js → boot-seeds-aykx51v3.js} +4 -4
  11. package/dist/{boxd-515v1t8z.js → boxd-k1j26g3h.js} +1 -1
  12. package/dist/{claude-adapter-t00wrzeh.js → claude-adapter-8j4hm099.js} +7 -7
  13. package/dist/{claude-managed-adapter-g6vmjccb.js → claude-managed-adapter-dvs2xw5n.js} +2 -2
  14. package/dist/{claude-managed-setup-afpa82f7.js → claude-managed-setup-0wyynpn1.js} +3 -3
  15. package/dist/{claude-sdk-session-vj0c2azx.js → claude-sdk-session-q6wj5hmy.js} +9 -9
  16. package/dist/{cli-wndv9p7g.js → cli-0731hvbw.js} +6 -6
  17. package/dist/{cli-d2pzqej1.js → cli-0bxqnvq9.js} +719 -7
  18. package/dist/{cli-jkt52mgt.js → cli-0gey5fzy.js} +2 -2
  19. package/dist/{cli-1yekbyqm.js → cli-1338796d.js} +205 -48
  20. package/dist/{cli-1s1dqn1j.js → cli-1ayk1eas.js} +171 -123
  21. package/dist/{cli-a4btqqft.js → cli-1b7btd38.js} +1 -1
  22. package/dist/{cli-a2jw6ee5.js → cli-1p89xny0.js} +1 -1
  23. package/dist/{cli-v726afdm.js → cli-2pnswgh8.js} +1 -1
  24. package/dist/{cli-kvtc7aez.js → cli-3h4nhjn8.js} +42 -14
  25. package/dist/{cli-655xm9ms.js → cli-583frbyr.js} +1 -1
  26. package/dist/{cli-4r12t6w6.js → cli-5c4hjzne.js} +101 -4
  27. package/dist/{cli-az0bpf8p.js → cli-5pag6rmn.js} +3 -3
  28. package/dist/{cli-36g23z21.js → cli-6s7vdcta.js} +4 -4
  29. package/dist/{cli-rtk9g7av.js → cli-73jzwksr.js} +6 -2
  30. package/dist/{cli-jg0nfda6.js → cli-75gkpjvw.js} +52 -21
  31. package/dist/{cli-a81qevyg.js → cli-7a42nfy0.js} +20 -36
  32. package/dist/{cli-0sbkss7h.js → cli-7wcyk2s5.js} +2 -2
  33. package/dist/{cli-41z8xwj0.js → cli-7wspm5gm.js} +14 -4
  34. package/dist/{cli-byqfbnjw.js → cli-82ae98pz.js} +9 -9
  35. package/dist/{cli-bq2pcphn.js → cli-8hs41zy2.js} +1 -1
  36. package/dist/{cli-vqgnyh76.js → cli-98rw5dt5.js} +15 -3
  37. package/dist/{cli-9cctx1tn.js → cli-a22h291q.js} +1 -1
  38. package/dist/{cli-j4kt1mpb.js → cli-a47xy8eq.js} +3 -3
  39. package/dist/{cli-ycm6180s.js → cli-a7v854rf.js} +5 -3
  40. package/dist/{cli-98pke4vk.js → cli-ae7mrdf9.js} +2 -2
  41. package/dist/{cli-ahd90bwt.js → cli-afrrppsw.js} +6 -6
  42. package/dist/{cli-8yy525ad.js → cli-bhjt02p8.js} +2 -2
  43. package/dist/{cli-v1yw3wha.js → cli-bnz7a7qf.js} +4 -4
  44. package/dist/{cli-j4kw3rsx.js → cli-cm5yqn35.js} +18 -0
  45. package/dist/{cli-h55y2bjc.js → cli-dhar9qay.js} +2 -2
  46. package/dist/{cli-5gr1g711.js → cli-fqzx7gwa.js} +2 -2
  47. package/dist/{cli-tzrpv5sc.js → cli-ftyxyspd.js} +1 -1
  48. package/dist/cli-fw2w0kva.js +47 -0
  49. package/dist/{cli-2dfbv8qr.js → cli-fy6x31ps.js} +1 -0
  50. package/dist/{cli-mvckssyq.js → cli-g61qxmge.js} +5 -5
  51. package/dist/{cli-aq58fk5m.js → cli-g83s9ewh.js} +7 -1
  52. package/dist/{cli-224epwwf.js → cli-hn9hxmz9.js} +3 -3
  53. package/dist/{cli-5hjced2v.js → cli-j3jvnpe8.js} +6 -6
  54. package/dist/{cli-dae4sbgy.js → cli-jm2vfpfd.js} +2 -2
  55. package/dist/{cli-zn15c46z.js → cli-mjzynrse.js} +1 -1
  56. package/dist/{cli-a1fhj4n9.js → cli-mmwm7zbf.js} +2 -2
  57. package/dist/{cli-cq1pf336.js → cli-mry0qzym.js} +5 -5
  58. package/dist/{cli-bwty35gx.js → cli-mthwvjre.js} +1 -1
  59. package/dist/{cli-s0exq74r.js → cli-nmbmy64z.js} +3 -3
  60. package/dist/{cli-wcmnan2g.js → cli-nn4d8dne.js} +5 -5
  61. package/dist/{cli-jg4p5t36.js → cli-nzxr556s.js} +121 -51
  62. package/dist/{cli-bcz074zx.js → cli-p599f1qm.js} +1 -1
  63. package/dist/{cli-k76tgq03.js → cli-pywf3eh5.js} +2 -2
  64. package/dist/{cli-wk9md5px.js → cli-qxnf8z2z.js} +2 -2
  65. package/dist/{cli-je8yeyc5.js → cli-rhw40jgb.js} +5 -5
  66. package/dist/{cli-avgcz4ra.js → cli-rm6hqj3z.js} +406 -35
  67. package/dist/{cli-0fgr1w9b.js → cli-rpm2sgv0.js} +2 -2
  68. package/dist/{cli-t282jgab.js → cli-rqkxrmgw.js} +12 -12
  69. package/dist/{cli-2qpvmmkw.js → cli-s0qnd4wv.js} +1 -1
  70. package/dist/{cli-pwyevs29.js → cli-spknvxbq.js} +22 -21
  71. package/dist/{cli-9y354g3j.js → cli-srye3vdt.js} +1 -1
  72. package/dist/{cli-pe09p6dv.js → cli-tqgg40ek.js} +1 -1
  73. package/dist/{cli-f255yaqn.js → cli-vbyfwh92.js} +3 -3
  74. package/dist/{cli-gcbdjt47.js → cli-vfym7wmz.js} +6 -6
  75. package/dist/{cli-edmm9d9y.js → cli-vqypxzh4.js} +7 -7
  76. package/dist/{cli-1gm8jcyr.js → cli-wvaawacd.js} +13 -13
  77. package/dist/{cli-eny54typ.js → cli-wx3rhnm0.js} +1 -1
  78. package/dist/{cli-b1afgtj3.js → cli-xbkdm710.js} +3 -3
  79. package/dist/{cli-mc97xy2s.js → cli-xtjhfmpc.js} +16 -4
  80. package/dist/{cli-eqx6k993.js → cli-y24sc29k.js} +25 -13
  81. package/dist/{cli-ktmvb20s.js → cli-z6jkx23q.js} +12 -12
  82. package/dist/{cli-7n5sbw0z.js → cli-z8eh3ex8.js} +5 -5
  83. package/dist/cli-zh3fp8v2.js +151 -0
  84. package/dist/{cli-13jvmew3.js → cli-zppwg9p9.js} +7 -7
  85. package/dist/cli.js +18 -18
  86. package/dist/{codex-adapter-g0gctw29.js → codex-adapter-q6k3gnyg.js} +5 -5
  87. package/dist/{codex-hook-vrzz2xhr.js → codex-hook-9efm2x1m.js} +1 -1
  88. package/dist/{codex-login-0gg46ygn.js → codex-login-y0pmff1c.js} +1 -1
  89. package/dist/{codex-session-runner-dr2cxe1c.js → codex-session-runner-3mwmk3d9.js} +6 -6
  90. package/dist/{commands-1p07cvff.js → commands-evrenjn9.js} +2 -2
  91. package/dist/{controller-z0s7sv6r.js → controller-xjp8vswh.js} +4 -4
  92. package/dist/{controllers-connect-x9kvvhyk.js → controllers-connect-pp5jnxvc.js} +20 -20
  93. package/dist/{cursor-adapter-m5b98pvk.js → cursor-adapter-yf47w4t0.js} +2 -2
  94. package/dist/{db-rsh04kh3.js → db-rgfsh91f.js} +2 -2
  95. package/dist/{db-capk2raj.js → db-zxpw6aht.js} +3 -3
  96. package/dist/{devin-adapter-yxm87fe9.js → devin-adapter-r9phbnc9.js} +1 -1
  97. package/dist/{dispatcher-304q6fwa.js → dispatcher-smasgws7.js} +17 -17
  98. package/dist/{docker-7rxt0rry.js → docker-rrjhaqt9.js} +2 -2
  99. package/dist/{dsh-adapter-btewem0y.js → dsh-adapter-ftyy8dnp.js} +2 -2
  100. package/dist/{e2b-renc0k8q.js → e2b-qcaj675y.js} +2 -2
  101. package/dist/{e2b-7fc9b9bd.js → e2b-w1p0bnpm.js} +2 -2
  102. package/dist/{easter-egg-3d-fm1p3n4d.js → easter-egg-3d-yyts7p16.js} +1 -1
  103. package/dist/{embedding-space-s68v49ct.js → embedding-space-1z0mbg95.js} +6 -6
  104. package/dist/{embeddings-4ms3604w.js → embeddings-ygeg7tm7.js} +1 -1
  105. package/dist/{event-bus-dfjx2qdh.js → event-bus-g0ka3pbw.js} +1 -1
  106. package/dist/{execute-mcdgccjs.js → execute-14qhqp4d.js} +1 -1
  107. package/dist/{external-channel-9dxwfztn.js → external-channel-ttgrs3sx.js} +2 -2
  108. package/dist/fx-adapter-gt6pncj2.js +18 -0
  109. package/dist/{grok-adapter-05ecv4h0.js → grok-adapter-sm4m5k4g.js} +8 -8
  110. package/dist/handlers-vwmba1m2.js +31 -0
  111. package/dist/{heartbeat-3n94heh3.js → heartbeat-qahz4p62.js} +20 -20
  112. package/dist/{hook-zxwje86c.js → hook-4fd5wq5w.js} +4 -4
  113. package/dist/{hook-aadrn69x.js → hook-kj3d398m.js} +6 -6
  114. package/dist/{http-xzxk9mst.js → http-1ted0j1k.js} +100 -64
  115. package/dist/index-5r71rf98.js +32 -0
  116. package/dist/{index-d34f6r87.js → index-6rq3r5zb.js} +1 -1
  117. package/dist/{index-9j4zwcht.js → index-7v51awbn.js} +158 -672
  118. package/dist/index-anmwxs87.js +29 -0
  119. package/dist/{index-qeb82z37.js → index-q4b917zs.js} +1 -1
  120. package/dist/index-spp0ra5c.js +36 -0
  121. package/dist/{keepalive-w4aakbyk.js → keepalive-6cf311t5.js} +4 -4
  122. package/dist/lead-fjy418cw.js +69 -0
  123. package/dist/{maintenance-hbptpk6p.js → maintenance-a4bm1bmt.js} +1 -1
  124. package/dist/{oauth-refresh-sweep-217semrv.js → oauth-refresh-sweep-3h0dfw1k.js} +3 -3
  125. package/dist/{onboard-vz2s5df3.js → onboard-myn5hraa.js} +3 -3
  126. package/dist/{opencode-adapter-gtfd7d7v.js → opencode-adapter-p2nkb5am.js} +5 -5
  127. package/dist/{otel-impl-aefave37.js → otel-impl-a1a0ef6b.js} +2 -2
  128. package/dist/{pi-mono-adapter-f8h5yseh.js → pi-mono-adapter-rzmpb72a.js} +117 -23
  129. package/dist/{pricing-refresh-m1yhh9ya.js → pricing-refresh-83drrnxj.js} +3 -3
  130. package/dist/{rbac-roles-svj72cdf.js → rbac-roles-0fb6e7pj.js} +2 -2
  131. package/dist/{rbac-roles-rrw8twdz.js → rbac-roles-s8y6ecb5.js} +1 -1
  132. package/dist/{render-v2-amwbnfxh.js → render-v2-twmbgrz4.js} +3 -3
  133. package/dist/{resume-9cd6wf6z.js → resume-zfqc36xj.js} +7 -7
  134. package/dist/{runtime-fp4vrx7m.js → runtime-qwggdpqr.js} +1 -1
  135. package/dist/{session-access-21t6yjyg.js → session-access-sc9pn9da.js} +4 -4
  136. package/dist/{session-source-backfill-pxbzcsc6.js → session-source-backfill-qh07xr8g.js} +3 -3
  137. package/dist/{setup-4592tqz4.js → setup-4fr0m7a1.js} +1 -1
  138. package/dist/{virtual-modules-gzfnpmnz.js → virtual-modules-fh4ha15b.js} +2 -2
  139. package/dist/{work-objects-t9tcf174.js → work-objects-0wr8zxct.js} +20 -20
  140. package/dist/worker-0c5tyzqe.js +69 -0
  141. package/dist/{worker-follow-up-q32jh4yk.js → worker-follow-up-ktyyazyp.js} +17 -17
  142. package/dist/{x-b6wxntdt.js → x-vmmye1kb.js} +2 -2
  143. package/openapi.json +97 -32
  144. package/package.json +1 -1
  145. package/src/be/db/session-summaries.ts +8 -2
  146. package/src/be/db/session-tree.ts +174 -61
  147. package/src/be/db/session-visibility.ts +5 -4
  148. package/src/be/db.ts +8 -4
  149. package/src/be/migrations/259_session_summaries_open_tasks.sql +111 -0
  150. package/src/be/model-validation.ts +5 -1
  151. package/src/be/pricing-normalize.ts +7 -2
  152. package/src/be/scripts/typecheck.ts +1 -0
  153. package/src/be/seed/schedules-seeder.ts +135 -9
  154. package/src/be/seed/workflows-seeder.ts +65 -1
  155. package/src/be/seed-pricing.ts +23 -0
  156. package/src/be/steering.ts +50 -3
  157. package/src/be/swarm-config-guard.ts +1 -0
  158. package/src/commands/provider-credentials.ts +29 -2
  159. package/src/commands/runner.ts +86 -5
  160. package/src/extensions/contract-types.generated.ts +1 -1
  161. package/src/extensions/contract.ts +27 -1
  162. package/src/extensions/dispatcher.ts +4 -0
  163. package/src/http/agents.ts +1 -0
  164. package/src/http/mcp-bridge.ts +4 -0
  165. package/src/integrations/chat-sdk/inbound.ts +15 -5
  166. package/src/integrations/chat-sdk/steering.ts +3 -1
  167. package/src/kv-reserved-namespaces.ts +2 -0
  168. package/src/prompts/base-prompt.ts +4 -1
  169. package/src/prompts/session-templates.ts +26 -0
  170. package/src/providers/acp-adapter.ts +190 -26
  171. package/src/providers/acp-targets.ts +306 -6
  172. package/src/providers/amp-adapter.ts +1 -1
  173. package/src/providers/codex-adapter.ts +9 -44
  174. package/src/providers/codex-oauth/flow.ts +24 -3
  175. package/src/providers/codex-oauth/storage.ts +56 -17
  176. package/src/providers/codex-oauth/subscription.ts +67 -0
  177. package/src/providers/fx-adapter.ts +211 -0
  178. package/src/providers/index.ts +5 -1
  179. package/src/providers/mcp-loopback-proxy.ts +94 -0
  180. package/src/providers/pi-mono-adapter.ts +98 -13
  181. package/src/providers/pricing-sources.md +8 -0
  182. package/src/providers/reasoning-effort.ts +17 -1
  183. package/src/providers/types.ts +2 -1
  184. package/src/scheduler/scheduler.ts +36 -3
  185. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  186. package/src/scripts-runtime/types/stdlib.d.ts +5 -0
  187. package/src/scripts-runtime/types/swarm-extension.d.ts +33 -0
  188. package/src/scripts-runtime/types/swarm-sdk.d.ts +5 -0
  189. package/src/server.ts +7 -0
  190. package/src/slack/assistant.ts +17 -1
  191. package/src/slack/handlers.ts +18 -5
  192. package/src/slack/left-threads.ts +65 -0
  193. package/src/slack/steering.ts +3 -0
  194. package/src/slack/thread-buffer.ts +40 -16
  195. package/src/tests/acp-adapter.test.ts +79 -0
  196. package/src/tests/base-prompt.test.ts +12 -0
  197. package/src/tests/chat-sdk-inbound.test.ts +30 -0
  198. package/src/tests/chatgpt-subscription-model-groups.test.ts +62 -0
  199. package/src/tests/chatgpt-subscription-pi.test.ts +170 -0
  200. package/src/tests/chatgpt-subscription-session-bench.test.ts +198 -0
  201. package/src/tests/codex-oauth-credential-info.test.ts +40 -0
  202. package/src/tests/codex-oauth-refresh-egress.test.ts +194 -0
  203. package/src/tests/context-window.test.ts +7 -0
  204. package/src/tests/explicit-model-validation.test.ts +99 -0
  205. package/src/tests/extensions-run-context.test.ts +11 -0
  206. package/src/tests/extensions-steering.test.ts +353 -0
  207. package/src/tests/extensions-validate.test.ts +5 -0
  208. package/src/tests/fixtures/extensions/steering-policy/hooks.ts +39 -0
  209. package/src/tests/fixtures/extensions/steering-policy/manifest.json +7 -0
  210. package/src/tests/fixtures/fake-fx.ts +167 -0
  211. package/src/tests/fixtures/fx/acp-responses-recorded.json +157 -0
  212. package/src/tests/fixtures/fx/acp-updates-recorded.jsonl +15 -0
  213. package/src/tests/fx-adapter.test.ts +771 -0
  214. package/src/tests/grok-adapter.test.ts +36 -0
  215. package/src/tests/mcp-loopback-proxy.test.ts +193 -0
  216. package/src/tests/model-tier-resolution.test.ts +3 -3
  217. package/src/tests/pricing-refresh.test.ts +5 -5
  218. package/src/tests/prompt-template-session.test.ts +5 -3
  219. package/src/tests/provider-registration.test.ts +2 -1
  220. package/src/tests/query-audit-plans.test.ts +3 -2
  221. package/src/tests/script-connections.test.ts +2 -0
  222. package/src/tests/seed-model-profiles.test.ts +2 -2
  223. package/src/tests/seed-schedules.test.ts +138 -0
  224. package/src/tests/session-cost-recompute-unit.test.ts +39 -0
  225. package/src/tests/session-costs-golden.test.ts +26 -0
  226. package/src/tests/session-summaries-plans.test.ts +120 -0
  227. package/src/tests/session-summaries.test.ts +72 -12
  228. package/src/tests/sessions-tree.test.ts +11 -3
  229. package/src/tests/slack-leave-thread.test.ts +431 -0
  230. package/src/tests/tool-annotations.test.ts +2 -1
  231. package/src/tests/workflow-engine-v2.test.ts +42 -0
  232. package/src/tests/workflow-script-principal.test.ts +207 -7
  233. package/src/tools/slack-leave-thread.ts +104 -0
  234. package/src/tools/steer-task.ts +3 -2
  235. package/src/tools/tool-config.ts +2 -1
  236. package/src/types.ts +19 -0
  237. package/src/utils/codex-auth-failure.ts +30 -0
  238. package/src/utils/context-window.ts +4 -1
  239. package/src/utils/credentials.ts +7 -0
  240. package/src/utils/fx-models.ts +43 -0
  241. package/src/workflows/cooldown.ts +3 -2
  242. package/src/workflows/engine.ts +24 -9
  243. package/templates/schedules/dreaming-daily/config.json +1 -1
  244. package/templates/schedules/dreaming-daily/content.md +6 -14
  245. package/templates/skills/swarm-docs/content.md +1 -0
  246. package/templates/workflows/dreaming/content.md +5 -1
  247. package/dist/acp-adapter-96dc64m2.js +0 -15
  248. package/dist/handlers-jg7v74ty.js +0 -31
  249. package/dist/index-8c1zbz59.js +0 -29
  250. package/dist/index-8k8tmfxd.js +0 -32
  251. package/dist/index-hq617xdj.js +0 -36
  252. package/dist/lead-567kw7x3.js +0 -68
  253. package/dist/worker-fjdqra6q.js +0 -68
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ChatGPT subscription credential for a pi session that runs an
3
+ * `openai-codex/<id>` model (see `@desplega/model-catalog`'s
4
+ * `chatgpt-subscription.ts`).
5
+ *
6
+ * The runner picks a `codex_oauth_<n>` pool slot exactly as it does for codex
7
+ * (`resolveCodexOAuthCredentialInfo`, same rate-limit bookkeeping) and passes
8
+ * it as `codexSlot`. This revalidates that slot through the locked
9
+ * `getValidCodexOAuth`, the sole path that ever exchanges a pool refresh
10
+ * token, and returns the credential with the refresh token BLANKED. The
11
+ * harness gets an access token it can use but never a refresh token it could
12
+ * rotate outside `/api/oauth/refresh-locks`: OpenAI revokes the whole token
13
+ * family on replay, which would kill the slot for every worker. Same contract
14
+ * as `credentialsToAuthJson(..., { includeRefreshToken: false })` for codex.
15
+ *
16
+ * Accepted cost: a session that outlives the access token errors instead of
17
+ * refreshing. `getValidCodexOAuth` hands out tokens with at least 12 h left.
18
+ */
19
+
20
+ import { chatgptSubscriptionModelId } from "@desplega/model-catalog";
21
+ import { isCodexRejectedAccessError } from "../../utils/codex-auth-failure";
22
+ import { registerVolatileSecret } from "../../utils/secret-scrubber";
23
+ import type { ProviderSessionConfig } from "../types";
24
+ import { buildPoolRevalidationFailureReason, getValidCodexOAuth } from "./storage.js";
25
+ import type { CodexOAuthCredentials } from "./types.js";
26
+
27
+ export async function loadChatgptSubscriptionCredential(
28
+ config: Pick<ProviderSessionConfig, "apiUrl" | "apiKey" | "codexSlot" | "model">,
29
+ deps: { getValid?: typeof getValidCodexOAuth } = {},
30
+ ): Promise<CodexOAuthCredentials> {
31
+ const slot = config.codexSlot;
32
+ if (slot === undefined || !config.apiUrl || !config.apiKey) {
33
+ throw new Error(
34
+ `[auth-error] Model "${config.model}" runs on the ChatGPT subscription pool, but no codex_oauth pool slot is configured. Run codex-login to add one, or pick a model with its own API key.`,
35
+ );
36
+ }
37
+ const getValid = deps.getValid ?? getValidCodexOAuth;
38
+ let creds: CodexOAuthCredentials | null;
39
+ try {
40
+ creds = await getValid(config.apiUrl, config.apiKey, slot);
41
+ } catch (err) {
42
+ throw new Error(buildPoolRevalidationFailureReason(err, slot));
43
+ }
44
+ if (!creds) {
45
+ throw new Error(
46
+ `[auth-error] Codex pool slot ${slot} revalidation failed: no credentials found in config store — the slot may have just been quarantined; re-run codex-login for this slot if this persists.`,
47
+ );
48
+ }
49
+ registerVolatileSecret(creds.access, "CODEX_OAUTH_ACCESS_TOKEN");
50
+ return { ...creds, refresh: "" };
51
+ }
52
+
53
+ /**
54
+ * The `[auth-error]` failure reason for a subscription session whose access
55
+ * the ChatGPT backend rejected mid-run (a login revoked while its access
56
+ * token is unexpired), else undefined. The runner counts it toward the
57
+ * slot's auth-failure bench, as it does codex's turn-level 401.
58
+ */
59
+ export function chatgptSubscriptionAuthFailure(
60
+ raw: string,
61
+ config: Pick<ProviderSessionConfig, "codexSlot" | "model">,
62
+ ): string | undefined {
63
+ const slot = config.codexSlot;
64
+ if (slot === undefined || !chatgptSubscriptionModelId(config.model)) return undefined;
65
+ if (!isCodexRejectedAccessError(raw)) return undefined;
66
+ return `[auth-error] Codex pool slot ${slot} authentication failed — re-run agent-swarm codex-login --slot ${slot}. Original error: ${raw}`;
67
+ }
@@ -0,0 +1,211 @@
1
+ // Bun has no rename, chmod or mkdir API; file reads and writes use Bun.file/Bun.write.
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, rename, rm } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { getKnownContextWindowSize } from "../utils/context-window";
6
+ import { FX_ROUTE_KEY_VARS, type ResolvedFxRoute, requireFxRoute } from "../utils/fx-models";
7
+ import { getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
8
+ import { registerVolatileSecret } from "../utils/secret-scrubber";
9
+ import { ACPAdapter } from "./acp-adapter";
10
+ import { fxSessionExists, fxTargetProfile } from "./acp-targets";
11
+ import type {
12
+ CredStatus,
13
+ ProviderAdapter,
14
+ ProviderSession,
15
+ ProviderSessionConfig,
16
+ ProviderTraits,
17
+ } from "./types";
18
+
19
+ /**
20
+ * The fx release the worker image installs (`ARG FX_VERSION` in
21
+ * Dockerfile.worker; bump the two together). Every ACP shape this adapter
22
+ * relies on was measured against it.
23
+ */
24
+ export const FX_PINNED_VERSION = "0.0.15";
25
+
26
+ /** OpenAI's Chat Completions prefix; fx appends `/chat/completions`. */
27
+ export const FX_OPENAI_BASE_URL = "https://api.openai.com/v1";
28
+
29
+ /**
30
+ * fx (Vercel Labs' terminal coding agent) on the swarm's ACP client: `fx acp`
31
+ * is an ACP server, so spawn, swarm MCP, permissions, event translation and
32
+ * cost come from {@link ACPAdapter} with {@link fxTargetProfile}. This adapter
33
+ * resolves the model route, checks its key and seeds `~/.fx/settings.json`.
34
+ *
35
+ * Routes (`src/utils/fx-models.ts`): `openrouter/<vendor>/<id>` on
36
+ * OPENROUTER_API_KEY, `vercel/<vendor>/<id>` on AI_GATEWAY_API_KEY, a bare
37
+ * OpenAI id on OPENAI_API_KEY.
38
+ */
39
+ export function checkFxCredentials(env: Record<string, string | undefined>): CredStatus {
40
+ return FX_ROUTE_KEY_VARS.some((name) => env[name]?.trim())
41
+ ? { ready: true, missing: [], satisfiedBy: "env" }
42
+ : {
43
+ ready: false,
44
+ missing: [...FX_ROUTE_KEY_VARS],
45
+ hint: "Set OPENROUTER_API_KEY (openrouter/<vendor>/<id>), OPENAI_API_KEY (bare OpenAI ids) or AI_GATEWAY_API_KEY (vercel/<vendor>/<id>) for fx.",
46
+ };
47
+ }
48
+
49
+ type JsonObject = Record<string, unknown>;
50
+
51
+ function isJsonObject(value: unknown): value is JsonObject {
52
+ return !!value && typeof value === "object" && !Array.isArray(value);
53
+ }
54
+
55
+ /** An fx custom connection: OpenAI Chat Completions with a bearer key read from `keyVar`. */
56
+ function connection(baseUrl: string, keyVar: string): JsonObject {
57
+ return {
58
+ protocol: "openai-chat-completions",
59
+ base_url: baseUrl,
60
+ auth: { type: "bearer", env: keyVar },
61
+ };
62
+ }
63
+
64
+ export interface FxSettingsInput {
65
+ /** The home dir whose `.fx/settings.json` fx reads (fx has no config-dir override). */
66
+ home: string;
67
+ route: ResolvedFxRoute;
68
+ /** The model's context window, registered so fx reports usage and compacts on time. */
69
+ contextWindow?: number;
70
+ openRouterBaseUrl: string;
71
+ }
72
+
73
+ /**
74
+ * `~/.fx/settings.json` with the swarm-owned keys merged in:
75
+ * - `providers.openrouter` and `providers.openai`: custom connections that
76
+ * name the key's env var (never its value). Fields the swarm does not own
77
+ * (`model_metadata`, `tool_choice_mode`, ...) are kept.
78
+ * - `model_metadata[<id>].context_window` for the session's model on its
79
+ * connection, when the catalog knows it and the file has no entry: without
80
+ * a window fx sends no `usage_update` and cannot time compaction.
81
+ * - `auto_upgrade: false` (the image pins fx) and `session_titles: false`
82
+ * (a title is an extra, unmetered model call).
83
+ * Every other key is preserved. Pure.
84
+ */
85
+ export function mergeFxSettings(
86
+ current: JsonObject,
87
+ input: Omit<FxSettingsInput, "home">,
88
+ ): JsonObject {
89
+ const next: JsonObject = structuredClone(current);
90
+ const providers: JsonObject = isJsonObject(next.providers) ? next.providers : {};
91
+ const owned: Record<string, JsonObject> = {
92
+ openrouter: connection(input.openRouterBaseUrl, "OPENROUTER_API_KEY"),
93
+ openai: connection(FX_OPENAI_BASE_URL, "OPENAI_API_KEY"),
94
+ };
95
+ for (const [name, fields] of Object.entries(owned)) {
96
+ const existing = isJsonObject(providers[name]) ? providers[name] : {};
97
+ providers[name] = { ...existing, ...fields };
98
+ }
99
+ const { fxProvider, fxModel } = input.route;
100
+ const target = fxProvider === "gateway" ? undefined : (providers[fxProvider] as JsonObject);
101
+ if (target && input.contextWindow && input.contextWindow > 0) {
102
+ const metadata = isJsonObject(target.model_metadata) ? target.model_metadata : {};
103
+ if (!isJsonObject(metadata[fxModel])) {
104
+ metadata[fxModel] = { context_window: input.contextWindow };
105
+ }
106
+ target.model_metadata = metadata;
107
+ }
108
+ next.providers = providers;
109
+ next.auto_upgrade = false;
110
+ next.session_titles = false;
111
+ return next;
112
+ }
113
+
114
+ /**
115
+ * Merge the swarm's keys into `<home>/.fx/settings.json` ({@link mergeFxSettings}).
116
+ * Idempotent: an unchanged file is not rewritten. Atomic: the new content goes
117
+ * to a temp file in the same dir, then replaces the old one with a rename, so
118
+ * a concurrent fx never reads half a file. A file that is not a JSON object is
119
+ * an error, not something to overwrite.
120
+ */
121
+ export async function ensureFxSettings(input: FxSettingsInput): Promise<{
122
+ path: string;
123
+ changed: boolean;
124
+ }> {
125
+ const path = join(input.home, ".fx", "settings.json");
126
+ const file = Bun.file(path);
127
+ let current: JsonObject = {};
128
+ if (await file.exists()) {
129
+ const text = await file.text();
130
+ if (text.trim()) {
131
+ let parsed: unknown;
132
+ try {
133
+ parsed = JSON.parse(text);
134
+ } catch {
135
+ throw new Error(`${path} is not valid JSON; fix or remove it before running fx`);
136
+ }
137
+ if (!isJsonObject(parsed)) {
138
+ throw new Error(`${path} is not a JSON object; fix or remove it before running fx`);
139
+ }
140
+ current = parsed;
141
+ }
142
+ }
143
+ const next = mergeFxSettings(current, input);
144
+ if (JSON.stringify(next) === JSON.stringify(current)) return { path, changed: false };
145
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
146
+ const tmp = `${path}.swarm-${process.pid}-${randomUUID()}.tmp`;
147
+ try {
148
+ await Bun.write(tmp, `${JSON.stringify(next, null, 2)}\n`);
149
+ await chmod(tmp, 0o600);
150
+ await rename(tmp, path);
151
+ } catch (error) {
152
+ await rm(tmp, { force: true });
153
+ throw error;
154
+ }
155
+ return { path, changed: true };
156
+ }
157
+
158
+ export interface FxAdapterOptions {
159
+ /** The home dir fx runs under. Defaults to the session's `HOME`. Tests inject a temp dir. */
160
+ home?: string;
161
+ }
162
+
163
+ export class FxAdapter implements ProviderAdapter {
164
+ readonly name = "fx";
165
+
166
+ readonly traits: ProviderTraits = {
167
+ hasMcp: true,
168
+ // fx defers swarm tools behind its own `capability_search`; the prompt
169
+ // uses the fx-specific discovery line, not the generic tool-search one.
170
+ hasToolSearch: false,
171
+ // `~/.claude/skills` is a native fx skill root (verified: a planted skill loaded).
172
+ nativeSkillDiscovery: true,
173
+ hasLocalEnvironment: true,
174
+ steerModes: ["steer"],
175
+ };
176
+
177
+ private readonly acp = new ACPAdapter({ providerName: "fx", target: fxTargetProfile });
178
+
179
+ constructor(private readonly options: FxAdapterOptions = {}) {}
180
+
181
+ async createSession(sessionConfig: ProviderSessionConfig): Promise<ProviderSession> {
182
+ const route = requireFxRoute(sessionConfig.model);
183
+ const env = { ...process.env, ...sessionConfig.env };
184
+ const apiKey = env[route.keyVar]?.trim();
185
+ if (!apiKey) throw new Error(`fx requires ${route.keyVar} for ${route.model}`);
186
+ registerVolatileSecret(apiKey, route.keyVar);
187
+ const home = this.options.home ?? env.HOME;
188
+ if (!home) throw new Error("fx requires HOME: it keeps its settings and sessions in ~/.fx");
189
+
190
+ await ensureFxSettings({
191
+ home,
192
+ route,
193
+ contextWindow:
194
+ route.fxProvider === "gateway" ? undefined : getKnownContextWindowSize(route.model),
195
+ openRouterBaseUrl: getOpenRouterBaseUrl(env),
196
+ });
197
+ return this.acp.createSession({
198
+ ...sessionConfig,
199
+ model: route.model,
200
+ env: { ...sessionConfig.env, [route.keyVar]: apiKey, HOME: home },
201
+ });
202
+ }
203
+
204
+ async canResume(sessionId: string): Promise<boolean> {
205
+ return fxSessionExists(sessionId, this.options.home ?? process.env.HOME);
206
+ }
207
+
208
+ formatCommand(commandName: string): string {
209
+ return `/${commandName}`;
210
+ }
211
+ }
@@ -63,13 +63,17 @@ export async function createProviderAdapter(provider: string): Promise<ProviderA
63
63
  const { GrokAdapter } = await import("./grok-adapter");
64
64
  return new GrokAdapter();
65
65
  }
66
+ case "fx": {
67
+ const { FxAdapter } = await import("./fx-adapter");
68
+ return new FxAdapter();
69
+ }
66
70
  case "acp": {
67
71
  const { ACPAdapter } = await import("./acp-adapter");
68
72
  return new ACPAdapter();
69
73
  }
70
74
  default:
71
75
  throw new Error(
72
- `Unknown HARNESS_PROVIDER: "${provider}". Supported: claude, pi, codex, devin, claude-managed, opencode, acp, dsh, cursor, amp, grok`,
76
+ `Unknown HARNESS_PROVIDER: "${provider}". Supported: claude, pi, codex, devin, claude-managed, opencode, acp, dsh, cursor, amp, grok, fx`,
73
77
  );
74
78
  }
75
79
  }
@@ -0,0 +1,94 @@
1
+ import { scrubSecrets } from "../utils/secret-scrubber";
2
+
3
+ /**
4
+ * A 127.0.0.1 forwarder in front of a plain-HTTP MCP endpoint.
5
+ *
6
+ * Some ACP targets (fx) accept an MCP server URL only when it is HTTPS or an
7
+ * explicit loopback HTTP endpoint, and reject `http://api:3013/mcp` with
8
+ * `-32602`. The forwarder listens on an ephemeral loopback port and streams
9
+ * every request and response (SSE included) to `upstream`, so the target sees
10
+ * `http://127.0.0.1:<port>/…`. Worker-side HTTP only: no DB access.
11
+ *
12
+ * Header values are never logged; error text goes through `scrubSecrets`.
13
+ */
14
+ export interface McpLoopbackProxy {
15
+ /** `http://127.0.0.1:<port>`, no trailing slash. Append the upstream-relative path. */
16
+ readonly url: string;
17
+ stop(): Promise<void>;
18
+ }
19
+
20
+ /** Hop-by-hop headers a proxy must not forward, plus `host` (the upstream's own). */
21
+ const DROPPED_REQUEST_HEADERS = [
22
+ "host",
23
+ "connection",
24
+ "keep-alive",
25
+ "proxy-connection",
26
+ "transfer-encoding",
27
+ "upgrade",
28
+ ];
29
+
30
+ /** Hosts fx treats as explicit loopback (fx 0.0.15: `127.0.0.2` is rejected). */
31
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
32
+
33
+ /** True when `url` is plain `http://` to a host that is not an explicit loopback name. */
34
+ export function needsLoopbackProxy(url: string): boolean {
35
+ let parsed: URL;
36
+ try {
37
+ parsed = new URL(url);
38
+ } catch {
39
+ return false;
40
+ }
41
+ return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase());
42
+ }
43
+
44
+ /**
45
+ * Start a forwarder to `upstream` (a base URL; its path is kept as a prefix).
46
+ * A request for `/mcp?x=1` goes to `<upstream>/mcp?x=1`.
47
+ */
48
+ export async function startMcpLoopbackProxy(upstream: string): Promise<McpLoopbackProxy> {
49
+ const base = new URL(upstream);
50
+ const prefix = base.pathname.replace(/\/+$/, "");
51
+ const server = Bun.serve({
52
+ hostname: "127.0.0.1",
53
+ port: 0,
54
+ // SSE responses stay open with no traffic between events.
55
+ idleTimeout: 0,
56
+ async fetch(req) {
57
+ const incoming = new URL(req.url);
58
+ const target = new URL(base);
59
+ target.pathname = `${prefix}${incoming.pathname}`;
60
+ target.search = incoming.search;
61
+ const headers = new Headers(req.headers);
62
+ for (const name of DROPPED_REQUEST_HEADERS) headers.delete(name);
63
+ try {
64
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
65
+ return await fetch(target, {
66
+ method: req.method,
67
+ headers,
68
+ body: hasBody ? req.body : undefined,
69
+ redirect: "manual",
70
+ signal: req.signal,
71
+ // Pass bytes through untouched: decompressing here would leave a
72
+ // stale content-encoding header on the forwarded response.
73
+ decompress: false,
74
+ });
75
+ } catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ return new Response(
78
+ JSON.stringify({
79
+ error: scrubSecrets(`MCP loopback proxy: upstream failed: ${message}`),
80
+ }),
81
+ { status: 502, headers: { "Content-Type": "application/json" } },
82
+ );
83
+ }
84
+ },
85
+ });
86
+ let stopped: Promise<void> | null = null;
87
+ return {
88
+ url: `http://127.0.0.1:${server.port}`,
89
+ stop() {
90
+ stopped ??= server.stop(true);
91
+ return stopped;
92
+ },
93
+ };
94
+ }
@@ -18,6 +18,8 @@ import { mkdtemp, rm } from "node:fs/promises";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import {
21
+ CHATGPT_SUBSCRIPTION_PROVIDER,
22
+ chatgptSubscriptionModelId,
21
23
  ignoredBaseUrlVar,
22
24
  type OpenAICompatibleProvider,
23
25
  openAICompatibleKeyVars,
@@ -29,6 +31,7 @@ import {
29
31
  providerKeyFromEnv,
30
32
  providerKeyVars,
31
33
  } from "@desplega/model-catalog";
34
+ import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
32
35
  import {
33
36
  getBuiltinModel as getModel,
34
37
  getBuiltinModels as getModels,
@@ -71,6 +74,12 @@ import { swarmRuntimeInstanceId } from "../utils/multi-runtime";
71
74
  import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
72
75
  import { snapshotCatalogRows } from "../utils/runtime-model-catalog";
73
76
  import { registerEnvSecrets, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
77
+ import { hasCodexOAuthPoolSlots } from "./codex-oauth/env-keys.js";
78
+ import {
79
+ chatgptSubscriptionAuthFailure,
80
+ loadChatgptSubscriptionCredential,
81
+ } from "./codex-oauth/subscription.js";
82
+ import type { CodexOAuthCredentials } from "./codex-oauth/types.js";
74
83
  import { readPkgVersion } from "./harness-version";
75
84
  import { createSwarmHooksExtension } from "./pi-mono-extension";
76
85
  import { McpHttpClient, type McpTool } from "./pi-mono-mcp-client";
@@ -328,6 +337,17 @@ export async function checkPiMonoCredentials(
328
337
  }
329
338
  }
330
339
 
340
+ // A ChatGPT subscription model runs on the codex_oauth pool only: the
341
+ // session gets a per-task in-memory credential, never auth.json or a key.
342
+ if (chatgptSubscriptionModelId(env.MODEL_OVERRIDE)) {
343
+ if (hasCodexOAuthPoolSlots(env)) return { ready: true, missing: [], satisfiedBy: "env" };
344
+ return {
345
+ ready: false,
346
+ missing: ["CODEX_OAUTH"],
347
+ hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} runs on the ChatGPT subscription pool; add a codex_oauth slot (codex-login) or pick a model with its own API key.`,
348
+ };
349
+ }
350
+
331
351
  const homeDir = opts.homeDir ?? env.HOME ?? "/root";
332
352
  const fsProbe = opts.fs?.existsSync ?? existsSync;
333
353
  const authFile = `${homeDir}/.pi/agent/auth.json`;
@@ -351,11 +371,13 @@ export async function checkPiMonoCredentials(
351
371
  // Permissive case: any one supported key works. A registry provider key
352
372
  // (NaN, Helmcode) only serves `<id>/*` models, but it is still a usable pi
353
373
  // credential (the task's explicit model is checked above once it is set).
374
+ // The codex_oauth pool likewise serves only `openai-codex/*` models.
354
375
  if (
355
376
  env.ANTHROPIC_API_KEY ||
356
377
  env.OPENROUTER_API_KEY ||
357
378
  env.OPENAI_API_KEY ||
358
- openAICompatibleKeyVars().some((k) => env[k])
379
+ openAICompatibleKeyVars().some((k) => env[k]) ||
380
+ hasCodexOAuthPoolSlots(env)
359
381
  ) {
360
382
  return { ready: true, missing: [], satisfiedBy: "env" };
361
383
  }
@@ -518,12 +540,16 @@ const PI_RUNTIME_API_KEYS: readonly (readonly [envKey: string, provider: string]
518
540
  export async function createPiRuntimeAuth(
519
541
  env: Record<string, string | undefined> = process.env,
520
542
  modelsPath?: string,
543
+ subscription?: CodexOAuthCredentials,
521
544
  ): Promise<ModelRuntime> {
522
545
  // A per-session models.json (`writeSessionModelsJson`) still shares the
523
546
  // agent dir's catalog cache, which only holds built-in provider catalogs.
524
- const modelRuntime = await ModelRuntime.create(
525
- modelsPath ? { modelsPath, modelsStorePath: join(getAgentDir(), "models-store.json") } : {},
526
- );
547
+ const modelRuntime = await ModelRuntime.create({
548
+ ...(modelsPath
549
+ ? { modelsPath, modelsStorePath: join(getAgentDir(), "models-store.json") }
550
+ : {}),
551
+ ...(subscription ? { credentials: await subscriptionCredentialStore(subscription) } : {}),
552
+ });
527
553
  for (const [envKey, provider] of PI_RUNTIME_API_KEYS) {
528
554
  const apiKey = env[envKey];
529
555
  if (apiKey) {
@@ -534,6 +560,28 @@ export async function createPiRuntimeAuth(
534
560
  return modelRuntime;
535
561
  }
536
562
 
563
+ /**
564
+ * A ChatGPT subscription session's credential store: in memory, per session,
565
+ * holding only the pool slot's `openai-codex` OAuth entry with the refresh
566
+ * token blanked (`loadChatgptSubscriptionCredential`). It replaces
567
+ * `~/.pi/agent/auth.json` for this session, so nothing reaches disk and
568
+ * concurrent sessions on different slots never share an entry. A refresh pi
569
+ * attempts fails on the empty token and stays in memory.
570
+ */
571
+ async function subscriptionCredentialStore(
572
+ creds: CodexOAuthCredentials,
573
+ ): Promise<InMemoryCredentialStore> {
574
+ const store = new InMemoryCredentialStore();
575
+ await store.modify(CHATGPT_SUBSCRIPTION_PROVIDER, async () => ({
576
+ type: "oauth",
577
+ access: creds.access,
578
+ refresh: "",
579
+ expires: creds.expires,
580
+ accountId: creds.accountId,
581
+ }));
582
+ return store;
583
+ }
584
+
537
585
  /**
538
586
  * Legacy sidecar marker. Earlier versions wrote the OpenRouter gateway into
539
587
  * the shared `<agentDir>/models.json` and recorded here the `baseUrl` they
@@ -783,6 +831,9 @@ function resolveModelInner(
783
831
  ) {
784
832
  if (!modelStr) return undefined;
785
833
 
834
+ const subscriptionId = chatgptSubscriptionModelId(modelStr);
835
+ if (subscriptionId) return resolveSubscriptionModel(subscriptionId, modelRuntime);
836
+
786
837
  const lower = modelStr.toLowerCase();
787
838
  const isAnthropicShortname =
788
839
  lower === "opus" || lower === "sonnet" || lower === "haiku" || lower === "fable";
@@ -847,6 +898,24 @@ function resolveModelInner(
847
898
  return undefined;
848
899
  }
849
900
 
901
+ /**
902
+ * `openai-codex/<id>`: pi-ai's ChatGPT backend provider. Its builtin list
903
+ * trails the OpenAI catalog ids the picker offers (the codex harness list), so
904
+ * an id it does not ship reuses a shipped model's transport settings under the
905
+ * requested id. The ChatGPT backend rejects an id the subscription does not
906
+ * serve, which fails the task with the upstream error.
907
+ */
908
+ function resolveSubscriptionModel(
909
+ modelId: string,
910
+ modelRuntime: Pick<ModelRuntime, "getModel"> | undefined,
911
+ ) {
912
+ const provider = CHATGPT_SUBSCRIPTION_PROVIDER as "openai-codex";
913
+ const known = getModel(provider, modelId as never) ?? modelRuntime?.getModel(provider, modelId);
914
+ if (known) return known;
915
+ const template = getModels(provider)[0];
916
+ return template ? { ...template, id: modelId, name: modelId } : undefined;
917
+ }
918
+
850
919
  /** Manage AGENTS.md symlink for pi-mono CLAUDE.md compatibility */
851
920
  function createAgentsMdSymlink(cwd: string): boolean {
852
921
  const claudeMd = join(cwd, "CLAUDE.md");
@@ -1301,6 +1370,14 @@ export class PiMonoSession implements ProviderSession {
1301
1370
  }
1302
1371
  }
1303
1372
 
1373
+ /** An AWS SDK failure, or a rejected ChatGPT subscription login. */
1374
+ private classifyFailure(raw: string): { message: string; category: string } | undefined {
1375
+ const aws = classifyAwsSdkError(raw);
1376
+ if (aws) return aws;
1377
+ const auth = chatgptSubscriptionAuthFailure(raw, this.config);
1378
+ return auth ? { message: auth, category: "authentication_failed" } : undefined;
1379
+ }
1380
+
1304
1381
  private async runSession(): Promise<ProviderResult> {
1305
1382
  try {
1306
1383
  // Pi expands a leading `/skill:name` itself, with no tool call to observe.
@@ -1333,7 +1410,7 @@ export class PiMonoSession implements ProviderSession {
1333
1410
  // exactly like sibling adapters. AWS errors get a categorized, actionable
1334
1411
  // message; anything else surfaces its raw error text.
1335
1412
  if (this.terminalError) {
1336
- const classification = classifyAwsSdkError(this.terminalError);
1413
+ const classification = this.classifyFailure(this.terminalError);
1337
1414
  const message = scrubSecrets(classification?.message ?? this.terminalError);
1338
1415
  const category = classification?.category;
1339
1416
  this.emit({ type: "error", message, category });
@@ -1385,13 +1462,14 @@ export class PiMonoSession implements ProviderSession {
1385
1462
  // Defense-in-depth: AWS SDK failures surface as structured events (handled
1386
1463
  // above in runSession), not thrown exceptions, so this catch is for genuine
1387
1464
  // unexpected throws (MCP / transport / etc). Still classify in case an AWS
1388
- // signature ever reaches here, so the red box fires like sibling adapters.
1389
- const awsCatchError = classifyAwsSdkError(errorMessage);
1390
- if (awsCatchError) {
1465
+ // signature or a rejected subscription login ever reaches here, so the
1466
+ // red box fires like sibling adapters.
1467
+ const classifiedError = this.classifyFailure(errorMessage);
1468
+ if (classifiedError) {
1391
1469
  this.emit({
1392
1470
  type: "error",
1393
- message: awsCatchError.message,
1394
- category: awsCatchError.category,
1471
+ message: classifiedError.message,
1472
+ category: classifiedError.category,
1395
1473
  });
1396
1474
  }
1397
1475
  this.emit({ type: "raw_stderr", content: `[pi-mono] Error: ${errorMessage}\n` });
@@ -1402,8 +1480,8 @@ export class PiMonoSession implements ProviderSession {
1402
1480
  sessionId: this._sessionId,
1403
1481
  cost,
1404
1482
  isError: true,
1405
- errorCategory: awsCatchError?.category,
1406
- failureReason: scrubSecrets(awsCatchError?.message ?? errorMessage),
1483
+ errorCategory: classifiedError?.category,
1484
+ failureReason: scrubSecrets(classifiedError?.message ?? errorMessage),
1407
1485
  appliedReasoningEffort: this.appliedReasoningEffort,
1408
1486
  };
1409
1487
  } finally {
@@ -1992,7 +2070,14 @@ export class PiMonoAdapter implements ProviderAdapter {
1992
2070
  const { createdSymlink, customTools, deferTools, piMcpServers } = setup;
1993
2071
  // The runtime is created before resolving so custom providers, which only
1994
2072
  // exist in its composed store, can resolve.
1995
- const modelRuntime = await createPiRuntimeAuth(sessionEnv, sessionModels.modelsPath);
2073
+ const subscription = chatgptSubscriptionModelId(config.model)
2074
+ ? await loadChatgptSubscriptionCredential(config)
2075
+ : undefined;
2076
+ const modelRuntime = await createPiRuntimeAuth(
2077
+ sessionEnv,
2078
+ sessionModels.modelsPath,
2079
+ subscription,
2080
+ );
1996
2081
  const resolvedModel = resolveModel(config.model, sessionEnv, modelRuntime);
1997
2082
  // models.json overrides (e.g. the OpenRouter gateway baseUrl) only apply
1998
2083
  // to models resolved through the runtime's composed store; the builtin
@@ -78,6 +78,14 @@ rate by hand should also update this file.
78
78
  what xAI billed (`_meta.usage.costUsdTicks`), which wins over these rows
79
79
  as for amp. OpenRouter models also project under `provider='grok'` for
80
80
  `openrouter/<id>` models, which report no USD.
81
+ - fx (`provider='fx'`) reports tokens and no USD, so every fx row is
82
+ priced from the table (`costSource: "pricing-table"`). The lookup strips
83
+ the routing prefix (`src/be/pricing-normalize.ts`): an
84
+ `openrouter/<vendor>/<id>` model prices at OpenRouter's `<vendor>/<id>`
85
+ rate, a `vercel/<vendor>/<id>` model at the AI Gateway `<vendor>/<id>`
86
+ rate, a bare id at OpenAI's rate. fx reports no cache split, so every
87
+ input token prices at the uncached rate, and its counts exclude
88
+ sub-agent and helper-model calls: the figure is an estimate.
81
89
  - OpenAI, Anthropic, Google and xAI models (bare vendor ids) → rows under
82
90
  `provider='cursor'`. Cursor bills the vendor's API rates and reports the
83
91
  vendor's own id. The adapter subtracts cache reads and writes from SDK