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.
- package/README.md +45 -13
- package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js +1 -0
- package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-Bb9CNuoh.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-BXUaRCQQ.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js +1 -0
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-BOpI_LOH.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-DAkdvJ4k.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-DeT-VXHd.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-CytxSeGP.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +7 -0
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +1 -0
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-8-eVadhc.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-uEIXPyGO.js} +1 -1
- package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +1 -0
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-DqK5E26W.js} +4 -4
- package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +56 -0
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
- package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
- package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
- package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
- package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
- package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
- package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
- package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
- package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
- package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
- package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
- package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
- package/dist/dashboard/assets/entry--yj84zk3.js +4 -0
- package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
- package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
- package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
- package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
- package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
- package/dist/dashboard/assets/{index-D70pChfn.js → index-CcD1eSJ6.js} +2 -2
- package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
- package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
- package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
- package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
- package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
- package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
- package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
- package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
- package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
- package/dist/dashboard/assets/providers/cursor.webp +0 -0
- package/dist/dashboard/assets/providers/trae.webp +0 -0
- package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
- package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
- package/dist/dashboard/assets/select-XPJ-dIw3.js +41 -0
- package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
- package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
- package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
- package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
- package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
- package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
- package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
- package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
- package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -52
- package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
- package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
- package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
- package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
- package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
- package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
- package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
- package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
- package/package.json +3 -2
- package/scripts/cli/command/console-menu.ts +30 -11
- package/scripts/cli/command/start.ts +77 -13
- package/scripts/cli/command/tray.ts +47 -8
- package/scripts/cli/command/update.ts +1 -1
- package/scripts/cli/gateway/instance-lock.ts +105 -0
- package/scripts/cli/gateway/spawn.ts +173 -101
- package/scripts/cli/ui/background.ts +110 -49
- package/src/boot/app.ts +20 -6
- package/src/boot/dependencies.ts +19 -0
- package/src/boot/elysia-typebox-bridge.ts +37 -0
- package/src/boot/lifecycle.ts +2 -0
- package/src/config/cooldowns.ts +138 -138
- package/src/config/providers.ts +5 -5
- package/src/config/runtime.ts +19 -3
- package/src/config/spec.ts +9 -0
- package/src/console/api-keys/contracts.ts +414 -391
- package/src/console/api-keys/routes.ts +880 -870
- package/src/console/auth/account-reset-store.ts +14 -0
- package/src/console/auth/account-reset.ts +3 -2
- package/src/console/auth/setup-service.ts +6 -3
- package/src/console/domain-registration.ts +9 -2
- package/src/console/observability/contracts.ts +475 -455
- package/src/console/observability/store.ts +1015 -952
- package/src/console/providers/catalog/contracts.ts +31 -0
- package/src/console/providers/catalog/provider-operations.ts +8 -0
- package/src/console/providers/catalog/store.ts +1532 -1525
- package/src/console/providers/oauth/callback-listener.ts +18 -3
- package/src/console/providers/oauth/routes.ts +38 -15
- package/src/console/quota/refresh.ts +19 -0
- package/src/console/updates/routes.ts +162 -4
- package/src/main.ts +3 -0
- package/src/network/http2-bidi.ts +85 -0
- package/src/network/outbound-fetch.ts +1 -26
- package/src/network/pool/agent.ts +478 -460
- package/src/network/pool/resolver.ts +1104 -544
- package/src/network/pool-health-machine.ts +12 -5
- package/src/observability/payload-capture.ts +344 -324
- package/src/persistence/db-pg.ts +9 -3
- package/src/persistence/db-sqlite.ts +497 -495
- package/src/persistence/health-event-flush.ts +137 -0
- package/src/persistence/migrate.ts +181 -129
- package/src/persistence/schema.ts +1360 -1295
- package/src/persistence/settings-revision.ts +25 -0
- package/src/persistence/shared-cache.ts +217 -0
- package/src/persistence/stores/share-store.ts +556 -528
- package/src/persistence/usage-flush.ts +61 -1
- package/src/protocol/canonical-model.ts +23 -1
- package/src/protocol/response/chat.ts +143 -10
- package/src/protocol/response/omp-tool-call-markup.ts +221 -0
- package/src/providers/default-registry.ts +173 -1
- package/src/providers/discovery/openai-model-discovery.ts +39 -3
- package/src/providers/discovery/openrouter-free-roster.ts +98 -0
- package/src/providers/discovery/probing-service.ts +1022 -1001
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
- package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
- package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
- package/src/providers/integrations/claude/claude.ts +606 -582
- package/src/providers/integrations/cline/cline-quota.ts +18 -16
- package/src/providers/integrations/cline/cline.ts +539 -468
- package/src/providers/integrations/commandcode/commandcode.ts +675 -541
- package/src/providers/integrations/commandcode/discovery.ts +116 -0
- package/src/providers/integrations/commandcode/quota.ts +160 -0
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
- package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
- package/src/providers/integrations/cursor/cursor-request.ts +471 -0
- package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
- package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
- package/src/providers/integrations/cursor/cursor.ts +960 -0
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
- package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
- package/src/providers/integrations/github/github.ts +4 -4
- package/src/providers/integrations/hermes.ts +79 -8
- package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
- package/src/providers/integrations/kilo/kilo.ts +153 -146
- package/src/providers/integrations/opencode/provider.ts +532 -538
- package/src/providers/integrations/openrouter/provider.ts +139 -281
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
- package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
- package/src/providers/integrations/qoder/qoder.ts +865 -730
- package/src/providers/integrations/trae/trae-catalog.ts +190 -0
- package/src/providers/integrations/trae/trae-headers.ts +149 -0
- package/src/providers/integrations/trae/trae-oauth.ts +773 -0
- package/src/providers/integrations/trae/trae-protocol.ts +400 -0
- package/src/providers/integrations/trae/trae-realm.ts +220 -0
- package/src/providers/integrations/trae/trae.ts +458 -0
- package/src/providers/operations/account-health-service.ts +107 -10
- package/src/providers/operations/client-versions.ts +133 -6
- package/src/providers/operations/provider-catalog-seeder.ts +95 -86
- package/src/providers/operations/provider-credential-service.ts +46 -7
- package/src/providers/provider-contracts.ts +76 -0
- package/src/providers/provider-metadata.ts +78 -6
- package/src/providers/provider-model-definition.ts +373 -349
- package/src/security/api-key-auth.ts +126 -8
- package/src/transport/contracts.ts +3 -0
- package/src/transport/dispatch/attempt-finalize.ts +32 -1
- package/src/transport/dispatch/attempt-loop.ts +530 -515
- package/src/transport/dispatch/generation.ts +88 -21
- package/src/transport/dispatch/leases.ts +99 -2
- package/src/transport/dispatch/proxy-request.ts +60 -3
- package/src/transport/dispatch/streaming/finalize.ts +16 -1
- package/src/transport/dispatch/streaming/stream.ts +22 -2
- package/src/transport/dispatch/upstream.ts +19 -1
- package/src/transport/guards/auth-guard.ts +26 -1
- package/src/transport/guards/body-policy.ts +5 -4
- package/src/transport/request/preparer.ts +103 -0
- package/src/transport/request/state.ts +14 -0
- package/src/transport/routing/plan-gate.ts +45 -0
- package/src/transport/routing/route-catalog.ts +799 -777
- package/src/transport/routing/route-model.ts +52 -1
- package/src/transport/routing/router.ts +51 -0
- package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
- package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
- package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
- package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
- package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
- package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
- package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
- package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
- package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
- package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
- package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
- package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
- package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
- package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
- package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
- package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
- package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
- package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
- package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
- package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
- package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
- package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
- package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
- package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
- package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
- package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
- package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
- package/src/network/tls/bun-client-hello.ts +0 -112
- /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
- /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.
|
|
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/
|
|
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 {
|
|
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
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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(
|
|
201
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
if (
|
|
194
|
+
misses += 1;
|
|
195
|
+
if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
|
|
157
196
|
gateway = null;
|
|
158
|
-
console.error(
|
|
159
|
-
await close(
|
|
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
|
|
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
|
+
}
|