cartethyia 2.5.9 → 2.6.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.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -1,92 +1,70 @@
1
1
  /**
2
2
  * Remote-support tunnel service.
3
3
  *
4
- * The tunnel is launched by an external script rather than by spawning
5
- * `cloudflared`/`tailscale` directly, for one reason that matters: the script
6
- * hands the publisher to the OS session, so the tunnel is not a child of this
7
- * gateway. A gateway restart, a hot reload, or a Ctrl-C in the owning terminal
8
- * therefore cannot kill an operator's live remote-support session.
4
+ * Launches `cloudflared` or `tailscale` detached from the gateway process, so
5
+ * a gateway restart, a hot reload, or a Ctrl-C in the owning terminal cannot
6
+ * kill an operator's live remote-support session. Earlier revisions shelled
7
+ * out to a `.cmd` + `.ps1` launcher pair; those were Windows-only, had to be
8
+ * carried through the npm tarball, and failed with "Launcher not found" the
9
+ * day the whitelist missed them. This module does the same work with
10
+ * `child_process.spawn` directly and runs on every host Node runs on.
9
11
  *
10
12
  * This service never installs anything. A missing binary is reported as a
11
13
  * blocker with install guidance; downloading a third-party agent on the
12
14
  * operator's behalf is out of scope.
13
15
  */
14
- import { spawn } from "node:child_process";
15
- import { existsSync } from "node:fs";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
18
  import { join } from "node:path";
17
- import { PACKAGE_ROOT } from "../../config/package-paths";
19
+ import { resolveUserConfigDir } from "../../config/package-paths";
18
20
  import {
19
21
  REMOTE_SUPPORT_INSTALL_HINT,
22
+ REMOTE_SUPPORT_TOOLS,
20
23
  type RemoteSupportBlocker,
21
24
  type RemoteSupportStatus,
22
25
  type RemoteSupportTool,
23
26
  } from "./contracts";
24
27
 
25
- /** How long the launcher may take to report a URL before we stop waiting. */
26
- const LAUNCH_TIMEOUT_MS = 40_000;
28
+ /** How long to tail the publisher's log before giving up on a URL. */
29
+ const URL_WAIT_MS = 25_000;
30
+ /** How often to re-scan the log file while waiting for the URL. */
31
+ const URL_POLL_MS = 500;
27
32
 
28
- /** Cached install detection so we don't spawn `where` on every status poll. */
33
+ /** Cached install detection so we don't spawn `where`/`which` on every poll. */
29
34
  const installedCache = new Map<RemoteSupportTool, boolean>();
30
35
 
31
36
  export interface RemoteSupportServiceConfig {
32
37
  /** Defaults to the gateway's configured port. */
33
38
  readonly port: number;
34
- /** Overridable for tests; defaults to the package root. */
35
- readonly scriptDir?: string;
36
- }
37
-
38
- interface CommandResult {
39
- readonly status: number;
40
- readonly stdout: string;
41
- }
42
-
43
- function scriptPath(config: RemoteSupportServiceConfig, name: string): string {
44
- const dir = config.scriptDir ?? join(PACKAGE_ROOT, "scripts");
45
- return join(dir, name);
46
- }
47
-
48
- function runScript(
49
- config: RemoteSupportServiceConfig,
50
- args: readonly string[],
51
- ): Promise<CommandResult> {
52
- const { promise, resolve, reject } = Promise.withResolvers<CommandResult>();
53
- const isWindows = process.platform === "win32";
54
- // Windows cannot spawn a `.cmd` directly — `spawn` rejects it with EINVAL
55
- // because a batch file is not an executable image. It has to be handed to
56
- // `cmd.exe /c`, which is also what lets the batch call `exit /b 3` and have
57
- // that code reach us as the child's status.
58
- const command = isWindows ? "cmd.exe" : "powershell";
59
- const fileName = isWindows ? "remote-support.cmd" : "remote-support.ps1";
60
- const file = scriptPath(config, fileName);
61
- const spawnArgs = isWindows
62
- ? ["/d", "/s", "/c", file, ...args]
63
- : ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", file, ...args];
64
- const child = spawn(command, spawnArgs, {
65
- stdio: ["ignore", "pipe", "pipe"],
66
- windowsHide: true,
67
- });
68
- let stdout = "";
69
- let stderr = "";
70
- child.stdout.setEncoding("utf8");
71
- child.stderr.setEncoding("utf8");
72
- child.stdout.on("data", (chunk: string) => {
73
- stdout += chunk;
74
- });
75
- child.stderr.on("data", (chunk: string) => {
76
- stderr += chunk;
77
- });
78
- const timer = setTimeout(() => child.kill(), LAUNCH_TIMEOUT_MS);
79
- child.on("error", reject);
80
- child.on("close", (status) => {
81
- clearTimeout(timer);
82
- resolve({ status: status ?? 1, stdout: `${stdout}${stderr}`.trim() });
83
- });
84
- return promise;
39
+ /** Overridable for tests. Defaults to a subdir of the user config dir. */
40
+ readonly stateDir?: string;
41
+ }
42
+
43
+ function stateDirOf(config: RemoteSupportServiceConfig): string {
44
+ return config.stateDir ?? join(resolveUserConfigDir(), "remote-support");
45
+ }
46
+
47
+ function ensureStateDir(dir: string): void {
48
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
49
+ }
50
+
51
+ function pidPath(dir: string, tool: RemoteSupportTool): string {
52
+ return join(dir, `${tool}.pid`);
53
+ }
54
+ function urlPath(dir: string, tool: RemoteSupportTool): string {
55
+ return join(dir, `${tool}.url`);
56
+ }
57
+ function logPath(dir: string, tool: RemoteSupportTool): string {
58
+ return join(dir, `${tool}.log`);
59
+ }
60
+ function errPath(dir: string, tool: RemoteSupportTool): string {
61
+ return join(dir, `${tool}.err`);
85
62
  }
86
63
 
87
64
  /**
88
- * `where`/`which` lookup, mirroring what the script does itself so the route
89
- * can answer `not_installed` with a clear message instead of a generic failure.
65
+ * `where` / `which` lookup, cached per process. Shelled out rather than coded
66
+ * because PATHEXT, Spotlight PATH injections, and nvm's shims make the
67
+ * resolved location non-trivial — the OS's own resolver gets it right.
90
68
  */
91
69
  function toolAvailable(tool: RemoteSupportTool): Promise<boolean> {
92
70
  const cached = installedCache.get(tool);
@@ -106,6 +84,179 @@ function toolAvailable(tool: RemoteSupportTool): Promise<boolean> {
106
84
  return promise;
107
85
  }
108
86
 
87
+ /** Non-blocking check: is `pid` still alive on this host? Cross-OS. */
88
+ function pidAlive(pid: number): boolean {
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Reads the recorded PID file, returning `null` if the file is missing, empty,
99
+ * malformed, or names a process that is no longer alive. Dead files are
100
+ * cleaned up so a later start does not race against a stale record.
101
+ */
102
+ function readLivePid(dir: string, tool: RemoteSupportTool): number | null {
103
+ const file = pidPath(dir, tool);
104
+ if (!existsSync(file)) return null;
105
+ const raw = readFileSync(file, "utf8").trim();
106
+ const pid = Number.parseInt(raw, 10);
107
+ if (!Number.isFinite(pid) || pid <= 0) {
108
+ rmSync(file, { force: true });
109
+ return null;
110
+ }
111
+ if (!pidAlive(pid)) {
112
+ rmSync(file, { force: true });
113
+ return null;
114
+ }
115
+ return pid;
116
+ }
117
+
118
+ /**
119
+ * Terminates `pid` and its descendants. On Windows the tree has to be killed
120
+ * explicitly via `taskkill /T /F` — plain `process.kill` only signals the
121
+ * top-level process, and cloudflared under Windows spawns a worker that keeps
122
+ * the port open after the parent exits. On POSIX, SIGTERM to the group covers
123
+ * descendants spawned with `detached: true`.
124
+ */
125
+ function killTree(pid: number): void {
126
+ if (process.platform === "win32") {
127
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
128
+ return;
129
+ }
130
+ try {
131
+ process.kill(-pid, "SIGTERM");
132
+ } catch {
133
+ try {
134
+ process.kill(pid, "SIGTERM");
135
+ } catch {
136
+ /* already dead */
137
+ }
138
+ }
139
+ }
140
+
141
+ /** Arguments for the detached publisher launch. Shape mirrors the old script. */
142
+ function launchArgsFor(tool: RemoteSupportTool, port: number): readonly string[] {
143
+ if (tool === "cloudflared") {
144
+ // `--url` prints the fresh *.trycloudflare.com hostname (to stderr).
145
+ return ["tunnel", "--url", `http://127.0.0.1:${port}`, "--no-autoupdate"];
146
+ }
147
+ // `serve --bg` returns immediately; the funnel listens on 443.
148
+ return ["serve", "--bg", "--https=443", `http://127.0.0.1:${port}`];
149
+ }
150
+
151
+ /** The pattern anchors to the tunnel's own hostname so Cloudflare's startup
152
+ * banner (which references `cloudflare.com`) is not mistaken for the URL. */
153
+ function urlPatternFor(tool: RemoteSupportTool): RegExp {
154
+ return tool === "cloudflared"
155
+ ? /https:\/\/[A-Za-z0-9-]+\.trycloudflare\.com/
156
+ : /https:\/\/[A-Za-z0-9.-]+\.ts\.net/;
157
+ }
158
+
159
+ /**
160
+ * Tails the publisher's stdout + stderr log files for the URL regex. Resolves
161
+ * with the first match or `null` after the overall deadline.
162
+ */
163
+ async function waitForUrl(dir: string, tool: RemoteSupportTool): Promise<string | null> {
164
+ const pattern = urlPatternFor(tool);
165
+ const deadline = Date.now() + URL_WAIT_MS;
166
+ const log = logPath(dir, tool);
167
+ const err = errPath(dir, tool);
168
+ while (Date.now() < deadline) {
169
+ for (const path of [log, err]) {
170
+ if (!existsSync(path)) continue;
171
+ const text = readFileSync(path, "utf8");
172
+ const match = pattern.exec(text);
173
+ if (match) return match[0];
174
+ }
175
+ const { promise, resolve } = Promise.withResolvers<void>();
176
+ setTimeout(resolve, URL_POLL_MS);
177
+ await promise;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Last-resort hostname lookup for Tailscale.
184
+ *
185
+ * `tailscale serve --bg` prints nothing useful once it has backgrounded; the
186
+ * live hostname comes from the daemon's own status. The JSON shape has drifted
187
+ * across releases (`DNSName` at the root in some, nested under `Self` in
188
+ * others), so both locations are probed and the first hit wins.
189
+ */
190
+ function tailscaleUrlFromDaemon(): string | null {
191
+ const result = spawnSync("tailscale", ["serve", "status", "--json"], { encoding: "utf8", windowsHide: true });
192
+ if (result.status !== 0) return null;
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(result.stdout);
196
+ } catch {
197
+ return null;
198
+ }
199
+ const bag: Record<string, unknown> =
200
+ typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
201
+ const direct = typeof bag["DNSName"] === "string" ? (bag["DNSName"] as string) : null;
202
+ const self =
203
+ typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
204
+ const nested = self && typeof self["DNSName"] === "string" ? (self["DNSName"] as string) : null;
205
+ const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "");
206
+ return hostname && hostname.length > 0 ? `https://${hostname}` : null;
207
+ }
208
+
209
+ /**
210
+ * Starts `tool` detached, writes its logs to the state dir, records the pid,
211
+ * and waits briefly for the URL. The caller never becomes the publisher's
212
+ * parent — `detached` + `unref` + `ignore`-stdin cuts the inheritance chain.
213
+ */
214
+ async function launchDetached(
215
+ dir: string,
216
+ tool: RemoteSupportTool,
217
+ port: number,
218
+ ): Promise<{ pid: number; url: string | null; error: string | null }> {
219
+ const resolved = spawnSync(process.platform === "win32" ? "where" : "which", [tool], {
220
+ encoding: "utf8",
221
+ windowsHide: true,
222
+ });
223
+ if (resolved.status !== 0) {
224
+ return { pid: 0, url: null, error: `${tool} is not on PATH` };
225
+ }
226
+ const binary = resolved.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
227
+ if (!binary) {
228
+ return { pid: 0, url: null, error: `${tool} is not on PATH` };
229
+ }
230
+ ensureStateDir(dir);
231
+ rmSync(urlPath(dir, tool), { force: true });
232
+ rmSync(logPath(dir, tool), { force: true });
233
+ rmSync(errPath(dir, tool), { force: true });
234
+ const outFd = openSync(logPath(dir, tool), "a");
235
+ const errFd = openSync(errPath(dir, tool), "a");
236
+ try {
237
+ const child = spawn(binary, launchArgsFor(tool, port), {
238
+ detached: true,
239
+ windowsHide: true,
240
+ stdio: ["ignore", outFd, errFd],
241
+ });
242
+ const pid = child.pid;
243
+ if (pid === undefined) {
244
+ return { pid: 0, url: null, error: `failed to launch ${tool}` };
245
+ }
246
+ writeFileSync(pidPath(dir, tool), String(pid), "utf8");
247
+ child.unref();
248
+ let url = await waitForUrl(dir, tool);
249
+ if (!url && tool === "tailscale") {
250
+ url = tailscaleUrlFromDaemon();
251
+ }
252
+ if (url) writeFileSync(urlPath(dir, tool), url, "utf8");
253
+ return { pid, url, error: null };
254
+ } finally {
255
+ closeSync(outFd);
256
+ closeSync(errFd);
257
+ }
258
+ }
259
+
109
260
  export interface RemoteSupportService {
110
261
  status(): Promise<RemoteSupportStatus>;
111
262
  start(tool: RemoteSupportTool, port?: number): Promise<RemoteSupportStatus>;
@@ -114,37 +265,56 @@ export interface RemoteSupportService {
114
265
 
115
266
  export function createRemoteSupportService(config: RemoteSupportServiceConfig): RemoteSupportService {
116
267
  const port = config.port;
268
+ const dir = stateDirOf(config);
117
269
 
118
- /**
119
- * Install state for both publishers, cached per process.
120
- *
121
- * Every status carries it so the card can show "not installed" without the
122
- * browser probing on its own — but the probe is `where`/`which`, which is a
123
- * process spawn, so it is memoised here rather than run on each poll.
124
- */
125
270
  async function detectInstalled(): Promise<Readonly<Record<RemoteSupportTool, boolean>>> {
126
- return {
127
- cloudflared: await toolAvailable("cloudflared"),
128
- tailscale: await toolAvailable("tailscale"),
129
- } as const;
271
+ const [cloudflared, tailscale] = await Promise.all([
272
+ toolAvailable("cloudflared"),
273
+ toolAvailable("tailscale"),
274
+ ]);
275
+ return { cloudflared, tailscale } as const;
130
276
  }
131
277
 
132
- async function readStatus(blocker: RemoteSupportBlocker | null = null, message: string | null = null): Promise<RemoteSupportStatus> {
133
- const result = await runScript(config, ["status"]);
134
- const text = result.stdout;
135
- const active: RemoteSupportTool | null = text.includes("cloudflared running")
136
- ? "cloudflared"
137
- : text.includes("tailscale running")
138
- ? "tailscale"
139
- : null;
140
- let url: string | null = null;
141
- if (active !== null) {
142
- const urlResult = await runScript(config, ["url"]);
143
- const candidate = urlResult.stdout.trim().split(/\s+/).find((line) => line.startsWith("https://"));
144
- url = candidate ?? null;
278
+ /** Which publisher, if any, is currently alive on this host. */
279
+ function detectActive(): { tool: RemoteSupportTool | null; url: string | null } {
280
+ for (const tool of REMOTE_SUPPORT_TOOLS) {
281
+ // Tailscale runs as a system service — its serve daemon owns its own
282
+ // lifecycle, so a pid file is not reliable. Ask the daemon.
283
+ if (tool === "tailscale") {
284
+ const url = tailscaleUrlFromDaemon();
285
+ if (url) return { tool: "tailscale", url };
286
+ continue;
287
+ }
288
+ const pid = readLivePid(dir, tool);
289
+ if (pid === null) continue;
290
+ const recorded = existsSync(urlPath(dir, tool)) ? readFileSync(urlPath(dir, tool), "utf8").trim() : "";
291
+ return { tool, url: recorded.length > 0 ? recorded : null };
145
292
  }
293
+ return { tool: null, url: null };
294
+ }
295
+
296
+ async function readStatus(
297
+ blocker: RemoteSupportBlocker | null = null,
298
+ message: string | null = null,
299
+ ): Promise<RemoteSupportStatus> {
300
+ const { tool, url } = detectActive();
146
301
  const installed = await detectInstalled();
147
- return { active, url, blocker, message, port, installed };
302
+ return { active: tool, url, blocker, message, port, installed };
303
+ }
304
+
305
+ async function stopTool(tool: RemoteSupportTool): Promise<void> {
306
+ if (tool === "tailscale") {
307
+ // The daemon owns the serve lifecycle; `serve reset` is the sanctioned
308
+ // teardown and leaves no orphan process behind.
309
+ spawnSync("tailscale", ["serve", "reset"], { stdio: "ignore", windowsHide: true });
310
+ rmSync(urlPath(dir, tool), { force: true });
311
+ rmSync(pidPath(dir, tool), { force: true });
312
+ return;
313
+ }
314
+ const pid = readLivePid(dir, tool);
315
+ if (pid !== null) killTree(pid);
316
+ rmSync(pidPath(dir, tool), { force: true });
317
+ rmSync(urlPath(dir, tool), { force: true });
148
318
  }
149
319
 
150
320
  return {
@@ -153,27 +323,6 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
153
323
  },
154
324
 
155
325
  async start(tool, requestedPort) {
156
- if (process.platform !== "win32") {
157
- return {
158
- active: null,
159
- url: null,
160
- blocker: "unsupported_platform",
161
- message: "Remote support is currently scripted for Windows hosts only.",
162
- port,
163
- installed: { cloudflared: false, tailscale: false },
164
- };
165
- }
166
- const launcher = scriptPath(config, "remote-support.cmd");
167
- if (!existsSync(launcher)) {
168
- return {
169
- active: null,
170
- url: null,
171
- blocker: "launch_failed",
172
- message: `Launcher not found: ${launcher}`,
173
- port,
174
- installed: await detectInstalled(),
175
- };
176
- }
177
326
  if (!(await toolAvailable(tool))) {
178
327
  return {
179
328
  active: null,
@@ -184,14 +333,17 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
184
333
  installed: await detectInstalled(),
185
334
  };
186
335
  }
336
+ // Replace any stale run of the same tool before launching a new one, so
337
+ // two instances never fight for the same local port.
338
+ await stopTool(tool);
187
339
  const target = requestedPort ?? port;
188
- const result = await runScript(config, ["start", tool, String(target)]);
189
- if (result.status !== 0 && !result.stdout.includes("started")) {
340
+ const result = await launchDetached(dir, tool, target);
341
+ if (result.error) {
190
342
  return {
191
343
  active: null,
192
344
  url: null,
193
345
  blocker: "launch_failed",
194
- message: result.stdout || `Failed to start ${tool}.`,
346
+ message: result.error,
195
347
  port,
196
348
  installed: await detectInstalled(),
197
349
  };
@@ -200,7 +352,11 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
200
352
  },
201
353
 
202
354
  async stop(tool) {
203
- await runScript(config, tool === undefined ? ["stop"] : ["stop", tool]);
355
+ if (tool === undefined) {
356
+ for (const name of REMOTE_SUPPORT_TOOLS) await stopTool(name);
357
+ } else {
358
+ await stopTool(tool);
359
+ }
204
360
  return await readStatus();
205
361
  },
206
362
  };