@desplega.ai/agent-swarm 2.7.6 → 2.7.7

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 (244) hide show
  1. package/dist/{acp-adapter-bp91d37f.js → acp-adapter-7b4vk3h5.js} +5 -4
  2. package/dist/{actions-yj0eeaqh.js → actions-3tt153af.js} +18 -18
  3. package/dist/{amp-adapter-4pg9h34q.js → amp-adapter-njpts0yr.js} +1 -1
  4. package/dist/{anthropic-messages-x4q6rfq5.js → anthropic-messages-xey2skwx.js} +7 -7
  5. package/dist/{app-909e97cr.js → app-nky4378f.js} +3 -3
  6. package/dist/{assistant-qdyb5fyy.js → assistant-jnb669pa.js} +19 -19
  7. package/dist/{azure-openai-responses-s1w5n70z.js → azure-openai-responses-26t7j9ra.js} +5 -5
  8. package/dist/{boot-reembed-fk0s9z18.js → boot-reembed-9mgvaztt.js} +1 -1
  9. package/dist/{boot-reembed-gs1rzrnh.js → boot-reembed-qs2djdej.js} +1 -1
  10. package/dist/{boot-scrub-sweep-4qfd4kfx.js → boot-scrub-sweep-yjpdj7s1.js} +3 -3
  11. package/dist/{boot-seeds-yr00k13d.js → boot-seeds-5cm6ambd.js} +4 -4
  12. package/dist/{boxd-vdmfssd9.js → boxd-nv507va8.js} +1 -1
  13. package/dist/{claude-adapter-qta1f1xn.js → claude-adapter-fn287zmm.js} +5 -4
  14. package/dist/{claude-managed-setup-sc6j4dm0.js → claude-managed-setup-wtdz2mj2.js} +1 -1
  15. package/dist/{claude-sdk-session-zgrz6yvc.js → claude-sdk-session-x91kqyhm.js} +8288 -8224
  16. package/dist/{cli-wp9sp1qb.js → cli-08bmz9z9.js} +1 -1
  17. package/dist/{cli-yy3rhsdw.js → cli-1c6tzt8g.js} +7 -7
  18. package/dist/{cli-q0k1w5zd.js → cli-35rsp8ye.js} +1 -1
  19. package/dist/{cli-xnrwazav.js → cli-3fg0bdcv.js} +1 -1
  20. package/dist/{cli-89knwags.js → cli-5b456bpf.js} +2 -2
  21. package/dist/{cli-drr5v6av.js → cli-5jnhv72d.js} +1 -1
  22. package/dist/{cli-dxae2yg7.js → cli-5zx699j9.js} +2 -2
  23. package/dist/{cli-0at8w34w.js → cli-6g0hb4cp.js} +8 -8
  24. package/dist/{cli-e3r2neck.js → cli-6pfd54vx.js} +5 -5
  25. package/dist/{cli-bsn8nhrq.js → cli-6vcp76qk.js} +3 -3
  26. package/dist/{cli-ekwks2vs.js → cli-72v799xn.js} +1 -1
  27. package/dist/cli-82j36f2m.js +45 -0
  28. package/dist/{cli-yx4kfngt.js → cli-8305gbq0.js} +29 -16
  29. package/dist/{cli-4necwtsv.js → cli-8sckt74b.js} +8 -8
  30. package/dist/{cli-vvpx3gsx.js → cli-95jzxcp3.js} +1 -1
  31. package/dist/{cli-hr2e2fft.js → cli-adeegh7d.js} +7 -7
  32. package/dist/{cli-m48g6p95.js → cli-afkqkrtw.js} +3 -3
  33. package/dist/{cli-0qatpy8q.js → cli-ayayvfg4.js} +24 -4
  34. package/dist/{cli-bqt1mptt.js → cli-az48n0nr.js} +4 -4
  35. package/dist/{cli-zm72anhg.js → cli-bfv7vdg6.js} +9 -9
  36. package/dist/{cli-hgaazk69.js → cli-caj33vcq.js} +5 -5
  37. package/dist/{cli-3ypk9vk1.js → cli-cb2a8pna.js} +1 -1
  38. package/dist/{cli-waqbxg08.js → cli-cppkf404.js} +17 -17
  39. package/dist/{cli-yn4kxs06.js → cli-ddaanqkd.js} +4 -4
  40. package/dist/{cli-386tyxxg.js → cli-e0kmphd6.js} +2 -2
  41. package/dist/{cli-54t00gt8.js → cli-e46hg0xw.js} +1 -1
  42. package/dist/{cli-jm2vfpfd.js → cli-e5bzrn01.js} +1 -1
  43. package/dist/{cli-436532ck.js → cli-excnnjg2.js} +165 -153
  44. package/dist/{cli-71ptav7x.js → cli-ferkkrg9.js} +1 -1
  45. package/dist/{cli-vpf89f34.js → cli-fmjd0t1y.js} +3 -3
  46. package/dist/{cli-pd7vagab.js → cli-fyt2g4wd.js} +3 -3
  47. package/dist/{cli-gdhpcbhf.js → cli-g08zdaec.js} +1 -1
  48. package/dist/{cli-ehq736cn.js → cli-g7s47cc5.js} +4 -4
  49. package/dist/{cli-svj5090z.js → cli-gz6q4eve.js} +3 -3
  50. package/dist/{cli-8snvgs5r.js → cli-je518txm.js} +10 -10
  51. package/dist/{cli-rmmmrjkd.js → cli-jtjtabxe.js} +1 -1
  52. package/dist/{cli-611a1294.js → cli-k0wwfqbg.js} +18 -1
  53. package/dist/{cli-es6va8jk.js → cli-k1nje3v3.js} +38 -43
  54. package/dist/{cli-p5gp8pcx.js → cli-k8196sh2.js} +6 -6
  55. package/dist/{cli-nwj0ee2w.js → cli-k899ehvh.js} +2 -2
  56. package/dist/{cli-decfb6v8.js → cli-m0da8j35.js} +23 -9
  57. package/dist/{cli-he9wchme.js → cli-mkk9rnzz.js} +4 -4
  58. package/dist/{cli-nhrvpaj7.js → cli-n6bv12jn.js} +510 -4
  59. package/dist/{cli-8bc1w0tw.js → cli-nmzhp1b5.js} +14 -12
  60. package/dist/{cli-0yv5z2pg.js → cli-p82cnnjq.js} +1 -1
  61. package/dist/{cli-c60vetmm.js → cli-pc6n7fre.js} +1 -1
  62. package/dist/{cli-xds5f77d.js → cli-pjctyea3.js} +2 -2
  63. package/dist/{cli-xbbndvjy.js → cli-pq73h5mz.js} +3 -3
  64. package/dist/{cli-aa6yxc7m.js → cli-pz4rk021.js} +1 -1
  65. package/dist/{cli-xnqgpshy.js → cli-q3tkegby.js} +1 -1
  66. package/dist/cli-qghe0h66.js +67 -0
  67. package/dist/{cli-zbbwez0r.js → cli-qh4d4tvk.js} +4 -4
  68. package/dist/{cli-w6dt9ged.js → cli-rtt8g4v9.js} +1 -1
  69. package/dist/{cli-1h3wcpbw.js → cli-snpj5zbj.js} +2 -2
  70. package/dist/{cli-a7v854rf.js → cli-tgrv9cjx.js} +263 -12
  71. package/dist/{cli-faxe6thg.js → cli-vz22fyqv.js} +186 -54
  72. package/dist/{cli-k2gzwsj2.js → cli-wk5yrfwd.js} +160 -26
  73. package/dist/{cli-shpbx5ee.js → cli-ws19768n.js} +1641 -49
  74. package/dist/{cli-70r77n04.js → cli-xae4g51r.js} +1 -1
  75. package/dist/{cli-kqdg1bsb.js → cli-yndmfxb3.js} +2 -2
  76. package/dist/{cli-bjb3z51z.js → cli-yyj3j5m4.js} +2 -2
  77. package/dist/cli.js +21 -21
  78. package/dist/{cloudflare-workers-ai-system-one-bm60fjm0.js → cloudflare-workers-ai-system-one-ef2a980q.js} +2 -2
  79. package/dist/{codex-adapter-ayd1fb6h.js → codex-adapter-cf2yxe9m.js} +3 -2
  80. package/dist/{codex-hook-9efm2x1m.js → codex-hook-a0c6g6t6.js} +1 -1
  81. package/dist/{codex-session-runner-jesa32xc.js → codex-session-runner-6vfzdgbd.js} +3 -2
  82. package/dist/{commands-hanewgf2.js → commands-t3mfy7vn.js} +3 -3
  83. package/dist/{controller-dt1n3c1q.js → controller-q7xrbaz0.js} +3 -3
  84. package/dist/{controllers-connect-xx8x64n5.js → controllers-connect-c6nm70kg.js} +19 -19
  85. package/dist/{cursor-adapter-9s4dwwdw.js → cursor-adapter-ftd81ga8.js} +1 -1
  86. package/dist/{db-3vttcbv4.js → db-dk572x25.js} +3 -3
  87. package/dist/{db-9dv0g1ab.js → db-vemtpmcc.js} +4 -2
  88. package/dist/{dispatcher-tx9hd1sv.js → dispatcher-kh7ecxh9.js} +17 -17
  89. package/dist/{dsh-adapter-007y4ejs.js → dsh-adapter-v8628r89.js} +1 -1
  90. package/dist/{e2b-we6hs6q5.js → e2b-fr12yccx.js} +1 -1
  91. package/dist/{e2b-a19d28rz.js → e2b-x6ywzak3.js} +1 -1
  92. package/dist/{easter-egg-3d-rgbrqb8s.js → easter-egg-3d-nenw4x63.js} +1 -1
  93. package/dist/{embedding-space-ny1e2940.js → embedding-space-0jepb239.js} +6 -6
  94. package/dist/{embeddings-2t386t13.js → embeddings-xxt49yxg.js} +1 -1
  95. package/dist/{execute-pksh0ceh.js → execute-1mhw6e4x.js} +1 -1
  96. package/dist/{external-channel-phgmhmd7.js → external-channel-55v12e60.js} +2 -2
  97. package/dist/{fx-adapter-fy8rr921.js → fx-adapter-bs8wqrk5.js} +6 -5
  98. package/dist/{google-generative-ai-8y3m3k4b.js → google-generative-ai-v94hr7h0.js} +5 -5
  99. package/dist/{google-vertex-w3v98n7b.js → google-vertex-g7vr6cf8.js} +5 -5
  100. package/dist/{grok-adapter-q0abvpaj.js → grok-adapter-gpjqmkcq.js} +6 -5
  101. package/dist/handlers-zxmp1ta4.js +32 -0
  102. package/dist/{heartbeat-kadyek8p.js → heartbeat-k42svnr0.js} +20 -20
  103. package/dist/{hook-9k551xnj.js → hook-114m7hap.js} +4 -3
  104. package/dist/{hook-5zs3msf6.js → hook-6nphweh0.js} +24 -9
  105. package/dist/{http-mhz9vw1f.js → http-r8cc4wz7.js} +1025 -173
  106. package/dist/index-a8phxqnk.js +37 -0
  107. package/dist/{index-vdygbq10.js → index-e3s8wmxy.js} +1 -1
  108. package/dist/{index-q361cg2f.js → index-fpk4bcd9.js} +1 -1
  109. package/dist/index-k6h00t4c.js +30 -0
  110. package/dist/{index-rvr00sg7.js → index-nh3wyqdt.js} +1017 -574
  111. package/dist/index-xrz7te8h.js +33 -0
  112. package/dist/{keepalive-w56f32cg.js → keepalive-x060nt9p.js} +3 -3
  113. package/dist/lead-956sd2yc.js +71 -0
  114. package/dist/{maintenance-7mdgxcea.js → maintenance-2xxfexhf.js} +1 -1
  115. package/dist/{mistral-conversations-4pmwj1c0.js → mistral-conversations-3gjpdb4b.js} +4 -4
  116. package/dist/{oauth-refresh-sweep-29txy9vk.js → oauth-refresh-sweep-fckc4ar5.js} +2 -2
  117. package/dist/{onboard-kpsrkarj.js → onboard-zc48j237.js} +3 -3
  118. package/dist/{openai-codex-responses-44j573nm.js → openai-codex-responses-8pbxq9h5.js} +5 -5
  119. package/dist/{openai-completions-a4gee2a8.js → openai-completions-rm00s912.js} +4 -4
  120. package/dist/{openai-decisions-w4qkrcar.js → openai-decisions-8v957r2r.js} +1 -1
  121. package/dist/{openai-responses-rbp6h5kr.js → openai-responses-agxpf96k.js} +5 -5
  122. package/dist/{opencode-adapter-j25vm3rr.js → opencode-adapter-r690h1cr.js} +4 -3
  123. package/dist/{otel-impl-6kkct7a8.js → otel-impl-r5xbh81q.js} +4 -4
  124. package/dist/{pi-mono-adapter-exhkzhn2.js → pi-mono-adapter-c7v8sdec.js} +7 -6
  125. package/dist/{pricing-refresh-n2x7jh6k.js → pricing-refresh-3pmre11v.js} +3 -3
  126. package/dist/{rbac-roles-81ypx6es.js → rbac-roles-3vbshwsb.js} +1 -1
  127. package/dist/{rbac-roles-b5htkjby.js → rbac-roles-910mmkgz.js} +2 -2
  128. package/dist/{render-v2-qn6jmpk4.js → render-v2-pxykkbab.js} +3 -3
  129. package/dist/{resume-wd30wx23.js → resume-adda0bwy.js} +6 -6
  130. package/dist/{runtime-4hsg7h60.js → runtime-fnbm7stk.js} +1 -1
  131. package/dist/{session-access-5qvfevhk.js → session-access-2fvamrdc.js} +4 -4
  132. package/dist/{session-source-backfill-n1czavbr.js → session-source-backfill-08xvv0sm.js} +2 -2
  133. package/dist/{setup-dnb56qy5.js → setup-1aqmvmh1.js} +1 -1
  134. package/dist/{typesafe-system-one-5mavm259.js → typesafe-system-one-0bmqcxfg.js} +2 -2
  135. package/dist/{virtual-modules-76xxv9fs.js → virtual-modules-d2pjk9j1.js} +5 -5
  136. package/dist/{work-objects-4n21tfkm.js → work-objects-9s0yrm5j.js} +20 -20
  137. package/dist/{worker-follow-up-77hvzxww.js → worker-follow-up-3q78fqmr.js} +17 -17
  138. package/dist/worker-y15hy08h.js +71 -0
  139. package/openapi.json +197 -4
  140. package/package.json +5 -5
  141. package/src/apps/definition.ts +6 -5
  142. package/src/be/db/page-theme-tokens.ts +12 -0
  143. package/src/be/db/tasks/read.ts +9 -1
  144. package/src/be/db/tasks/write.ts +19 -0
  145. package/src/be/db-client.ts +107 -7
  146. package/src/be/db.ts +34 -2
  147. package/src/be/migrations/269_pages_design_system.sql +23 -0
  148. package/src/be/seed/schedules-seeder.ts +0 -10
  149. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +2 -0
  150. package/src/be/seed-scripts/catalog/catalog-report.ts +2 -0
  151. package/src/be/seed-scripts/catalog/memory-eval.ts +2 -0
  152. package/src/be/seed-scripts/catalog/swarm-update-check.ts +150 -0
  153. package/src/be/seed-scripts/index.ts +9 -0
  154. package/src/be/seed-skills/bundled-files.generated.json +3 -3
  155. package/src/be/swarm-config-guard.ts +32 -0
  156. package/src/be/task-auto-title-listener.ts +127 -0
  157. package/src/commands/onboard/agent-fs-version.ts +1 -1
  158. package/src/commands/profile-sync.ts +151 -16
  159. package/src/commands/runner.ts +15 -7
  160. package/src/hooks/hook.ts +20 -9
  161. package/src/http/index.ts +3 -0
  162. package/src/http/pages-public.ts +93 -129
  163. package/src/http/pages.ts +94 -21
  164. package/src/pages/browser-sdk.ts +48 -21
  165. package/src/pages/color-literals.ts +115 -0
  166. package/src/pages/page-head.ts +766 -0
  167. package/src/pages/version.ts +3 -0
  168. package/src/providers/amp-adapter.ts +1 -1
  169. package/src/providers/codex-adapter.ts +1 -0
  170. package/src/providers/codex-app-server.ts +27 -1
  171. package/src/providers/pi-mono-extension.ts +30 -15
  172. package/src/tasks/follow-up-gate.ts +318 -0
  173. package/src/tasks/worker-follow-up.ts +21 -1
  174. package/src/tests/codex-app-server.test.ts +59 -0
  175. package/src/tests/codex-gateway-route.test.ts +50 -1
  176. package/src/tests/create-page-tool.test.ts +501 -0
  177. package/src/tests/db-client-busy-retry.test.ts +81 -0
  178. package/src/tests/db-task-read-contract.test.ts +1 -0
  179. package/src/tests/db-task-write-contract.test.ts +2 -0
  180. package/src/tests/db-tasks-read.test.ts +21 -0
  181. package/src/tests/fixtures/pages/head-v1.html +56 -0
  182. package/src/tests/follow-up-gate-shadow.test.ts +513 -0
  183. package/src/tests/identity-field-budget.test.ts +1 -1
  184. package/src/tests/internal-ai/credentials.test.ts +62 -0
  185. package/src/tests/internal-ai/task-title.test.ts +283 -0
  186. package/src/tests/merge-gate-detect-changes.test.ts +145 -0
  187. package/src/tests/page-proxy.test.ts +1 -1
  188. package/src/tests/page-session-dev-cookie.test.ts +188 -0
  189. package/src/tests/pages-frame-ancestors.test.ts +28 -1
  190. package/src/tests/pages-head-v2.test.ts +534 -0
  191. package/src/tests/pages-http.test.ts +330 -0
  192. package/src/tests/pages-public-html.test.ts +43 -0
  193. package/src/tests/pages-storage.test.ts +182 -0
  194. package/src/tests/pages-versioning.test.ts +72 -0
  195. package/src/tests/profile-sync-stale-writeback.test.ts +334 -0
  196. package/src/tests/profile-sync.test.ts +11 -15
  197. package/src/tests/scheduled-task-required-params-migration.test.ts +3 -1
  198. package/src/tests/script-connections.test.ts +2 -0
  199. package/src/tests/seed-registry.test.ts +28 -6
  200. package/src/tests/seed-schedules.test.ts +61 -8
  201. package/src/tests/seed-scripts.test.ts +142 -2
  202. package/src/tests/swarm-diff.test.ts +121 -2
  203. package/src/tests/task-auto-title-config-guard.test.ts +37 -0
  204. package/src/tests/task-auto-title-listener.test.ts +165 -0
  205. package/src/tests/theme-import.test.ts +758 -0
  206. package/src/tests/themes-page-theme.test.ts +155 -0
  207. package/src/tests/themes-presets.test.ts +172 -0
  208. package/src/tests/workflow-script-principal.test.ts +2 -1
  209. package/src/themes/classics.ts +949 -0
  210. package/src/themes/import.ts +420 -0
  211. package/src/themes/page-fields.ts +40 -0
  212. package/src/themes/page-theme.ts +102 -0
  213. package/src/themes/presets.ts +561 -0
  214. package/src/themes/tokens.ts +218 -0
  215. package/src/tools/app-upsert.ts +1 -1
  216. package/src/tools/create-page.ts +118 -12
  217. package/src/tools/utils.ts +33 -0
  218. package/src/types.ts +45 -3
  219. package/src/utils/constants.ts +19 -0
  220. package/src/utils/follow-up-gate-schedule-ids.ts +13 -0
  221. package/src/utils/internal-ai/credentials.ts +146 -93
  222. package/src/utils/internal-ai/index.ts +10 -0
  223. package/src/utils/internal-ai/models.ts +18 -0
  224. package/src/utils/internal-ai/task-title.ts +282 -0
  225. package/src/utils/page-session.ts +37 -0
  226. package/templates/schedules/daily-status-report/content.md +88 -2
  227. package/templates/schedules/daily-swarm-update-check/content.md +16 -40
  228. package/templates/skills/apps/SKILL.md +1 -1
  229. package/templates/skills/apps/content.md +1 -1
  230. package/templates/skills/heartbeat-runbook/content.md +1 -1
  231. package/templates/skills/pages/config.json +1 -1
  232. package/templates/skills/pages/content.md +539 -86
  233. package/templates/skills/pages/files/examples/annotated-pr.html +21 -12
  234. package/templates/skills/pages/files/examples/multiplayer-board.html +41 -41
  235. package/templates/skills/pages/files/examples/report-page.html +125 -124
  236. package/dist/cli-2pnswgh8.js +0 -189
  237. package/dist/handlers-by3sfxyx.js +0 -32
  238. package/dist/index-4f2pvxae.js +0 -37
  239. package/dist/index-98gz7tmp.js +0 -30
  240. package/dist/index-k08z2cak.js +0 -33
  241. package/dist/lead-enq35nqp.js +0 -70
  242. package/dist/worker-gg1576j3.js +0 -70
  243. package/templates/schedules/daily-workflow-health-audit/config.json +0 -19
  244. package/templates/schedules/daily-workflow-health-audit/content.md +0 -191
@@ -108,6 +108,30 @@ export interface ResolveCredentialOptions {
108
108
  _persistCodexOAuth?: typeof persistCodexOAuth;
109
109
  }
110
110
 
111
+ /**
112
+ * Credential kinds in precedence order. `resolveCredentials` walks this list;
113
+ * `resolveCredential` returns the first hit.
114
+ */
115
+ export const CREDENTIAL_KIND_PRECEDENCE: readonly CredentialKind[] = [
116
+ "openrouter",
117
+ "anthropic",
118
+ "openai",
119
+ "openai-codex",
120
+ "claude-cli",
121
+ ];
122
+
123
+ export interface ResolveCredentialsOptions extends ResolveCredentialOptions {
124
+ /**
125
+ * Only resolve these kinds (in precedence order). Lets a caller probe one
126
+ * provider at a time, so the codex OAuth lookup (an HTTP call that can
127
+ * rotate a refresh token) runs only when that kind is actually needed.
128
+ * Default: every kind.
129
+ */
130
+ kinds?: readonly CredentialKind[];
131
+ /** Stop after this many credentials. Default: no limit. */
132
+ limit?: number;
133
+ }
134
+
111
135
  /**
112
136
  * Resolve a credential according to the documented precedence. Returns `null`
113
137
  * if no credential could be resolved — callers MUST treat null as a graceful
@@ -125,112 +149,141 @@ export interface ResolveCredentialOptions {
125
149
  export async function resolveCredential(
126
150
  opts: ResolveCredentialOptions = {},
127
151
  ): Promise<ResolvedCredential | null> {
152
+ const [first] = await resolveCredentials({ ...opts, limit: 1 });
153
+ return first ?? null;
154
+ }
155
+
156
+ /**
157
+ * Every credential the environment offers, in the precedence order documented
158
+ * on {@link resolveCredential}. Callers that can fall back across providers
159
+ * (task titles) walk the list; `resolveCredential` is the `limit: 1` case.
160
+ * The codex OAuth probe runs only when `openai-codex` is reached, so an env
161
+ * key ahead of it (or `kinds` that exclude it) never triggers the HTTP call.
162
+ */
163
+ export async function resolveCredentials(
164
+ opts: ResolveCredentialsOptions = {},
165
+ ): Promise<ResolvedCredential[]> {
128
166
  const env = opts.env ?? process.env;
129
167
  const getEnvKey = opts._getEnvApiKey ?? getEnvApiKey;
130
- const getCodex = opts._getValidCodexOAuth ?? getValidCodexOAuth;
131
- const getOAuth = opts._getOAuthApiKey ?? resolveOAuthApiKey;
132
- const persistCodex = opts._persistCodexOAuth ?? persistCodexOAuth;
168
+ const wanted = opts.kinds ?? CREDENTIAL_KIND_PRECEDENCE;
169
+ const limit = opts.limit ?? Number.POSITIVE_INFINITY;
170
+ const found: ResolvedCredential[] = [];
133
171
 
134
- // 1. OpenRouter.
135
- const openrouterKey = env.OPENROUTER_API_KEY ?? getEnvKey("openrouter");
136
- if (openrouterKey) {
137
- return {
138
- kind: "openrouter",
139
- apiKey: selectCredential(openrouterKey, undefined, "OPENROUTER_API_KEY").selected,
140
- modelDefault: resolveRaterModelString("openrouter"),
141
- };
172
+ for (const kind of CREDENTIAL_KIND_PRECEDENCE) {
173
+ if (found.length >= limit) break;
174
+ if (!wanted.includes(kind)) continue;
175
+ const cred = await resolveKind(kind, opts, env, getEnvKey);
176
+ if (cred) found.push(cred);
142
177
  }
178
+ return found;
179
+ }
143
180
 
144
- // 2. Anthropic. pi-ai sends this key to api.anthropic.com, so skip it when
145
- // ANTHROPIC_BASE_URL names a gateway: the key belongs to the gateway (#1800).
146
- const anthropicBaseUrl = env.ANTHROPIC_BASE_URL?.trim();
147
- const anthropicKeyIsForGateway =
148
- Boolean(anthropicBaseUrl) && !isFirstPartyAnthropicUrl(anthropicBaseUrl ?? "");
149
- const anthropicKey = anthropicKeyIsForGateway
150
- ? undefined
151
- : (env.ANTHROPIC_API_KEY ?? getEnvKey("anthropic"));
152
- if (anthropicKey) {
153
- return {
154
- kind: "anthropic",
155
- apiKey: selectCredential(anthropicKey, undefined, "ANTHROPIC_API_KEY").selected,
156
- modelDefault: resolveRaterModelString("anthropic"),
157
- };
181
+ async function resolveKind(
182
+ kind: CredentialKind,
183
+ opts: ResolveCredentialOptions,
184
+ env: NodeJS.ProcessEnv,
185
+ getEnvKey: typeof getEnvApiKey,
186
+ ): Promise<ResolvedCredential | null> {
187
+ switch (kind) {
188
+ case "openrouter": {
189
+ const key = env.OPENROUTER_API_KEY ?? getEnvKey("openrouter");
190
+ if (!key) return null;
191
+ return {
192
+ kind,
193
+ apiKey: selectCredential(key, undefined, "OPENROUTER_API_KEY").selected,
194
+ modelDefault: resolveRaterModelString(kind),
195
+ };
196
+ }
197
+ case "anthropic": {
198
+ // pi-ai sends this key to api.anthropic.com, so skip it when
199
+ // ANTHROPIC_BASE_URL names a gateway: the key belongs to the gateway (#1800).
200
+ const anthropicBaseUrl = env.ANTHROPIC_BASE_URL?.trim();
201
+ const keyIsForGateway =
202
+ Boolean(anthropicBaseUrl) && !isFirstPartyAnthropicUrl(anthropicBaseUrl ?? "");
203
+ const key = keyIsForGateway ? undefined : (env.ANTHROPIC_API_KEY ?? getEnvKey("anthropic"));
204
+ if (!key) return null;
205
+ return {
206
+ kind,
207
+ apiKey: selectCredential(key, undefined, "ANTHROPIC_API_KEY").selected,
208
+ modelDefault: resolveRaterModelString(kind),
209
+ };
210
+ }
211
+ case "openai": {
212
+ const key = env.OPENAI_API_KEY ?? getEnvKey("openai");
213
+ if (!key) return null;
214
+ return {
215
+ kind,
216
+ apiKey: selectCredential(key, undefined, "OPENAI_API_KEY").selected,
217
+ modelDefault: resolveRaterModelString(kind),
218
+ };
219
+ }
220
+ case "openai-codex":
221
+ // Only if we have apiUrl + apiKey to probe the config store.
222
+ if (!opts.apiUrl || !opts.apiKey) return null;
223
+ return resolveCodexOAuthCredential(opts, opts.apiUrl, opts.apiKey);
224
+ case "claude-cli":
225
+ // `AGENT_SWARM_CLAUDE_OAUTH_TOKEN` is the mirror set by claude-adapter.ts
226
+ // before spawning `claude` — the CLI strips `CLAUDE_CODE_OAUTH_TOKEN` from
227
+ // hook subprocesses (security), so the hook reads the mirror instead.
228
+ if (!env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN && !env.CLAUDE_CODE_OAUTH_TOKEN) return null;
229
+ return { kind, modelDefault: resolveRaterModelString(kind) };
158
230
  }
231
+ }
159
232
 
160
- // 3. OpenAI.
161
- const openaiKey = env.OPENAI_API_KEY ?? getEnvKey("openai");
162
- if (openaiKey) {
163
- return {
164
- kind: "openai",
165
- apiKey: selectCredential(openaiKey, undefined, "OPENAI_API_KEY").selected,
166
- modelDefault: resolveRaterModelString("openai"),
233
+ async function resolveCodexOAuthCredential(
234
+ opts: ResolveCredentialOptions,
235
+ apiUrl: string,
236
+ apiKey: string,
237
+ ): Promise<ResolvedCredential | null> {
238
+ const getCodex = opts._getValidCodexOAuth ?? getValidCodexOAuth;
239
+ const getOAuth = opts._getOAuthApiKey ?? resolveOAuthApiKey;
240
+ const persistCodex = opts._persistCodexOAuth ?? persistCodexOAuth;
241
+ try {
242
+ const codexCreds = await getCodex(apiUrl, apiKey);
243
+ if (!codexCreds) return null;
244
+ // pi-ai expects a Record<providerID, OAuthCredentials>.
245
+ const credMap: Record<string, OAuthCredentials> = {
246
+ "openai-codex": {
247
+ access: codexCreds.access,
248
+ refresh: codexCreds.refresh,
249
+ expires: codexCreds.expires,
250
+ },
167
251
  };
168
- }
169
-
170
- // 4. Codex OAuth — only if we have apiUrl + apiKey to probe the config store.
171
- if (opts.apiUrl && opts.apiKey) {
172
- try {
173
- const codexCreds = await getCodex(opts.apiUrl, opts.apiKey);
174
- if (codexCreds) {
175
- // pi-ai expects a Record<providerID, OAuthCredentials>.
176
- const credMap: Record<string, OAuthCredentials> = {
177
- "openai-codex": {
178
- access: codexCreds.access,
179
- refresh: codexCreds.refresh,
180
- expires: codexCreds.expires,
181
- },
182
- };
183
- const oauthResult = await getOAuth("openai-codex", credMap);
184
- if (oauthResult) {
185
- // Persist any rotated refresh token — best-effort, must not block.
186
- // Wrap defensively here even though the production helper already
187
- // swallows; tests may inject a throwing hook, and persistence
188
- // failure must never prevent the current call from succeeding.
189
- if (oauthResult.newCredentials) {
190
- const updated = oauthResult.newCredentials;
191
- try {
192
- await persistCodex(opts.apiUrl, opts.apiKey, {
193
- access: String(updated.access),
194
- refresh: String(updated.refresh),
195
- expires: Number(updated.expires),
196
- accountId: codexCreds.accountId,
197
- });
198
- } catch (err) {
199
- console.error(
200
- `internal-ai: persistCodexOAuth failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
201
- err,
202
- );
203
- }
204
- }
205
- return {
206
- kind: "openai-codex",
207
- apiKey: oauthResult.apiKey,
208
- modelDefault: resolveRaterModelString("openai-codex"),
209
- };
210
- }
252
+ const oauthResult = await getOAuth("openai-codex", credMap);
253
+ if (!oauthResult) return null;
254
+ // Persist any rotated refresh token — best-effort, must not block.
255
+ // Wrap defensively here even though the production helper already
256
+ // swallows; tests may inject a throwing hook, and persistence
257
+ // failure must never prevent the current call from succeeding.
258
+ if (oauthResult.newCredentials) {
259
+ const updated = oauthResult.newCredentials;
260
+ try {
261
+ await persistCodex(apiUrl, apiKey, {
262
+ access: String(updated.access),
263
+ refresh: String(updated.refresh),
264
+ expires: Number(updated.expires),
265
+ accountId: codexCreds.accountId,
266
+ });
267
+ } catch (err) {
268
+ console.error(
269
+ `internal-ai: persistCodexOAuth failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
270
+ err,
271
+ );
211
272
  }
212
- } catch (err) {
213
- console.error(
214
- `internal-ai: codex OAuth probe failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
215
- err,
216
- );
217
- // Fall through to claude-cli fallback below.
218
273
  }
219
- }
220
-
221
- // 5. CLAUDE_CODE_OAUTH_TOKEN → claude-cli fallback.
222
- // `AGENT_SWARM_CLAUDE_OAUTH_TOKEN` is the mirror set by claude-adapter.ts
223
- // before spawning `claude` — the CLI strips `CLAUDE_CODE_OAUTH_TOKEN` from
224
- // hook subprocesses (security), so the hook reads the mirror instead.
225
- if (env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN || env.CLAUDE_CODE_OAUTH_TOKEN) {
226
274
  return {
227
- kind: "claude-cli",
228
- modelDefault: resolveRaterModelString("claude-cli"),
275
+ kind: "openai-codex",
276
+ apiKey: oauthResult.apiKey,
277
+ modelDefault: resolveRaterModelString("openai-codex"),
229
278
  };
279
+ } catch (err) {
280
+ console.error(
281
+ `internal-ai: codex OAuth probe failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
282
+ err,
283
+ );
284
+ // The caller falls through to the claude-cli fallback.
285
+ return null;
230
286
  }
231
-
232
- // 6. No creds.
233
- return null;
234
287
  }
235
288
 
236
289
  // Re-export for convenience to keep imports flat.
@@ -19,11 +19,14 @@ export {
19
19
  completeStructuredWithModel,
20
20
  } from "./complete-structured.js";
21
21
  export {
22
+ CREDENTIAL_KIND_PRECEDENCE,
22
23
  type CredentialKind,
23
24
  DEFAULT_MODEL,
24
25
  type ResolveCredentialOptions,
26
+ type ResolveCredentialsOptions,
25
27
  type ResolvedCredential,
26
28
  resolveCredential,
29
+ resolveCredentials,
27
30
  } from "./credentials.js";
28
31
  export {
29
32
  MEMORY_RATER_DEFAULT_MODEL,
@@ -36,3 +39,10 @@ export {
36
39
  summarizeSession,
37
40
  summaryToolSchema,
38
41
  } from "./summarize-session.js";
42
+ export {
43
+ type GenerateTaskTitleOptions,
44
+ type GenerateTaskTitleResult,
45
+ generateTaskTitle,
46
+ TASK_TITLE_MODEL_CANDIDATES,
47
+ TASK_TITLE_MODEL_ENV,
48
+ } from "./task-title.js";
@@ -59,6 +59,24 @@ export function resolveRaterModelString(kind: CredentialKind): string {
59
59
  return process.env.MEMORY_RATER_MODEL ?? MEMORY_RATER_DEFAULT_MODEL[kind];
60
60
  }
61
61
 
62
+ const PROVIDER_TO_KIND: Record<string, CredentialKind> = {
63
+ openrouter: "openrouter",
64
+ anthropic: "anthropic",
65
+ openai: "openai",
66
+ "openai-codex": "openai-codex",
67
+ };
68
+
69
+ /**
70
+ * Credential kind a model string belongs to. `provider/model-id` maps through
71
+ * the provider prefix; a bare alias (`haiku`) is a `claude -p` model. `null`
72
+ * for a provider the internal-ai layer cannot call.
73
+ */
74
+ export function kindForModelString(modelStr: string): CredentialKind | null {
75
+ const slash = modelStr.indexOf("/");
76
+ if (slash < 0) return "claude-cli";
77
+ return PROVIDER_TO_KIND[modelStr.slice(0, slash)] ?? null;
78
+ }
79
+
62
80
  /**
63
81
  * Split a `provider/model-id` string on the FIRST `/` so that OpenRouter
64
82
  * compound IDs like `openrouter/deepseek/deepseek-v4.1-flash` parse as
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Task title generation: a short display title for a task prompt.
3
+ *
4
+ * Consumer of `completeStructuredWithModel`. Walks `TASK_TITLE_MODEL_CANDIDATES`
5
+ * (provider order, then model order) and returns the first title a configured
6
+ * credential produces. Every candidate is a cheap, fast model: one title costs
7
+ * well under a tenth of a cent, so thousands of weekly tasks stay in cents.
8
+ *
9
+ * Worker-safe: no bun:sqlite import. The API server wires it to task creation
10
+ * in `src/be/task-auto-title-listener.ts`.
11
+ */
12
+
13
+ import { Type } from "typebox";
14
+ import { z } from "zod";
15
+ import { scrubSecrets } from "../secret-scrubber.js";
16
+ import { completeStructuredWithModel } from "./complete-structured.js";
17
+ import { type CredentialKind, type ResolvedCredential, resolveCredentials } from "./credentials.js";
18
+ import { kindForModelString } from "./models.js";
19
+
20
+ /** Longest generated title. The PATCH /api/tasks/{id}/title route allows 120. */
21
+ export const TASK_TITLE_MAX_CHARS = 100;
22
+ /** Prompts shorter than this keep the prompt itself as the display title. */
23
+ export const TASK_TITLE_MIN_TASK_CHARS = 48;
24
+ /** Prompt text sent to the model is cut here. Keeps every call small. */
25
+ export const TASK_TITLE_MAX_TASK_CHARS = 2000;
26
+ /** Whole generation, across every candidate. */
27
+ export const TASK_TITLE_TIMEOUT_MS = 20_000;
28
+
29
+ /** Env var that pins one model (`provider/model-id`, or a `claude -p` alias). */
30
+ export const TASK_TITLE_MODEL_ENV = "TASK_AUTO_TITLE_MODEL";
31
+
32
+ export interface TaskTitleCandidateGroup {
33
+ kind: CredentialKind;
34
+ /** Full model strings for this credential kind, best first. */
35
+ models: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * Provider order, then model order within a provider. Cheapest and fastest
40
+ * first: pay-per-token API keys before subscription OAuth, and the `claude -p`
41
+ * shellout last (it spawns a full CLI process per call).
42
+ *
43
+ * Model prices (input / output, USD per million tokens, models.dev snapshot):
44
+ * deepseek-v4.1-flash 0.034 / 0.137 · claude-haiku-5.5 0.10 / 0.50 ·
45
+ * gpt-6-luna 0.10 / 0.50 · gpt-5-nano 0.05 / 0.40
46
+ */
47
+ export const TASK_TITLE_MODEL_CANDIDATES: readonly TaskTitleCandidateGroup[] = [
48
+ {
49
+ kind: "openrouter",
50
+ models: [
51
+ "openrouter/deepseek/deepseek-v4.1-flash",
52
+ "openrouter/anthropic/claude-haiku-5.5",
53
+ "openrouter/openai/gpt-6-luna",
54
+ ],
55
+ },
56
+ { kind: "anthropic", models: ["anthropic/claude-haiku-5-5"] },
57
+ { kind: "openai", models: ["openai/gpt-6-luna", "openai/gpt-5-nano"] },
58
+ { kind: "openai-codex", models: ["openai-codex/gpt-6-luna"] },
59
+ { kind: "claude-cli", models: ["haiku"] },
60
+ ];
61
+
62
+ export { kindForModelString };
63
+
64
+ /**
65
+ * The candidate list for one environment. `TASK_AUTO_TITLE_MODEL` moves its
66
+ * provider to the front and puts the pinned model first inside it; the rest of
67
+ * the list stays as fallback. An unknown provider is ignored with a warning.
68
+ */
69
+ export function buildTaskTitlePlan(
70
+ env: Record<string, string | undefined> = process.env,
71
+ ): readonly TaskTitleCandidateGroup[] {
72
+ const pinned = env[TASK_TITLE_MODEL_ENV]?.trim();
73
+ if (!pinned) return TASK_TITLE_MODEL_CANDIDATES;
74
+ const kind = kindForModelString(pinned);
75
+ if (!kind) {
76
+ console.warn(
77
+ `[task-title] ${TASK_TITLE_MODEL_ENV}=${pinned} names an unsupported provider; ignored`,
78
+ );
79
+ return TASK_TITLE_MODEL_CANDIDATES;
80
+ }
81
+ const own = TASK_TITLE_MODEL_CANDIDATES.find((group) => group.kind === kind);
82
+ const rest = TASK_TITLE_MODEL_CANDIDATES.filter((group) => group.kind !== kind);
83
+ const models = [pinned, ...(own?.models ?? []).filter((model) => model !== pinned)];
84
+ return [{ kind, models }, ...rest];
85
+ }
86
+
87
+ const SIBLING_OPEN = "<sibling_tasks_in_progress>";
88
+ const SIBLING_CLOSE = "</sibling_tasks_in_progress>";
89
+
90
+ /**
91
+ * Task text as the model should see it: without the sibling-awareness block
92
+ * the API prepends to child tasks, with runs of blank lines collapsed, and cut
93
+ * to `TASK_TITLE_MAX_TASK_CHARS`.
94
+ */
95
+ export function prepareTaskTextForTitle(task: string): string {
96
+ let text = task;
97
+ if (text.startsWith(SIBLING_OPEN)) {
98
+ const close = text.indexOf(SIBLING_CLOSE);
99
+ if (close !== -1) text = text.slice(close + SIBLING_CLOSE.length);
100
+ }
101
+ text = text
102
+ .replace(/\r\n?/g, "\n")
103
+ .replace(/\n{3,}/g, "\n\n")
104
+ .trim();
105
+ if (text.length > TASK_TITLE_MAX_TASK_CHARS) {
106
+ text = `${text.slice(0, TASK_TITLE_MAX_TASK_CHARS).trimEnd()} …`;
107
+ }
108
+ return text;
109
+ }
110
+
111
+ const WRAPPING_QUOTES: ReadonlyArray<[string, string]> = [
112
+ ['"', '"'],
113
+ ["'", "'"],
114
+ ["“", "”"],
115
+ ["‘", "’"],
116
+ ["`", "`"],
117
+ ["«", "»"],
118
+ ];
119
+
120
+ /**
121
+ * Clean a model-produced title: one line, no wrapping quotes or `Title:`
122
+ * prefix, no markdown emphasis, no trailing sentence punctuation, at most
123
+ * `TASK_TITLE_MAX_CHARS` (cut at a word boundary). Empty string when nothing
124
+ * usable remains.
125
+ */
126
+ export function normalizeTaskTitle(raw: string): string {
127
+ let title = raw.replace(/\s+/g, " ").trim();
128
+ title = title.replace(/^title\s*:\s*/i, "");
129
+ title = title.replace(/\*\*|__|^#+\s*/g, "");
130
+ const stripTrailingPunctuation = () => {
131
+ title = title.replace(/[.!…]+$/u, "").trim();
132
+ };
133
+ // Quotes can sit inside or outside the trailing punctuation: `"Title".` and `"Title…"`.
134
+ stripTrailingPunctuation();
135
+ for (const [open, close] of WRAPPING_QUOTES) {
136
+ if (title.length >= 2 && title.startsWith(open) && title.endsWith(close)) {
137
+ title = title.slice(open.length, title.length - close.length).trim();
138
+ }
139
+ }
140
+ stripTrailingPunctuation();
141
+ if (title.length > TASK_TITLE_MAX_CHARS) {
142
+ const cut = title.slice(0, TASK_TITLE_MAX_CHARS);
143
+ const lastSpace = cut.lastIndexOf(" ");
144
+ title = (lastSpace >= TASK_TITLE_MAX_CHARS / 2 ? cut.slice(0, lastSpace) : cut).trimEnd();
145
+ title = title.replace(/[,;:\-–—]+$/u, "").trimEnd();
146
+ }
147
+ return title;
148
+ }
149
+
150
+ export const TASK_TITLE_SYSTEM_PROMPT = [
151
+ "You write titles for tasks on a software team's dashboard.",
152
+ "Rules:",
153
+ "- 3 to 8 words, at most 80 characters.",
154
+ "- Sentence case. No trailing period. No quotes, markdown, emoji, or task ids.",
155
+ "- Name the concrete subject (feature, bug, repo, file, system, person), not a generic verb alone.",
156
+ "- Keep the language of the request.",
157
+ "- Ignore pasted logs, code, and context blocks; title the request itself.",
158
+ "Call the set_title tool with the title.",
159
+ ].join("\n");
160
+
161
+ export const TASK_TITLE_TOOL_NAME = "set_title";
162
+
163
+ const TaskTitleZodSchema = z.object({ title: z.string().trim().min(1).max(200) });
164
+ const TaskTitleToolSchema = Type.Object({
165
+ title: Type.String({ minLength: 1, maxLength: 200, description: "The task title." }),
166
+ });
167
+
168
+ export interface GenerateTaskTitleOptions {
169
+ /** Full task prompt as stored on the task. */
170
+ task: string;
171
+ /** Credential lookup env. Defaults to `process.env`. */
172
+ env?: Record<string, string | undefined>;
173
+ /** Optional loopback context for the codex OAuth probe (see `resolveCredentials`). */
174
+ apiUrl?: string;
175
+ apiKey?: string;
176
+ signal?: AbortSignal;
177
+ /** Diagnostic tag forwarded to `completeStructured` logs. */
178
+ callerTag?: string;
179
+ // Test injection points:
180
+ _resolveCredentials?: typeof resolveCredentials;
181
+ _completeStructured?: typeof completeStructuredWithModel;
182
+ }
183
+
184
+ export type GenerateTaskTitleResult =
185
+ | { ok: true; title: string; model: string; kind: CredentialKind }
186
+ | { ok: false; reason: "short-task" | "no-credentials" | "all-models-failed" };
187
+
188
+ /**
189
+ * Generate a title for `task`. Never throws: a provider error moves on to the
190
+ * next candidate, and exhausting the list returns `{ ok: false }`.
191
+ */
192
+ export async function generateTaskTitle(
193
+ opts: GenerateTaskTitleOptions,
194
+ ): Promise<GenerateTaskTitleResult> {
195
+ const text = prepareTaskTextForTitle(opts.task);
196
+ if (text.length < TASK_TITLE_MIN_TASK_CHARS) return { ok: false, reason: "short-task" };
197
+
198
+ const env = opts.env ?? process.env;
199
+ const resolve = opts._resolveCredentials ?? resolveCredentials;
200
+ const complete = opts._completeStructured ?? completeStructuredWithModel;
201
+ const callerTag = opts.callerTag ?? "task-title";
202
+ const plan = buildTaskTitlePlan(env);
203
+
204
+ // One lookup per kind, on demand, so the codex OAuth probe (an HTTP call
205
+ // that can rotate a refresh token) runs only when its turn comes.
206
+ const credentialCache = new Map<CredentialKind, ResolvedCredential | null>();
207
+ const credentialFor = async (kind: CredentialKind): Promise<ResolvedCredential | null> => {
208
+ const cached = credentialCache.get(kind);
209
+ if (cached !== undefined) return cached;
210
+ const [cred] = await resolve({
211
+ env,
212
+ apiUrl: opts.apiUrl,
213
+ apiKey: opts.apiKey,
214
+ callerTag,
215
+ kinds: [kind],
216
+ limit: 1,
217
+ });
218
+ credentialCache.set(kind, cred ?? null);
219
+ return cred ?? null;
220
+ };
221
+
222
+ let sawCredential = false;
223
+ for (const group of plan) {
224
+ if (opts.signal?.aborted) break;
225
+ const cred = await credentialFor(group.kind);
226
+ if (!cred) continue;
227
+ sawCredential = true;
228
+ for (const model of group.models) {
229
+ if (opts.signal?.aborted) break;
230
+ const title = await attemptTitle(complete, {
231
+ cred,
232
+ model,
233
+ text,
234
+ env,
235
+ callerTag,
236
+ signal: opts.signal,
237
+ });
238
+ if (title) return { ok: true, title, model, kind: cred.kind };
239
+ }
240
+ }
241
+ return { ok: false, reason: sawCredential ? "all-models-failed" : "no-credentials" };
242
+ }
243
+
244
+ interface TitleAttempt {
245
+ cred: ResolvedCredential;
246
+ model: string;
247
+ text: string;
248
+ env: Record<string, string | undefined>;
249
+ callerTag: string;
250
+ signal?: AbortSignal;
251
+ }
252
+
253
+ /** One model call. `completeStructuredWithModel` swallows provider errors; a throw here is still just a miss. */
254
+ async function attemptTitle(
255
+ complete: typeof completeStructuredWithModel,
256
+ attempt: TitleAttempt,
257
+ ): Promise<string> {
258
+ try {
259
+ const result = await complete({
260
+ zodSchema: TaskTitleZodSchema,
261
+ toolSchema: TaskTitleToolSchema,
262
+ toolName: TASK_TITLE_TOOL_NAME,
263
+ toolDescription: "Record the title for this task.",
264
+ systemPrompt: TASK_TITLE_SYSTEM_PROMPT,
265
+ userPrompt: `Task request:\n\n${attempt.text}`,
266
+ env: attempt.env,
267
+ retries: 1,
268
+ signal: attempt.signal,
269
+ callerTag: `${attempt.callerTag}:${attempt.model}`,
270
+ _credentialOverride: { ...attempt.cred, modelDefault: attempt.model },
271
+ });
272
+ return result ? normalizeTaskTitle(result.data.title) : "";
273
+ } catch (error) {
274
+ // Provider errors can echo request fragments; scrub before logging.
275
+ console.error(
276
+ scrubSecrets(
277
+ `[task-title] ${attempt.model} threw: ${error instanceof Error ? error.message : String(error)}`,
278
+ ),
279
+ );
280
+ return "";
281
+ }
282
+ }
@@ -27,7 +27,9 @@
27
27
  */
28
28
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
29
29
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
30
+ import type { IncomingHttpHeaders } from "node:http";
30
31
  import path from "node:path";
32
+ import { isDevOrTestNodeEnv } from "./node-env";
31
33
 
32
34
  const ENV_SECRET = "PAGE_SESSION_SECRET";
33
35
  const ENV_SECRET_FILE = "PAGE_SESSION_SECRET_FILE";
@@ -336,6 +338,41 @@ export async function extractAndVerifyCookie(
336
338
  */
337
339
  const PAGE_SESSION_TTL_SECONDS = 3600;
338
340
 
341
+ const LOCAL_DEV_HOSTNAMES = new Set(["localhost", "127.0.0.1"]);
342
+
343
+ /** True only for a parsed `http:` URL whose hostname is exactly a loopback dev name. */
344
+ function isLocalHttpUrl(value: string): boolean {
345
+ try {
346
+ const url = new URL(value);
347
+ return url.protocol === "http:" && LOCAL_DEV_HOSTNAMES.has(url.hostname);
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Decide the `dev` flag for `issuePageSessionCookie`. Shared by the launch
355
+ * endpoint (`src/http/pages.ts`) and the password flow on `/p/:id`
356
+ * (`src/http/pages-public.ts`) so both issue the same cookie.
357
+ *
358
+ * Fails closed: only an explicit development or test NODE_ENV can soften the
359
+ * cookie. Deployed images leave NODE_ENV unset and get `SameSite=None; Secure`.
360
+ * In dev, the request must come from `http://localhost` or `http://127.0.0.1`
361
+ * (parsed, so `http://localhost.evil.example` does not match). Without an
362
+ * Origin, the Host header decides; a missing Host counts as local.
363
+ * Anything else, including portless `https://*.localhost`, keeps
364
+ * `SameSite=None; Secure` so the cookie travels on cross-site fetches.
365
+ * Chrome treats localhost as a secure context, so Secure also works over http.
366
+ */
367
+ export function isLocalDevPageRequest(req: { headers: IncomingHttpHeaders }): boolean {
368
+ if (!isDevOrTestNodeEnv()) return false;
369
+ const origin = req.headers.origin ?? "";
370
+ if (origin !== "") return isLocalHttpUrl(origin);
371
+ const host = req.headers.host;
372
+ if (!host) return true;
373
+ return isLocalHttpUrl(`http://${host}`);
374
+ }
375
+
339
376
  /**
340
377
  * Mint a signed page-session token + build the `Set-Cookie` header value for
341
378
  * `pageId`. Shared by the bearer-authed launch endpoint (`src/http/pages.ts`)