cartethyia 2.4.5 → 2.5.0
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/.env.example +4 -1
- package/README.md +63 -13
- package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
- package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
- package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
- package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.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-Df77V5I5.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
- package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
- package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
- package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
- package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
- package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
- package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
- package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
- 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/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
- package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
- package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
- package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.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/backup-file-gdKIlg8U.js.gz +0 -0
- package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
- package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
- package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
- package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
- package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
- package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
- package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
- package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
- package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
- package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
- package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
- package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
- package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
- package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
- package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
- package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
- package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
- package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
- package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
- package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
- package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
- package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
- package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
- package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
- package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
- package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
- package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
- package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
- package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
- package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
- package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
- package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
- package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
- package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
- package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
- package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
- package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
- package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
- package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
- package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
- package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
- package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
- package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
- package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
- package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
- package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
- package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
- package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
- package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
- package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
- package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
- package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
- package/dist/dashboard/assets/providers/cursor.webp +0 -0
- package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
- package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
- package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
- package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
- package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
- package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
- package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
- package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
- package/dist/dashboard/assets/providers/trae.webp +0 -0
- package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
- package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
- package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
- package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
- package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
- package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
- package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
- package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
- package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
- package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
- package/dist/dashboard/assets/select-C0Rer3gF.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/shield-check-DVS0Foqh.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
- package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
- package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
- package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
- package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
- package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
- package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
- package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
- package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
- package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
- package/dist/dashboard/assets/wrench-B6E1oEuY.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 +122 -0
- package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
- package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
- 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/0000_baseline.sql +4 -2
- package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
- package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -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 +26 -30
- package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
- package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
- package/scripts/cli/command/admin.ts +65 -0
- package/scripts/cli/command/console-menu.ts +246 -0
- package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
- package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
- package/scripts/cli/command/start.ts +77 -0
- package/scripts/cli/command/tray.ts +165 -0
- package/scripts/cli/command/update.ts +23 -0
- package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
- package/scripts/cli/gateway/instance-lock.ts +105 -0
- package/scripts/cli/gateway/spawn.ts +173 -0
- package/scripts/cli/gateway/stop.ts +51 -0
- package/scripts/cli/main.ts +94 -0
- package/scripts/cli/support/bun.ts +27 -0
- package/scripts/cli/support/env.ts +99 -0
- package/scripts/cli/support/platform.ts +64 -0
- package/scripts/cli/support/services.ts +88 -0
- package/scripts/cli/support/startup-signal.ts +23 -0
- package/scripts/cli/{version.ts → support/version.ts} +1 -1
- package/scripts/cli/tray/hidden-window.vbs +28 -0
- package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
- package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
- package/scripts/cli/ui/background.ts +110 -0
- package/src/boot/app.ts +23 -7
- package/src/boot/dependencies.ts +55 -13
- 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 +31 -5
- package/src/config/runtime.ts +19 -3
- package/src/config/spec.ts +20 -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/backup/routes.ts +8 -0
- package/src/console/cli-tools/contracts.ts +6 -6
- package/src/console/console-router.ts +5 -3
- package/src/console/dashboard-assets.ts +81 -11
- package/src/console/domain-registration.ts +9 -2
- package/src/console/observability/contracts.ts +475 -423
- package/src/console/observability/routes.ts +31 -6
- package/src/console/observability/store.ts +1015 -912
- package/src/console/providers/catalog/contracts.ts +31 -0
- package/src/console/providers/catalog/provider-operations.ts +8 -0
- package/src/console/providers/catalog/public-model-store.ts +91 -60
- package/src/console/providers/catalog/store.ts +1532 -1521
- package/src/console/providers/oauth/callback-listener.ts +18 -3
- package/src/console/providers/oauth/routes.ts +38 -15
- package/src/console/quota/account-quota-tenant.ts +9 -6
- package/src/console/quota/refresh.ts +19 -0
- package/src/console/response-compression.ts +69 -2
- package/src/console/routing/pools/probe-verdict.ts +72 -0
- package/src/console/share/share-router.ts +7 -4
- package/src/console/share/share-stats.ts +12 -5
- package/src/console/shared/read-through-cache.ts +133 -7
- package/src/console/updates/repository-updates.ts +39 -34
- 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 +87 -24
- 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/event-log.ts +29 -8
- package/src/observability/metrics.ts +40 -13
- package/src/observability/payload-capture.ts +344 -324
- package/src/observability/runtime-metrics.ts +7 -0
- 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 -1257
- 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/codex.ts +9 -75
- package/src/protocol/response/omp-tool-call-markup.ts +221 -0
- package/src/protocol/tool-identity.ts +43 -9
- package/src/providers/authentication/jwt-validator.ts +130 -187
- package/src/providers/authentication/oauth-flow-store.ts +16 -9
- package/src/providers/default-registry.ts +177 -1
- package/src/providers/discovery/catalog/catalog-format.ts +20 -0
- package/src/providers/discovery/models-dev-catalog.ts +39 -22
- package/src/providers/discovery/openai-model-discovery.ts +39 -3
- package/src/providers/discovery/openrouter-free-roster.ts +98 -0
- package/src/providers/discovery/probe-wire.ts +29 -5
- package/src/providers/discovery/probing-service.ts +1022 -1001
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
- package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
- package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -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 +115 -73
- package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
- package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
- package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
- 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/codex/codex.ts +5 -8
- 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/factory/factory-oauth.ts +7 -11
- 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/opencode-go-quota.ts +130 -0
- package/src/providers/integrations/opencode/provider.ts +532 -499
- 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 -263
- 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/shared/install-id.ts +29 -2
- 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 +143 -6
- package/src/providers/operations/model-catalog-cache.ts +18 -3
- package/src/providers/operations/provider-catalog-seeder.ts +95 -86
- package/src/providers/operations/provider-catalog-service.ts +76 -0
- 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 +39 -2
- package/src/transport/dispatch/attempt-loop.ts +530 -513
- package/src/transport/dispatch/empty-stream.ts +17 -26
- 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/failure-policy.ts +20 -0
- 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 -738
- package/src/transport/routing/route-model.ts +52 -1
- package/src/transport/routing/router.ts +51 -0
- package/src/transport/surface/chat/encode.ts +18 -31
- package/src/transport/surface/messages/stream.ts +3 -2
- package/src/transport/translation/tool-repair.ts +37 -16
- package/src/workers/account-identity-sweep.ts +14 -12
- package/src/workers/daily-checkin.ts +62 -0
- package/src/workers/quota-refresh-sweep.ts +27 -2
- package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
- package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
- package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
- package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
- package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
- package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
- package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
- package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
- package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
- package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
- package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
- package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
- package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
- package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
- package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
- package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
- package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
- package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
- package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
- package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
- package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
- package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
- package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
- package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
- package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
- package/scripts/cli/cli.ts +0 -771
- package/scripts/operations/probe-egress.ts +0 -335
- package/scripts/operations/restart.ts +0 -120
- package/scripts/shared/env.ts +0 -269
- /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
- /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
- /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
- /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { isNewerRelease } from "../../../src/console/updates/version-compare";
|
|
2
|
+
import { resolvePackagePath } from "../../../src/config/package-paths";
|
|
3
|
+
import { resolvePort } from "../../../src/config/runtime";
|
|
4
|
+
import { ensureDrainToken } from "../gateway/drain-token";
|
|
5
|
+
import { startGateway } from "../gateway/spawn";
|
|
6
|
+
import { stopGateway } from "../gateway/stop";
|
|
7
|
+
import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
|
|
8
|
+
import { reportTrayStartup } from "../support/startup-signal";
|
|
9
|
+
import { createTray } from "../tray";
|
|
10
|
+
import { openUrl } from "../ui/background";
|
|
11
|
+
import { exportBackup, resetConsoleAccount } from "./admin";
|
|
12
|
+
import { printUpdateInstructions } from "./update";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs the gateway with a tray icon. The tray is platform-specific (`tray/`):
|
|
16
|
+
* on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
|
|
17
|
+
* an empty console tab open for the tray's whole lifetime, and that window is
|
|
18
|
+
* owned by Windows Terminal — not by this process, so it cannot be found and
|
|
19
|
+
* hidden from inside.
|
|
20
|
+
*/
|
|
21
|
+
export async function startTray(): Promise<void> {
|
|
22
|
+
const port = resolvePort();
|
|
23
|
+
const drainToken = ensureDrainToken();
|
|
24
|
+
const dashboardUrl = `http://127.0.0.1:${port}/console`;
|
|
25
|
+
const repositoryUrl = "https://github.com/risunCode/cartethyia";
|
|
26
|
+
const currentVersion = await readInstalledVersion();
|
|
27
|
+
|
|
28
|
+
let gateway: Bun.Subprocess | null = null;
|
|
29
|
+
let closing = false;
|
|
30
|
+
let busy = false;
|
|
31
|
+
let resetArmed = false;
|
|
32
|
+
let latestVersion: string | null = null;
|
|
33
|
+
let closePromise: Promise<void> | undefined;
|
|
34
|
+
|
|
35
|
+
const tray = createTray({
|
|
36
|
+
iconPath: resolvePackagePath(
|
|
37
|
+
"scripts",
|
|
38
|
+
"cli",
|
|
39
|
+
"tray",
|
|
40
|
+
"assets",
|
|
41
|
+
process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
|
|
42
|
+
),
|
|
43
|
+
tooltip: `Cartethyia ${currentVersion}`,
|
|
44
|
+
items: [
|
|
45
|
+
{ kind: "action", id: "update", title: `Cartethyia ${currentVersion} \u00b7 checking\u2026`, enabled: true },
|
|
46
|
+
{ kind: "separator" },
|
|
47
|
+
{ kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
|
|
48
|
+
{ kind: "action", id: "reset", title: "Reset console password" },
|
|
49
|
+
{ kind: "action", id: "backup", title: "Backup" },
|
|
50
|
+
{ kind: "separator" },
|
|
51
|
+
{ kind: "action", id: "exit", title: "Exit" },
|
|
52
|
+
],
|
|
53
|
+
onSelect: (id: string) => void handleSelect(id),
|
|
54
|
+
onExit: () => void close(1),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const close = (exitCode = 0): Promise<void> => {
|
|
58
|
+
if (closePromise) return closePromise;
|
|
59
|
+
closing = true;
|
|
60
|
+
closePromise = (async () => {
|
|
61
|
+
const current: Bun.Subprocess | null = gateway;
|
|
62
|
+
gateway = null;
|
|
63
|
+
if (current) await stopGateway(current, port, drainToken);
|
|
64
|
+
await tray.close();
|
|
65
|
+
process.exitCode = exitCode;
|
|
66
|
+
})();
|
|
67
|
+
return closePromise;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const refreshVersion = async (): Promise<void> => {
|
|
71
|
+
const fetched = await readLatestPublishedVersion();
|
|
72
|
+
if (closing) return;
|
|
73
|
+
latestVersion = fetched;
|
|
74
|
+
if (fetched === null) {
|
|
75
|
+
tray.update("update", `Cartethyia ${currentVersion} \u00b7 check for updates`, true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const newer = isNewerRelease(currentVersion, fetched);
|
|
79
|
+
tray.update(
|
|
80
|
+
"update",
|
|
81
|
+
newer
|
|
82
|
+
? `Cartethyia ${currentVersion} \u00b7 update to ${fetched}`
|
|
83
|
+
: `Cartethyia ${currentVersion} \u00b7 up to date`,
|
|
84
|
+
// Always clickable: the row opens the repository, and only offers the
|
|
85
|
+
// update command when there is something newer.
|
|
86
|
+
true,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const runUpdate = async (): Promise<void> => {
|
|
91
|
+
if (busy) return;
|
|
92
|
+
busy = true;
|
|
93
|
+
const latest = await readLatestPublishedVersion();
|
|
94
|
+
await close();
|
|
95
|
+
printUpdateInstructions(currentVersion, latest);
|
|
96
|
+
process.exit(0);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
async function handleSelect(id: string): Promise<void> {
|
|
100
|
+
if (closing) return;
|
|
101
|
+
if (id === "exit") return void (await close());
|
|
102
|
+
if (id === "console") return void (await openUrl(dashboardUrl));
|
|
103
|
+
if (id === "backup") {
|
|
104
|
+
const result = await exportBackup(port, drainToken);
|
|
105
|
+
if (result !== null) {
|
|
106
|
+
console.log(
|
|
107
|
+
`Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (id === "reset") {
|
|
113
|
+
// A tray click cannot ask for the typed confirmation the menu uses, so
|
|
114
|
+
// the first click arms the action and the second one runs it.
|
|
115
|
+
if (!resetArmed) {
|
|
116
|
+
resetArmed = true;
|
|
117
|
+
tray.update("reset", "Reset console password \u00b7 click again to delete", true);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
resetArmed = false;
|
|
121
|
+
tray.update("reset", "Reset console password", true);
|
|
122
|
+
await resetConsoleAccount(port, drainToken);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (id === "update") {
|
|
126
|
+
// Nothing to install: the row is the way to the repository instead.
|
|
127
|
+
if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
|
|
128
|
+
await openUrl(repositoryUrl);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await runUpdate();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
process.once("SIGINT", () => void close());
|
|
136
|
+
process.once("SIGTERM", () => void close());
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await tray.ready();
|
|
140
|
+
gateway = await startGateway(port, drainToken);
|
|
141
|
+
// The launcher that spawned this tray is blocked on a signal file: it
|
|
142
|
+
// cannot read our stdio, because the tray is detached. Without this write
|
|
143
|
+
// it waits out its 60 s deadline and reports a failure for a tray that is
|
|
144
|
+
// running fine.
|
|
145
|
+
reportTrayStartup({ status: "ready", url: dashboardUrl });
|
|
146
|
+
console.log(`Cartethyia is running at ${dashboardUrl}`);
|
|
147
|
+
void refreshVersion();
|
|
148
|
+
|
|
149
|
+
while (!closing) {
|
|
150
|
+
const current: Bun.Subprocess | null = gateway;
|
|
151
|
+
if (!current) {
|
|
152
|
+
await Bun.sleep(100);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const code = await current.exited;
|
|
156
|
+
if (gateway !== current || closing) continue;
|
|
157
|
+
gateway = null;
|
|
158
|
+
console.error(`Cartethyia exited with code ${code}.`);
|
|
159
|
+
await close(code ?? 1);
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await close(1);
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The update path, which prints a command rather than running one.
|
|
3
|
+
*
|
|
4
|
+
* npm cannot replace this package's directory while a process is executing out
|
|
5
|
+
* of it: on Windows the rename fails with EBUSY and npm exits 14. Stopping the
|
|
6
|
+
* gateway is not enough either, because the CLI or tray itself is the process
|
|
7
|
+
* holding the directory. So every caller stops, prints the command, and exits —
|
|
8
|
+
* the same shape 9router uses.
|
|
9
|
+
*/
|
|
10
|
+
export function updateCommand(): string {
|
|
11
|
+
return "npm install -g cartethyia@latest --prefer-online";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Prints the "run this now" block after Cartethyia has stopped. */
|
|
15
|
+
export function printUpdateInstructions(from: string, to: string | null): void {
|
|
16
|
+
console.log("");
|
|
17
|
+
console.log(`Update available: ${from} \u2192 ${to ?? "latest"}`);
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log("Run this now that Cartethyia has stopped:");
|
|
20
|
+
console.log("");
|
|
21
|
+
console.log(` ${updateCommand()}`);
|
|
22
|
+
console.log("");
|
|
23
|
+
}
|
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The install's drain token, persisted in the config directory.
|
|
6
|
-
*
|
|
7
|
-
* Why it persists instead of being generated per run:
|
|
8
|
-
* a different process
|
|
9
|
-
*
|
|
10
|
-
* start could never
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* It is kept out of `.env` on purpose: it authorises a shutdown, so it belongs
|
|
14
|
-
* in a machine-written file the operator never hand-edits and never commits.
|
|
15
|
-
* Mode 0600 where the platform honours it.
|
|
16
|
-
*/
|
|
17
|
-
const RUNTIME_STATE_FILE = "runtime.json";
|
|
18
|
-
|
|
19
|
-
interface RuntimeState {
|
|
20
|
-
readonly drainToken: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function statePath(): string | null {
|
|
24
|
-
const configDir = process.env.CARTETHYIA_CONFIG_DIR;
|
|
25
|
-
return configDir ? join(configDir, RUNTIME_STATE_FILE) : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function readState(): RuntimeState | null {
|
|
29
|
-
const path = statePath();
|
|
30
|
-
if (path === null || !existsSync(path)) return null;
|
|
31
|
-
try {
|
|
32
|
-
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
-
if (
|
|
34
|
-
parsed !== null &&
|
|
35
|
-
typeof parsed === "object" &&
|
|
36
|
-
"drainToken" in parsed &&
|
|
37
|
-
typeof parsed.drainToken === "string" &&
|
|
38
|
-
parsed.drainToken.length > 0
|
|
39
|
-
) {
|
|
40
|
-
return { drainToken: parsed.drainToken };
|
|
41
|
-
}
|
|
42
|
-
} catch {
|
|
43
|
-
// A corrupt or stale state file reads as "no token".
|
|
44
|
-
}
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Returns the install's drain token, creating and persisting one on first use.
|
|
50
|
-
*
|
|
51
|
-
* Every start calls this, so a gateway started from the menu, from `tray`, or
|
|
52
|
-
* from `cartethyia start` all authenticate with the same token and `stop`
|
|
53
|
-
* always has the one that works.
|
|
54
|
-
*/
|
|
55
|
-
export function ensureDrainToken(): string {
|
|
56
|
-
const existing = readState();
|
|
57
|
-
if (existing !== null) return existing.drainToken;
|
|
58
|
-
|
|
59
|
-
const token = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
|
60
|
-
const path = statePath();
|
|
61
|
-
if (path !== null) {
|
|
62
|
-
try {
|
|
63
|
-
writeFileSync(path, JSON.stringify({ drainToken: token }), {
|
|
64
|
-
encoding: "utf8",
|
|
65
|
-
mode: 0o600,
|
|
66
|
-
});
|
|
67
|
-
} catch {
|
|
68
|
-
// A read-only config directory still yields a usable in-process token;
|
|
69
|
-
// only `stop` from another process is lost.
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return token;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** The persisted token, or null when this install has never started. */
|
|
76
|
-
export function readPersistedDrainToken(): string | null {
|
|
77
|
-
return readState()?.drainToken ?? null;
|
|
78
|
-
}
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The install's drain token, persisted in the config directory.
|
|
6
|
+
*
|
|
7
|
+
* Why it persists instead of being generated per run: the tray that "Hide to
|
|
8
|
+
* tray" starts runs in a different process and drains the gateway through the
|
|
9
|
+
* loopback endpoint, so the token has to match across processes. A token
|
|
10
|
+
* regenerated on each start could never authenticate against a gateway another
|
|
11
|
+
* process had started.
|
|
12
|
+
*
|
|
13
|
+
* It is kept out of `.env` on purpose: it authorises a shutdown, so it belongs
|
|
14
|
+
* in a machine-written file the operator never hand-edits and never commits.
|
|
15
|
+
* Mode 0600 where the platform honours it.
|
|
16
|
+
*/
|
|
17
|
+
const RUNTIME_STATE_FILE = "runtime.json";
|
|
18
|
+
|
|
19
|
+
interface RuntimeState {
|
|
20
|
+
readonly drainToken: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function statePath(): string | null {
|
|
24
|
+
const configDir = process.env.CARTETHYIA_CONFIG_DIR;
|
|
25
|
+
return configDir ? join(configDir, RUNTIME_STATE_FILE) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readState(): RuntimeState | null {
|
|
29
|
+
const path = statePath();
|
|
30
|
+
if (path === null || !existsSync(path)) return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
if (
|
|
34
|
+
parsed !== null &&
|
|
35
|
+
typeof parsed === "object" &&
|
|
36
|
+
"drainToken" in parsed &&
|
|
37
|
+
typeof parsed.drainToken === "string" &&
|
|
38
|
+
parsed.drainToken.length > 0
|
|
39
|
+
) {
|
|
40
|
+
return { drainToken: parsed.drainToken };
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// A corrupt or stale state file reads as "no token".
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns the install's drain token, creating and persisting one on first use.
|
|
50
|
+
*
|
|
51
|
+
* Every start calls this, so a gateway started from the menu, from `tray`, or
|
|
52
|
+
* from `cartethyia start` all authenticate with the same token and `stop`
|
|
53
|
+
* always has the one that works.
|
|
54
|
+
*/
|
|
55
|
+
export function ensureDrainToken(): string {
|
|
56
|
+
const existing = readState();
|
|
57
|
+
if (existing !== null) return existing.drainToken;
|
|
58
|
+
|
|
59
|
+
const token = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
|
60
|
+
const path = statePath();
|
|
61
|
+
if (path !== null) {
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(path, JSON.stringify({ drainToken: token }), {
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
mode: 0o600,
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
// A read-only config directory still yields a usable in-process token;
|
|
69
|
+
// only `stop` from another process is lost.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return token;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The persisted token, or null when this install has never started. */
|
|
76
|
+
export function readPersistedDrainToken(): string | null {
|
|
77
|
+
return readState()?.drainToken ?? null;
|
|
78
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { openSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
clearInstanceLock,
|
|
4
|
+
fingerprint,
|
|
5
|
+
pidAlive,
|
|
6
|
+
readInstanceLock,
|
|
7
|
+
writeInstanceLock,
|
|
8
|
+
} from "./instance-lock";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { PACKAGE_ROOT, resolvePackagePath } from "../../../src/config/package-paths";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Frees the port when what holds it is this install's own gateway.
|
|
14
|
+
*
|
|
15
|
+
* Returns `null` when the port is free (or was successfully taken), and a
|
|
16
|
+
* message to fail with otherwise. It never kills a process it cannot identify:
|
|
17
|
+
* a stranger's service on this port is reported, not drained.
|
|
18
|
+
*
|
|
19
|
+
* The probe is two-step on purpose. `/health` alone is answered by anything, so
|
|
20
|
+
* it only says "something is there"; the drain request is what distinguishes
|
|
21
|
+
* ours from someone else's, because it is token-gated — and a 401 means another
|
|
22
|
+
* Cartethyia install, which this one must not stop.
|
|
23
|
+
*/
|
|
24
|
+
async function claimPort(port: number, drainToken: string): Promise<string | null> {
|
|
25
|
+
// A lock naming this install's own gateway is the strongest evidence
|
|
26
|
+
// available, so prefer it over the network probe when it is live.
|
|
27
|
+
const lock = readInstanceLock();
|
|
28
|
+
const ours = lock !== null && lock.port === port && lock.tokenFingerprint === fingerprint(drainToken);
|
|
29
|
+
|
|
30
|
+
const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "x-drain-token": drainToken },
|
|
33
|
+
signal: AbortSignal.timeout(3_000),
|
|
34
|
+
}).catch(() => null);
|
|
35
|
+
|
|
36
|
+
if (response === null) {
|
|
37
|
+
// Nothing answered a drain request. If our own lock names a dead pid, the
|
|
38
|
+
// marker is stale and nothing is holding the port — clear it and continue.
|
|
39
|
+
if (ours && !pidAlive(lock.pid)) {
|
|
40
|
+
clearInstanceLock();
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return `Port ${port} is busy and nothing there answers a drain request. Stop whatever holds it, or set a different PORT.`;
|
|
44
|
+
}
|
|
45
|
+
if (response.status === 401) {
|
|
46
|
+
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.`;
|
|
47
|
+
}
|
|
48
|
+
if (response.status === 403) {
|
|
49
|
+
return `Port ${port} refused a drain from loopback. Stop what holds it, then start again.`;
|
|
50
|
+
}
|
|
51
|
+
if (response.status !== 202) {
|
|
52
|
+
return `Port ${port} is busy and refused to drain (HTTP ${response.status}). Stop it manually, then start again.`;
|
|
53
|
+
}
|
|
54
|
+
// Drain accepted. Wait for the port to actually free: the gateway flushes its
|
|
55
|
+
// 202 before closing sockets, so acceptance is not release.
|
|
56
|
+
const deadline = Date.now() + 30_000;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
const stillUp = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
59
|
+
signal: AbortSignal.timeout(500),
|
|
60
|
+
}).catch(() => null);
|
|
61
|
+
if (stillUp === null) {
|
|
62
|
+
clearInstanceLock();
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
await Bun.sleep(200);
|
|
66
|
+
}
|
|
67
|
+
return `The gateway on port ${port} accepted a drain but did not release the port within 30 seconds. Stop it manually, then start again.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Starts the gateway and resolves once it answers its own health probe.
|
|
72
|
+
*
|
|
73
|
+
* The child is attached by default. A detached child cannot be killed from an
|
|
74
|
+
* `exit` handler on Windows (measured: `kill()` runs, the gateway keeps
|
|
75
|
+
* serving), and a terminal's Ctrl-C only reaches children in the same process
|
|
76
|
+
* group. Staying attached means Ctrl-C and the `exit` backstop both stop the
|
|
77
|
+
* gateway. "Hide to tray" passes `detached: true` because that gateway must
|
|
78
|
+
* outlive this process.
|
|
79
|
+
*/
|
|
80
|
+
export async function startGateway(
|
|
81
|
+
port: number,
|
|
82
|
+
drainToken: string,
|
|
83
|
+
options: { readonly detached?: boolean } = {},
|
|
84
|
+
): Promise<Bun.Subprocess> {
|
|
85
|
+
// Starting a second gateway on the same port used to fail with "stopped
|
|
86
|
+
// before becoming ready (exit code 1)", which says nothing about the real
|
|
87
|
+
// cause. Probe the port first and say what is actually there.
|
|
88
|
+
const existing = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
89
|
+
signal: AbortSignal.timeout(1_000),
|
|
90
|
+
}).catch(() => null);
|
|
91
|
+
if (existing !== null) {
|
|
92
|
+
// Something already owns the port. Replace it only when it proves to be our
|
|
93
|
+
// own gateway — otherwise stop and say what is there. A gateway left behind
|
|
94
|
+
// by a closed terminal used to make every later start fail until the
|
|
95
|
+
// operator found and killed it by hand.
|
|
96
|
+
const claimed = await claimPort(port, drainToken);
|
|
97
|
+
if (claimed !== null) throw new Error(claimed);
|
|
98
|
+
}
|
|
99
|
+
// The gateway logs a startup phase line per stage. In menu mode those lines
|
|
100
|
+
// would scroll through the same terminal the menu is drawing on, so they go
|
|
101
|
+
// to a file instead and the operator sees only the menu.
|
|
102
|
+
const configDir = process.env.CARTETHYIA_CONFIG_DIR;
|
|
103
|
+
let logFd: number | null = null;
|
|
104
|
+
if (configDir) {
|
|
105
|
+
try {
|
|
106
|
+
logFd = openSync(join(configDir, "gateway.log"), "a");
|
|
107
|
+
} catch {
|
|
108
|
+
// A read-only config directory costs only the log, not startup.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const child = Bun.spawn(
|
|
112
|
+
[process.execPath, "run", resolvePackagePath("src", "main.ts")],
|
|
113
|
+
{
|
|
114
|
+
cwd: PACKAGE_ROOT,
|
|
115
|
+
env: { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken },
|
|
116
|
+
// "ignore", not "inherit": the gateway does not read stdin, and in menu
|
|
117
|
+
// mode the menu is the only stdin consumer. With "inherit" the gateway
|
|
118
|
+
// held the fd and swallowed every keypress after the first, so the menu
|
|
119
|
+
// rendered but never received Enter.
|
|
120
|
+
stdin: "ignore",
|
|
121
|
+
stdout: logFd ?? "ignore",
|
|
122
|
+
// stderr goes to the same file: "inherit" ties the gateway to the
|
|
123
|
+
// terminal, so closing that terminal took the gateway down with it and
|
|
124
|
+
// "Hide to tray" left nothing running.
|
|
125
|
+
stderr: logFd ?? "ignore",
|
|
126
|
+
detached: options.detached === true,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
const startupController = new AbortController();
|
|
130
|
+
const ready = (async (): Promise<void> => {
|
|
131
|
+
const deadline = Date.now() + 30_000;
|
|
132
|
+
while (!startupController.signal.aborted && Date.now() < deadline) {
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
135
|
+
signal: AbortSignal.timeout(500),
|
|
136
|
+
});
|
|
137
|
+
if (response.ok) {
|
|
138
|
+
const body: unknown = await response.json();
|
|
139
|
+
if (body !== null && typeof body === "object" && "status" in body && body.status === "ok") return;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// The gateway is not accepting health requests yet.
|
|
143
|
+
}
|
|
144
|
+
await Bun.sleep(250);
|
|
145
|
+
}
|
|
146
|
+
if (startupController.signal.aborted) throw new Error("Cartethyia exited before becoming ready");
|
|
147
|
+
throw new Error("Cartethyia did not open its configured port within 30 seconds");
|
|
148
|
+
})();
|
|
149
|
+
const exitedEarly = child.exited.then((code) => {
|
|
150
|
+
startupController.abort();
|
|
151
|
+
return { status: "exit" as const, code };
|
|
152
|
+
});
|
|
153
|
+
const readyResult = ready.then(
|
|
154
|
+
() => ({ status: "ready" as const }),
|
|
155
|
+
(error: unknown) => ({ status: "failure" as const, error }),
|
|
156
|
+
);
|
|
157
|
+
try {
|
|
158
|
+
const result = await Promise.race([readyResult, exitedEarly]);
|
|
159
|
+
if (result.status === "exit") {
|
|
160
|
+
throw new Error(`Cartethyia stopped before becoming ready (exit code ${result.code})`);
|
|
161
|
+
}
|
|
162
|
+
if (result.status === "failure") throw result.error;
|
|
163
|
+
// Recorded only once the child is actually serving: a marker written before
|
|
164
|
+
// readiness would name a gateway that never came up, and the next start
|
|
165
|
+
// would treat a live port as ours when it is not.
|
|
166
|
+
writeInstanceLock(port, child.pid, drainToken);
|
|
167
|
+
return child;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
child.kill();
|
|
170
|
+
await child.exited;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops a gateway this process started, gracefully where possible.
|
|
3
|
+
*
|
|
4
|
+
* The drain endpoint is the only graceful path: it lets in-flight responses
|
|
5
|
+
* finish. The OS-level kill is the fallback, and it is reached whenever the
|
|
6
|
+
* endpoint is unreachable — which is also what happens when the gateway is
|
|
7
|
+
* already on its way out, so a failure here is not necessarily an error the
|
|
8
|
+
* operator needs to act on.
|
|
9
|
+
*/
|
|
10
|
+
export async function stopGateway(
|
|
11
|
+
current: Bun.Subprocess,
|
|
12
|
+
port: number,
|
|
13
|
+
drainToken: string,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
let drainAccepted = false;
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "x-drain-token": drainToken },
|
|
20
|
+
signal: AbortSignal.timeout(2_000),
|
|
21
|
+
});
|
|
22
|
+
drainAccepted = response.status === 202;
|
|
23
|
+
} catch {
|
|
24
|
+
// The process may already be exiting; fall back to its OS-level termination.
|
|
25
|
+
}
|
|
26
|
+
if (!drainAccepted) {
|
|
27
|
+
console.error("Could not request a graceful gateway drain; terminating the gateway process.");
|
|
28
|
+
current.kill();
|
|
29
|
+
await current.exited;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The drain is bounded: a gateway that accepted the request but never exits
|
|
34
|
+
// would otherwise hang the CLI. 30 s is the drain budget `src/main.ts` uses.
|
|
35
|
+
// The timer is cleared unconditionally afterwards — leaving it pending keeps
|
|
36
|
+
// the event loop alive for its full 30 s after the gateway is already gone,
|
|
37
|
+
// which made every `carte` exit take half a minute.
|
|
38
|
+
let timer: Timer | undefined;
|
|
39
|
+
const drained = await Promise.race([
|
|
40
|
+
current.exited,
|
|
41
|
+
new Promise<null>((resolveExit) => {
|
|
42
|
+
timer = setTimeout(() => resolveExit(null), 30_000);
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
if (drained === null) {
|
|
47
|
+
console.error("Graceful drain timed out; terminating the gateway process.");
|
|
48
|
+
current.kill();
|
|
49
|
+
await current.exited;
|
|
50
|
+
}
|
|
51
|
+
}
|