cartethyia 2.4.6 → 2.5.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 (303) hide show
  1. package/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-Bb9CNuoh.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-BXUaRCQQ.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CisNZOUt.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-BOpI_LOH.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-DAkdvJ4k.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-DeT-VXHd.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-CytxSeGP.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-8-eVadhc.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-uEIXPyGO.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +1 -0
  37. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-DqK5E26W.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/entry--yj84zk3.js +4 -0
  63. package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  65. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  66. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  67. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CcD1eSJ6.js} +2 -2
  71. package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-XPJ-dIw3.js +41 -0
  89. package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/tray.ts +47 -8
  119. package/scripts/cli/command/update.ts +1 -1
  120. package/scripts/cli/gateway/instance-lock.ts +105 -0
  121. package/scripts/cli/gateway/spawn.ts +173 -101
  122. package/scripts/cli/ui/background.ts +110 -49
  123. package/src/boot/app.ts +20 -6
  124. package/src/boot/dependencies.ts +19 -0
  125. package/src/boot/elysia-typebox-bridge.ts +37 -0
  126. package/src/boot/lifecycle.ts +2 -0
  127. package/src/config/cooldowns.ts +138 -138
  128. package/src/config/providers.ts +5 -5
  129. package/src/config/runtime.ts +19 -3
  130. package/src/config/spec.ts +9 -0
  131. package/src/console/api-keys/contracts.ts +414 -391
  132. package/src/console/api-keys/routes.ts +880 -870
  133. package/src/console/auth/account-reset-store.ts +14 -0
  134. package/src/console/auth/account-reset.ts +3 -2
  135. package/src/console/auth/setup-service.ts +6 -3
  136. package/src/console/domain-registration.ts +9 -2
  137. package/src/console/observability/contracts.ts +475 -455
  138. package/src/console/observability/store.ts +1015 -952
  139. package/src/console/providers/catalog/contracts.ts +31 -0
  140. package/src/console/providers/catalog/provider-operations.ts +8 -0
  141. package/src/console/providers/catalog/store.ts +1532 -1525
  142. package/src/console/providers/oauth/callback-listener.ts +18 -3
  143. package/src/console/providers/oauth/routes.ts +38 -15
  144. package/src/console/quota/refresh.ts +19 -0
  145. package/src/console/updates/routes.ts +162 -4
  146. package/src/main.ts +3 -0
  147. package/src/network/http2-bidi.ts +85 -0
  148. package/src/network/outbound-fetch.ts +1 -26
  149. package/src/network/pool/agent.ts +478 -460
  150. package/src/network/pool/resolver.ts +1104 -544
  151. package/src/network/pool-health-machine.ts +12 -5
  152. package/src/observability/payload-capture.ts +344 -324
  153. package/src/persistence/db-pg.ts +9 -3
  154. package/src/persistence/db-sqlite.ts +497 -495
  155. package/src/persistence/health-event-flush.ts +137 -0
  156. package/src/persistence/migrate.ts +181 -129
  157. package/src/persistence/schema.ts +1360 -1295
  158. package/src/persistence/settings-revision.ts +25 -0
  159. package/src/persistence/shared-cache.ts +217 -0
  160. package/src/persistence/stores/share-store.ts +556 -528
  161. package/src/persistence/usage-flush.ts +61 -1
  162. package/src/protocol/canonical-model.ts +23 -1
  163. package/src/protocol/response/chat.ts +143 -10
  164. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  165. package/src/providers/default-registry.ts +173 -1
  166. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  167. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  168. package/src/providers/discovery/probing-service.ts +1022 -1001
  169. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  170. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  171. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  172. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  173. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  174. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  175. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  176. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  177. package/src/providers/integrations/claude/claude.ts +606 -582
  178. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  179. package/src/providers/integrations/cline/cline.ts +539 -468
  180. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  181. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  182. package/src/providers/integrations/commandcode/quota.ts +160 -0
  183. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  184. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  185. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  186. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  187. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  188. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  189. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  190. package/src/providers/integrations/cursor/cursor.ts +960 -0
  191. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  192. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  193. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  194. package/src/providers/integrations/github/github.ts +4 -4
  195. package/src/providers/integrations/hermes.ts +79 -8
  196. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  197. package/src/providers/integrations/kilo/kilo.ts +153 -146
  198. package/src/providers/integrations/opencode/provider.ts +532 -538
  199. package/src/providers/integrations/openrouter/provider.ts +139 -281
  200. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  201. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  202. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  203. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  204. package/src/providers/integrations/qoder/qoder.ts +865 -730
  205. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  206. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  207. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  208. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  209. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  210. package/src/providers/integrations/trae/trae.ts +458 -0
  211. package/src/providers/operations/account-health-service.ts +107 -10
  212. package/src/providers/operations/client-versions.ts +133 -6
  213. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  214. package/src/providers/operations/provider-credential-service.ts +46 -7
  215. package/src/providers/provider-contracts.ts +76 -0
  216. package/src/providers/provider-metadata.ts +78 -6
  217. package/src/providers/provider-model-definition.ts +373 -349
  218. package/src/security/api-key-auth.ts +126 -8
  219. package/src/transport/contracts.ts +3 -0
  220. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  221. package/src/transport/dispatch/attempt-loop.ts +530 -515
  222. package/src/transport/dispatch/generation.ts +88 -21
  223. package/src/transport/dispatch/leases.ts +99 -2
  224. package/src/transport/dispatch/proxy-request.ts +60 -3
  225. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  226. package/src/transport/dispatch/streaming/stream.ts +22 -2
  227. package/src/transport/dispatch/upstream.ts +19 -1
  228. package/src/transport/guards/auth-guard.ts +26 -1
  229. package/src/transport/guards/body-policy.ts +5 -4
  230. package/src/transport/request/preparer.ts +103 -0
  231. package/src/transport/request/state.ts +14 -0
  232. package/src/transport/routing/plan-gate.ts +45 -0
  233. package/src/transport/routing/route-catalog.ts +799 -777
  234. package/src/transport/routing/route-model.ts +52 -1
  235. package/src/transport/routing/router.ts +51 -0
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  237. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  239. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  241. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  242. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  243. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  244. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  245. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  246. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  248. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  250. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  252. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  254. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  256. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  258. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  259. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  260. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  262. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  263. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  265. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  266. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  267. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  268. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  270. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  271. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  272. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  273. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  274. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  275. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  276. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  277. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  279. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  280. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  281. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  282. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  283. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  284. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  285. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  286. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  287. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  288. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  289. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  291. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  293. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  295. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  296. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  297. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  298. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  299. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  300. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  301. package/src/network/tls/bun-client-hello.ts +0 -112
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  303. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -0,0 +1,18 @@
1
+ -- The plan the provider reports for this account, last seen by a quota read.
2
+ --
3
+ -- Routing needs it because some upstreams gate models on it. CodeBuddy
4
+ -- refuses a set of models on a free plan with `11158 "model [X] is not allowed
5
+ -- for user type [personal_free_user]"`, and nothing in its model directory
6
+ -- predicts which — only the account's own plan does. Without a persisted plan
7
+ -- the router cannot tell a pro account from a free one and would keep sending
8
+ -- pro-only traffic to an account that is certain to be refused.
9
+ --
10
+ -- Persisted rather than read from the quota cache: the cache is a 900 second
11
+ -- in-process TTL and empty on a cold start, so an account's plan would be
12
+ -- unknown exactly when a freshly booted process makes its first decisions.
13
+ -- The row is the only place that survives a restart.
14
+ --
15
+ -- Nullable: most providers report no plan at all, and an account whose plan
16
+ -- was never read must say so rather than inventing one.
17
+ ALTER TABLE "provider_accounts"
18
+ ADD COLUMN IF NOT EXISTS "quota_plan" text;
@@ -0,0 +1,15 @@
1
+ -- Whether the provider publishes this model as free.
2
+ --
3
+ -- Qoder states `is_free` per row in its own model directory, so the tier is a
4
+ -- fact the provider reports rather than something we have to infer. That is the
5
+ -- opposite of `pro_only`, which exists because CodeBuddy publishes nothing that
6
+ -- predicts plan access and only the refusal itself is a signal.
7
+ --
8
+ -- Nullable three-valued on purpose, matching `pro_only`: `true` is free,
9
+ -- `false` is billed, and `NULL` is "the provider did not state it". Qoder omits
10
+ -- the field entirely for several rows, and collapsing that absence into `false`
11
+ -- would assert a measured fact for models nobody measured. Absence must also
12
+ -- stay distinct from `false` on the surface, or a row that simply predates this
13
+ -- column would render as a billed model.
14
+ ALTER TABLE "models"
15
+ ADD COLUMN IF NOT EXISTS "free_tier" boolean;
@@ -0,0 +1,18 @@
1
+ -- Which payload stages were captured for a request, as a bitmask.
2
+ --
3
+ -- The detail drawer shows four panels (client request, provider request,
4
+ -- provider response, client response), and the Usage list shows one icon per
5
+ -- panel. Reading the frame to learn which stages exist costs an inflate per
6
+ -- row, so the writer records presence here at capture time and the list reads
7
+ -- it with the indexed row it already fetches.
8
+ --
9
+ -- Bit 0 (1) = request_body, bit 1 (2) = provider_request_body,
10
+ -- bit 2 (4) = provider_response_body, bit 3 (8) = client_response_body.
11
+ -- `response_body` is the legacy alias of the client response and is not
12
+ -- tracked: it never names a stage the other four do not.
13
+ --
14
+ -- Nullable with no default: NULL means the row predates this column, and the
15
+ -- list renders every icon greyed — an old row never observed presence, so
16
+ -- inventing it would be a claim.
17
+ ALTER TABLE "telemetry_payloads"
18
+ ADD COLUMN "stages" integer;
@@ -0,0 +1,20 @@
1
+ -- The public slug a share page is reachable at, held in the clear.
2
+ --
3
+ -- `token_encrypted` is the bearer token under the deployment's encryption key,
4
+ -- so a link cannot be re-displayed after that key changes — which is exactly
5
+ -- what happens when a backup is restored onto a fresh deployment. A share page
6
+ -- then dies on arrival even though the backup carried its row.
7
+ --
8
+ -- The slug is the half of the link that is not a secret: it names the page in
9
+ -- the URL and is looked up by index. Holding it in the clear means a restore
10
+ -- keeps every share page reachable under the same address, independent of the
11
+ -- encryption key.
12
+ --
13
+ -- Nullable with no default: rows written before this column have no slug, and
14
+ -- inventing one would publish an address the page never had.
15
+ ALTER TABLE "share_links"
16
+ ADD COLUMN "slug" text;
17
+
18
+ CREATE UNIQUE INDEX "share_links_slug_uidx"
19
+ ON "share_links" ("slug")
20
+ WHERE "slug" IS NOT NULL;
@@ -0,0 +1,18 @@
1
+ -- Whether the upstream refuses this model on a free plan.
2
+ --
3
+ -- CodeBuddy is why this is a column and not a derived rule. Its console
4
+ -- directory publishes no field that predicts access: `credits` and `vendor`
5
+ -- were both measured against real dispatches and neither correlates — one model
6
+ -- billed at `x1.62` is served on a free account while one billed at `x0.14` is
7
+ -- refused. The only signal is the refusal itself, `11158 "model [X] is not
8
+ -- allowed for user type [personal_free_user]"`, so the answer has to be
9
+ -- recorded per model rather than computed from the catalog.
10
+ --
11
+ -- Nullable three-valued on purpose. `true` is refused on a free plan and
12
+ -- `false` is served there; `NULL` is "nobody established it", which is the
13
+ -- state of every model on every provider that publishes nothing about plan
14
+ -- access. Collapsing that into `false` would assert, for thousands of rows, a
15
+ -- fact nobody measured — and would make a pro-only row that predates this
16
+ -- column indistinguishable from a genuinely free one.
17
+ ALTER TABLE "models"
18
+ ADD COLUMN "pro_only" boolean;
@@ -0,0 +1,18 @@
1
+ -- The plan the provider reports for this account, last seen by a quota read.
2
+ --
3
+ -- Routing needs it because some upstreams gate models on it. CodeBuddy
4
+ -- refuses a set of models on a free plan with `11158 "model [X] is not allowed
5
+ -- for user type [personal_free_user]"`, and nothing in its model directory
6
+ -- predicts which — only the account's own plan does. Without a persisted plan
7
+ -- the router cannot tell a pro account from a free one and would keep sending
8
+ -- pro-only traffic to an account that is certain to be refused.
9
+ --
10
+ -- Persisted rather than read from the quota cache: the cache is a 900 second
11
+ -- in-process TTL and empty on a cold start, so an account's plan would be
12
+ -- unknown exactly when a freshly booted process makes its first decisions.
13
+ -- The row is the only place that survives a restart.
14
+ --
15
+ -- Nullable: most providers report no plan at all, and an account whose plan
16
+ -- was never read must say so rather than inventing one.
17
+ ALTER TABLE "provider_accounts"
18
+ ADD COLUMN "quota_plan" text;
@@ -0,0 +1,15 @@
1
+ -- Whether the provider publishes this model as free.
2
+ --
3
+ -- Qoder states `is_free` per row in its own model directory, so the tier is a
4
+ -- fact the provider reports rather than something we have to infer. That is the
5
+ -- opposite of `pro_only`, which exists because CodeBuddy publishes nothing that
6
+ -- predicts plan access and only the refusal itself is a signal.
7
+ --
8
+ -- Nullable three-valued on purpose, matching `pro_only`: `true` is free,
9
+ -- `false` is billed, and `NULL` is "the provider did not state it". Qoder omits
10
+ -- the field entirely for several rows, and collapsing that absence into `false`
11
+ -- would assert a measured fact for models nobody measured. Absence must also
12
+ -- stay distinct from `false` on the surface, or a row that simply predates this
13
+ -- column would render as a billed model.
14
+ ALTER TABLE "models"
15
+ ADD COLUMN "free_tier" boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cartethyia",
3
- "version": "2.4.6",
3
+ "version": "2.5.1",
4
4
  "description": "A powerful, self-hosted reverse proxy for OpenAI- and Anthropic-compatible AI clients, powered by native Bun and Elysia. It serves both protocols from a single server, routes every model across any configured provider, and translates requests and responses in real time — streaming and prompt caching included. Underneath it runs a full delivery engine: per-account health tracking, quota and cooldown enforcement, automatic failover across pools and providers, usage accounting, and telemetry. Point OpenCode, Cline, Claude Code, Codex, or any compatible client at Cartethyia and it keeps its native protocol while the gateway absorbs every provider-specific difference behind it. Runs on embedded SQLite or external PostgreSQL with optional Redis coordination, ships as a global CLI with a system-tray console, and needs only Bun 1.4+ alongside Node.js/npm.",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "build": "bun run scripts/binary/build.ts",
46
46
  "prepack": "bun run dashboard:build",
47
47
  "setup": "bun run scripts/cli/command/setup.ts",
48
- "start": "bun run scripts/binary/start.ts",
48
+ "start": "bun run scripts/cli/main.ts start",
49
49
  "doctor": "bun run scripts/cli/command/doctor.ts",
50
50
  "probe:egress": "bun run scripts/operations/probe-egress.ts",
51
51
  "restart": "bun run scripts/binary/restart.ts",
@@ -78,6 +78,7 @@
78
78
  "pg": "8.23.0",
79
79
  "pino": "^10.3.1",
80
80
  "pino-pretty": "^13.1.3",
81
+ "protobufjs": "^7.6.5",
81
82
  "socks-proxy-agent": "^10.1.0",
82
83
  "systray2": "2.1.4",
83
84
  "typebox": "1.3.27",
@@ -1,4 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
2
5
  import { isNewerRelease } from "../../../src/console/updates/version-compare";
3
6
  import { resolvePackagePath } from "../../../src/config/package-paths";
4
7
  import { resolvePort } from "../../../src/config/runtime";
@@ -7,7 +10,7 @@ import { startGateway } from "../gateway/spawn";
7
10
  import { stopGateway } from "../gateway/stop";
8
11
  import { ENV_PATH, readEnvFile } from "../support/env";
9
12
  import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
10
- import { launchBackground, openUrl } from "../ui/background";
13
+ import { launchBackgroundWithStatus, openUrl } from "../ui/background";
11
14
  import { chooseFromMenu, confirmByTyping, confirmStep } from "../ui/prompt";
12
15
  import { renderWordmark } from "../ui/wordmark";
13
16
  import { exportBackup, resetConsoleAccount } from "./admin";
@@ -185,20 +188,36 @@ export async function startMenu(): Promise<void> {
185
188
  return;
186
189
  },
187
190
  async () => {
188
- // Start a detached `tray` process that owns the gateway from scratch,
189
- // then drain this one so the new process can claim the port. This is
190
- // the pattern 9router uses and it is simpler than handing a live
191
- // gateway between processes: the tray is a normal first-class run, not
192
- // an adopted orphan, so Exit and update behave the same whether the
193
- // gateway was started here or from `carte --tray`.
194
- console.log("Starting the tray in the background…");
195
- launchBackground(["run", resolvePackagePath("scripts", "cli", "main.ts"), "tray"]);
191
+ // Drain first, then start the tray.
192
+ //
193
+ // The tray's own startup probe refuses to boot when the port is busy,
194
+ // so starting it before this process has let go made it fail with
195
+ // "already running on port" every time — while the menu still printed
196
+ // that everything was running, because a detached child with no stdio
197
+ // cannot report anything back. Releasing the port first is the fix, and
198
+ // waiting on the child's signal is what stops the menu from claiming
199
+ // success for a child that died.
200
+ console.log("Stopping the gateway to free the port…");
196
201
  running = false;
197
202
  stopping = true;
198
203
  handedOff = true;
199
204
  await shutDown();
200
- console.log(`Cartethyia is running in the background at ${dashboardUrl}.`);
201
- console.log("The tray icon owns it; use Exit there to stop it.");
205
+ console.log("Starting the tray in the background…");
206
+ const signalPath = join(tmpdir(), `cartethyia-tray-menu-${process.pid}-${randomUUID()}.json`);
207
+ try {
208
+ const url = await launchBackgroundWithStatus(
209
+ ["run", resolvePackagePath("scripts", "cli", "main.ts"), "tray"],
210
+ { signalPath },
211
+ );
212
+ console.log(`Cartethyia is running in the background at ${url || dashboardUrl}.`);
213
+ console.log("The tray icon owns it; use Exit there to stop it.");
214
+ } catch (error) {
215
+ handedOff = false;
216
+ console.error(
217
+ `Could not hand off to the tray: ${error instanceof Error ? error.message : "unknown error"}`,
218
+ );
219
+ console.error("Cartethyia is stopped. Start it again from this menu.");
220
+ }
202
221
  },
203
222
  async () => {
204
223
  running = false;
@@ -1,13 +1,77 @@
1
- import { ensureDrainToken } from "../gateway/drain-token";
2
-
3
- /**
4
- * Runs the gateway in the foreground, in this process.
5
- *
6
- * A foreground run still needs a drain token on disk: the gateway registers
7
- * `/admin/drain` only when one is set, and "Hide to tray" hands this run to a
8
- * tray that drains it through that route.
9
- */
10
- export async function startForeground(): Promise<void> {
11
- process.env.CARTETHYIA_DRAIN_TOKEN ??= ensureDrainToken();
12
- await import("../../../src/main");
13
- }
1
+ import { resolvePort } from "../../../src/config/runtime";
2
+ import { ensureDrainToken } from "../gateway/drain-token";
3
+ import { clearInstanceLock, pidAlive, readInstanceLock, writeInstanceLock } from "../gateway/instance-lock";
4
+
5
+ /**
6
+ * Runs the gateway in the foreground, in this process.
7
+ *
8
+ * A foreground run still needs a drain token on disk: the gateway registers
9
+ * `/admin/drain` only when one is set, and "Hide to tray" hands this run to a
10
+ * tray that drains it through that route.
11
+ *
12
+ * Unlike the menu and tray paths this one does not spawn a child, so the
13
+ * port-busy check has to happen here too — otherwise a stale gateway makes a
14
+ * plain `cartethyia start` fail from inside `src/main` with a message that says
15
+ * nothing about what to do.
16
+ */
17
+ export async function startForeground(): Promise<void> {
18
+ const token = ensureDrainToken();
19
+ process.env.CARTETHYIA_DRAIN_TOKEN ??= token;
20
+ const port = resolvePort();
21
+ const blocker = await inspectPortHolder(port, token);
22
+ if (blocker !== null) throw new Error(blocker);
23
+ writeInstanceLock(port, process.pid, token);
24
+ await import("../../../src/main");
25
+ }
26
+
27
+ /**
28
+ * What to tell the operator when the port is already taken, or `null` when the
29
+ * port is free (or was freed).
30
+ *
31
+ * Same rule as the spawned path: only ever stop a gateway that proves it is
32
+ * this install's own. A drain request is token-gated, so a 401 means another
33
+ * Cartethyia install and anything else is not a gateway at all — neither may be
34
+ * touched, and both are reported instead.
35
+ */
36
+ async function inspectPortHolder(port: number, token: string): Promise<string | null> {
37
+ const up = await fetch(`http://127.0.0.1:${port}/health`, {
38
+ signal: AbortSignal.timeout(1_000),
39
+ }).catch(() => null);
40
+ if (up === null) {
41
+ // Port is free. Drop a stale marker from a crashed run so the next start
42
+ // does not read a dead pid as a live gateway.
43
+ const lock = readInstanceLock();
44
+ if (lock !== null && lock.port === port && !pidAlive(lock.pid)) clearInstanceLock();
45
+ return null;
46
+ }
47
+ const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
48
+ method: "POST",
49
+ headers: { "x-drain-token": token },
50
+ signal: AbortSignal.timeout(3_000),
51
+ }).catch(() => null);
52
+ if (response === null) {
53
+ return `Port ${port} is busy and nothing there answers a drain request. Stop whatever holds it, or set a different PORT.`;
54
+ }
55
+ if (response.status === 401) {
56
+ return `Another Cartethyia gateway is already running on port ${port} under a different install. Stop it from its own terminal or tray — this install will not stop a gateway it does not own.`;
57
+ }
58
+ if (response.status === 403) {
59
+ return `Port ${port} refused a drain from loopback. Stop what holds it, then start again.`;
60
+ }
61
+ if (response.status !== 202) {
62
+ return `Port ${port} is busy and refused to drain (HTTP ${response.status}). Stop it manually, then start again.`;
63
+ }
64
+ // Drain accepted; wait for the port to actually close.
65
+ const deadline = Date.now() + 30_000;
66
+ while (Date.now() < deadline) {
67
+ const stillUp = await fetch(`http://127.0.0.1:${port}/health`, {
68
+ signal: AbortSignal.timeout(500),
69
+ }).catch(() => null);
70
+ if (stillUp === null) {
71
+ clearInstanceLock();
72
+ return null;
73
+ }
74
+ await Bun.sleep(200);
75
+ }
76
+ return `The gateway on port ${port} accepted a drain but did not release the port within 30 seconds. Stop it manually, then start again.`;
77
+ }
@@ -17,7 +17,29 @@ import { printUpdateInstructions } from "./update";
17
17
  * an empty console tab open for the tray's whole lifetime, and that window is
18
18
  * owned by Windows Terminal — not by this process, so it cannot be found and
19
19
  * hidden from inside.
20
+ *
21
+ * The gateway it starts is detached, which is what makes the health poll below
22
+ * necessary — see {@link TRAY_HEALTH_POLL_MS}.
20
23
  */
24
+
25
+ /**
26
+ * How often the tray asks whether the gateway is still answering.
27
+ *
28
+ * 10 s: often enough that an operator who stops the gateway by hand sees the
29
+ * icon go away, and far below the cost of a request. A detached gateway can no
30
+ * longer be waited on, so this poll is the only signal left.
31
+ */
32
+ const TRAY_HEALTH_POLL_MS = 10_000;
33
+
34
+ /**
35
+ * Consecutive misses to forgive before a dead gateway closes the tray.
36
+ *
37
+ * One refusal is not a stopped gateway — a probe can lose a race with startup
38
+ * or a slow request — and closing the icon on one would make startup feel
39
+ * random.
40
+ */
41
+ const TRAY_HEALTH_MISSES_BEFORE_CLOSE = 3;
42
+
21
43
  export async function startTray(): Promise<void> {
22
44
  const port = resolvePort();
23
45
  const drainToken = ensureDrainToken();
@@ -137,7 +159,13 @@ export async function startTray(): Promise<void> {
137
159
 
138
160
  try {
139
161
  await tray.ready();
140
- gateway = await startGateway(port, drainToken);
162
+ // Detached, unlike every other caller: the gateway must outlive this
163
+ // process. Attached, it dies with the tray — and the tray dies with the
164
+ // terminal that launched it, so closing that terminal took the gateway
165
+ // down too and "Hide to tray" left nothing serving. `stopGateway` drains
166
+ // over HTTP before it ever reaches for `kill()`, so detaching costs the
167
+ // exit path nothing: the tray can still stop the gateway on Exit.
168
+ gateway = await startGateway(port, drainToken, { detached: true });
141
169
  // The launcher that spawned this tray is blocked on a signal file: it
142
170
  // cannot read our stdio, because the tray is detached. Without this write
143
171
  // it waits out its 60 s deadline and reports a failure for a tray that is
@@ -146,17 +174,28 @@ export async function startTray(): Promise<void> {
146
174
  console.log(`Cartethyia is running at ${dashboardUrl}`);
147
175
  void refreshVersion();
148
176
 
177
+ let misses = 0;
178
+ // A detached child's `exited` never settles on Windows, so the old
179
+ // `await current.exited` would have parked here forever and the tray would
180
+ // never notice the gateway going away. Poll its health endpoint instead:
181
+ // that answers for a process we no longer own, which is the whole point of
182
+ // detaching it. The probe is the same one startup used, so a gateway that
183
+ // is up reads as up regardless of who spawned it.
149
184
  while (!closing) {
150
- const current: Bun.Subprocess | null = gateway;
151
- if (!current) {
152
- await Bun.sleep(100);
185
+ await Bun.sleep(TRAY_HEALTH_POLL_MS);
186
+ if (closing || gateway === null) continue;
187
+ const alive = await fetch(`http://127.0.0.1:${port}/health`, {
188
+ signal: AbortSignal.timeout(2_000),
189
+ }).then((response) => response.ok).catch(() => false);
190
+ if (alive) {
191
+ misses = 0;
153
192
  continue;
154
193
  }
155
- const code = await current.exited;
156
- if (gateway !== current || closing) continue;
194
+ misses += 1;
195
+ if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
157
196
  gateway = null;
158
- console.error(`Cartethyia exited with code ${code}.`);
159
- await close(code ?? 1);
197
+ console.error("Cartethyia stopped answering; the tray is closing.");
198
+ await close(1);
160
199
  }
161
200
  } catch (error) {
162
201
  await close(1);
@@ -8,7 +8,7 @@
8
8
  * the same shape 9router uses.
9
9
  */
10
10
  export function updateCommand(): string {
11
- return "npm install --global cartethyia@latest --prefer-online";
11
+ return "npm install -g cartethyia@latest --prefer-online";
12
12
  }
13
13
 
14
14
  /** Prints the "run this now" block after Cartethyia has stopped. */
@@ -0,0 +1,105 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * A marker that one gateway owns this install's port.
6
+ *
7
+ * The port probe alone cannot tell a healthy gateway from a stranger's process:
8
+ * something answering on 12800 is not necessarily ours, so a start that found
9
+ * the port busy either failed outright or — worse — would have to guess before
10
+ * stopping anything. The lock names the PID and the token it was written with,
11
+ * which is what makes "is this our gateway?" answerable without guessing.
12
+ *
13
+ * It is advisory, not an OS lock: a stale file from a crashed process is the
14
+ * normal case this exists to recover from, so presence alone never blocks a
15
+ * start. Liveness is decided by probing the port, and identity by the token.
16
+ */
17
+ export interface InstanceLock {
18
+ readonly pid: number;
19
+ readonly port: number;
20
+ /** Fingerprint of the drain token, never the token itself. */
21
+ readonly tokenFingerprint: string;
22
+ readonly startedAt: string;
23
+ }
24
+
25
+ const LOCK_FILE = "instance.json";
26
+
27
+ function lockPath(): string | null {
28
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
29
+ return configDir ? join(configDir, LOCK_FILE) : null;
30
+ }
31
+
32
+ /** A short, non-reversible fingerprint so the token itself is never written. */
33
+ export function fingerprint(token: string): string {
34
+ let hash = 0;
35
+ for (let i = 0; i < token.length; i += 1) {
36
+ hash = (hash * 31 + token.charCodeAt(i)) | 0;
37
+ }
38
+ return (hash >>> 0).toString(16).padStart(8, "0");
39
+ }
40
+
41
+ export function writeInstanceLock(port: number, pid: number, token: string): void {
42
+ const path = lockPath();
43
+ if (path === null) return;
44
+ const lock: InstanceLock = {
45
+ pid,
46
+ port,
47
+ tokenFingerprint: fingerprint(token),
48
+ startedAt: new Date().toISOString(),
49
+ };
50
+ try {
51
+ writeFileSync(path, JSON.stringify(lock), { encoding: "utf8", mode: 0o600 });
52
+ } catch {
53
+ // A read-only config directory costs the marker, not the run.
54
+ }
55
+ }
56
+
57
+ export function readInstanceLock(): InstanceLock | null {
58
+ const path = lockPath();
59
+ if (path === null || !existsSync(path)) return null;
60
+ try {
61
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
62
+ if (
63
+ parsed !== null &&
64
+ typeof parsed === "object" &&
65
+ "pid" in parsed &&
66
+ typeof parsed.pid === "number" &&
67
+ "port" in parsed &&
68
+ typeof parsed.port === "number" &&
69
+ "tokenFingerprint" in parsed &&
70
+ typeof parsed.tokenFingerprint === "string"
71
+ ) {
72
+ const startedAt = "startedAt" in parsed && typeof parsed.startedAt === "string" ? parsed.startedAt : "";
73
+ return {
74
+ pid: parsed.pid,
75
+ port: parsed.port,
76
+ tokenFingerprint: parsed.tokenFingerprint,
77
+ startedAt,
78
+ };
79
+ }
80
+ } catch {
81
+ // A corrupt marker reads as none.
82
+ }
83
+ return null;
84
+ }
85
+
86
+ export function clearInstanceLock(): void {
87
+ const path = lockPath();
88
+ if (path === null) return;
89
+ try {
90
+ unlinkSync(path);
91
+ } catch {
92
+ // Already gone, or the directory is read-only.
93
+ }
94
+ }
95
+
96
+ /** Whether a process id is alive. `0` is never a real pid. */
97
+ export function pidAlive(pid: number): boolean {
98
+ if (!Number.isInteger(pid) || pid <= 0) return false;
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }