@alumbwe/anvil 1.0.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 (529) hide show
  1. package/README.md +84 -0
  2. package/bin/anvil +2 -0
  3. package/bin/tree-sitter.wasm +0 -0
  4. package/package.json +67 -0
  5. package/src/__tests__/README.md +256 -0
  6. package/src/__tests__/bash-mode.test.ts +442 -0
  7. package/src/__tests__/cli-args.test.ts +181 -0
  8. package/src/__tests__/cli-harness.test.ts +52 -0
  9. package/src/__tests__/e2e/first-time-login.test.ts +173 -0
  10. package/src/__tests__/e2e/logout-relogin-flow.test.ts +110 -0
  11. package/src/__tests__/e2e/returning-user-auth.test.ts +113 -0
  12. package/src/__tests__/e2e-cli.test.ts +183 -0
  13. package/src/__tests__/helpers/launcher-disconnect-fixture.cjs +176 -0
  14. package/src/__tests__/helpers/mock-api-client.ts +64 -0
  15. package/src/__tests__/helpers/renderer-cleanup-fixture.tsx +89 -0
  16. package/src/__tests__/helpers/terminal-watchdog-fixture.ts +75 -0
  17. package/src/__tests__/home-directory-detection.test.ts +213 -0
  18. package/src/__tests__/integration/api-integration.test.ts +288 -0
  19. package/src/__tests__/integration/credentials-storage.test.ts +486 -0
  20. package/src/__tests__/integration/local-agents.test.ts +1187 -0
  21. package/src/__tests__/integration/login-flow-instrumentation.test.ts +134 -0
  22. package/src/__tests__/integration/login-polling-working.test.ts +285 -0
  23. package/src/__tests__/integration/queue-editing.test.tsx +261 -0
  24. package/src/__tests__/integration/usage-refresh-on-completion.test.ts +168 -0
  25. package/src/__tests__/integration-tmux.test.ts +188 -0
  26. package/src/__tests__/launcher-avx2-fallback.test.ts +630 -0
  27. package/src/__tests__/launcher-disconnect.test.ts +104 -0
  28. package/src/__tests__/mocks/hover-toggle-controller.ts +71 -0
  29. package/src/__tests__/open-tui-native-bundle.test.ts +378 -0
  30. package/src/__tests__/path-completion.test.ts +243 -0
  31. package/src/__tests__/project-files-chat-id.test.ts +34 -0
  32. package/src/__tests__/release/proxy-http-get.test.ts +667 -0
  33. package/src/__tests__/release/wrapper-safety.test.ts +412 -0
  34. package/src/__tests__/renderer-cleanup.test.ts +174 -0
  35. package/src/__tests__/rerender-perf.integration.test.ts +283 -0
  36. package/src/__tests__/terminal-reset-sequences.test.ts +36 -0
  37. package/src/__tests__/terminal-watchdog.test.ts +388 -0
  38. package/src/__tests__/test-utils.ts +271 -0
  39. package/src/__tests__/tmux-poc.ts +150 -0
  40. package/src/__tests__/unit/agent-mode-toggle.test.ts +137 -0
  41. package/src/__tests__/unit/copy-button.test.ts +157 -0
  42. package/src/__tests__/unit/create-run-config.test.ts +66 -0
  43. package/src/__tests__/unit/publish-confirmation.test.ts +70 -0
  44. package/src/__tests__/unit/segmented-control.test.ts +152 -0
  45. package/src/__tests__/utils/env.test.ts +169 -0
  46. package/src/__tests__/utils/project-picker.test.ts +39 -0
  47. package/src/agents/bundled-agents.generated.d.ts +14 -0
  48. package/src/agents/bundled-agents.generated.ts +3692 -0
  49. package/src/app.tsx +399 -0
  50. package/src/chat.tsx +1698 -0
  51. package/src/cli-args.ts +134 -0
  52. package/src/commands/__tests__/bash-command.test.ts +445 -0
  53. package/src/commands/__tests__/command-args.test.ts +349 -0
  54. package/src/commands/__tests__/copy-conversation-clipboard.test.ts +108 -0
  55. package/src/commands/__tests__/copy-conversation.test.ts +226 -0
  56. package/src/commands/__tests__/diagnostics-command.test.ts +35 -0
  57. package/src/commands/__tests__/freebuff-command-aliases.test.ts +56 -0
  58. package/src/commands/__tests__/image.test.ts +99 -0
  59. package/src/commands/__tests__/init.test.ts +456 -0
  60. package/src/commands/__tests__/new-command-rotates-chat.test.ts +91 -0
  61. package/src/commands/__tests__/process-diagnostics.test.ts +54 -0
  62. package/src/commands/__tests__/prompt-builders.test.ts +28 -0
  63. package/src/commands/__tests__/router-input.test.ts +298 -0
  64. package/src/commands/ads.ts +44 -0
  65. package/src/commands/command-registry.ts +726 -0
  66. package/src/commands/copy-conversation.ts +392 -0
  67. package/src/commands/help.ts +13 -0
  68. package/src/commands/image.ts +20 -0
  69. package/src/commands/init.ts +121 -0
  70. package/src/commands/process-diagnostics.ts +120 -0
  71. package/src/commands/prompt-builders.ts +100 -0
  72. package/src/commands/publish.ts +203 -0
  73. package/src/commands/router-utils.ts +80 -0
  74. package/src/commands/router.ts +487 -0
  75. package/src/commands/usage.ts +26 -0
  76. package/src/components/__tests__/ad-banner.test.tsx +83 -0
  77. package/src/components/__tests__/chat-input-bar.test.tsx +130 -0
  78. package/src/components/__tests__/freebuff-landing-heading-row.test.tsx +91 -0
  79. package/src/components/__tests__/freebuff-model-selector.test.tsx +519 -0
  80. package/src/components/__tests__/freebuff-offer-invariants.test.ts +54 -0
  81. package/src/components/__tests__/freebuff-takeover-prompt.test.tsx +212 -0
  82. package/src/components/__tests__/grid-layout.integration.test.tsx +304 -0
  83. package/src/components/__tests__/grid-layout.test.tsx +1051 -0
  84. package/src/components/__tests__/message-block.completion.test.tsx +104 -0
  85. package/src/components/__tests__/message-block.streaming.test.tsx +78 -0
  86. package/src/components/__tests__/message-with-agents.test.tsx +579 -0
  87. package/src/components/__tests__/multiline-input.test.tsx +1132 -0
  88. package/src/components/__tests__/queue-panel.test.tsx +398 -0
  89. package/src/components/__tests__/selectable-list.test.ts +232 -0
  90. package/src/components/__tests__/status-bar.test.tsx +44 -0
  91. package/src/components/__tests__/status-indicator.test.tsx +204 -0
  92. package/src/components/__tests__/terminal-thinking-orb.test.tsx +111 -0
  93. package/src/components/__tests__/user-error-banner.test.tsx +102 -0
  94. package/src/components/ad-banner.tsx +323 -0
  95. package/src/components/agent-checklist.tsx +350 -0
  96. package/src/components/agent-mode-toggle.tsx +241 -0
  97. package/src/components/anvil-mark.tsx +52 -0
  98. package/src/components/ask-user/__tests__/multiple-choice-form.test.ts +432 -0
  99. package/src/components/ask-user/__tests__/validation.test.ts +213 -0
  100. package/src/components/ask-user/components/accordion-question.tsx +153 -0
  101. package/src/components/ask-user/components/custom-answer-input.tsx +66 -0
  102. package/src/components/ask-user/components/options-list.tsx +133 -0
  103. package/src/components/ask-user/components/question-header.tsx +80 -0
  104. package/src/components/ask-user/components/question-option.tsx +89 -0
  105. package/src/components/ask-user/constants.ts +38 -0
  106. package/src/components/ask-user/index.tsx +631 -0
  107. package/src/components/ask-user/utils/validation.ts +85 -0
  108. package/src/components/attachment-card.tsx +65 -0
  109. package/src/components/blocks/agent-block-grid.tsx +58 -0
  110. package/src/components/blocks/agent-branch-item.tsx +311 -0
  111. package/src/components/blocks/agent-branch-wrapper.tsx +493 -0
  112. package/src/components/blocks/agent-list-branch.tsx +77 -0
  113. package/src/components/blocks/ask-user-branch.tsx +81 -0
  114. package/src/components/blocks/block-helpers.ts +11 -0
  115. package/src/components/blocks/blocks-renderer.tsx +289 -0
  116. package/src/components/blocks/content-with-markdown.tsx +33 -0
  117. package/src/components/blocks/image-block.tsx +128 -0
  118. package/src/components/blocks/implementor-row.tsx +429 -0
  119. package/src/components/blocks/single-block.tsx +201 -0
  120. package/src/components/blocks/thinking-block.tsx +67 -0
  121. package/src/components/blocks/tool-block-group.tsx +57 -0
  122. package/src/components/blocks/tool-branch.tsx +206 -0
  123. package/src/components/blocks/user-content-copy.tsx +157 -0
  124. package/src/components/bottom-banner.tsx +136 -0
  125. package/src/components/build-mode-buttons.tsx +110 -0
  126. package/src/components/button.tsx +66 -0
  127. package/src/components/chat-header.tsx +77 -0
  128. package/src/components/chat-history-screen.tsx +413 -0
  129. package/src/components/chat-input-bar.tsx +523 -0
  130. package/src/components/clickable-title-box.tsx +66 -0
  131. package/src/components/clickable.tsx +119 -0
  132. package/src/components/collapse-button.tsx +34 -0
  133. package/src/components/copy-button.tsx +203 -0
  134. package/src/components/elapsed-timer.tsx +55 -0
  135. package/src/components/error-boundary.tsx +55 -0
  136. package/src/components/feedback-container.tsx +181 -0
  137. package/src/components/feedback-icon-button.tsx +89 -0
  138. package/src/components/feedback-input-mode.tsx +344 -0
  139. package/src/components/file-attachment-card.tsx +98 -0
  140. package/src/components/freebuff-active-session-summary.tsx +63 -0
  141. package/src/components/freebuff-landing-screen.tsx +865 -0
  142. package/src/components/freebuff-model-selector.tsx +1087 -0
  143. package/src/components/freebuff-referral-banner.tsx +604 -0
  144. package/src/components/freebuff-superseded-screen.tsx +62 -0
  145. package/src/components/grid-layout.tsx +80 -0
  146. package/src/components/help-banner.tsx +127 -0
  147. package/src/components/highlighted-text.tsx +52 -0
  148. package/src/components/image-card.tsx +167 -0
  149. package/src/components/image-thumbnail.tsx +114 -0
  150. package/src/components/input-cursor.tsx +77 -0
  151. package/src/components/input-mode-banner.tsx +57 -0
  152. package/src/components/load-previous-button.tsx +48 -0
  153. package/src/components/login-modal.tsx +475 -0
  154. package/src/components/message-block.tsx +346 -0
  155. package/src/components/message-footer.tsx +258 -0
  156. package/src/components/message-with-agents.tsx +506 -0
  157. package/src/components/mode-divider.tsx +43 -0
  158. package/src/components/multiline-input.tsx +1241 -0
  159. package/src/components/out-of-credits-banner.tsx +160 -0
  160. package/src/components/pending-attachments-banner.tsx +116 -0
  161. package/src/components/pending-bash-message.tsx +59 -0
  162. package/src/components/progress-bar.tsx +81 -0
  163. package/src/components/project-picker-screen.tsx +485 -0
  164. package/src/components/publish-confirmation.tsx +493 -0
  165. package/src/components/publish-container.tsx +687 -0
  166. package/src/components/queue-panel.tsx +310 -0
  167. package/src/components/raised-pill.tsx +95 -0
  168. package/src/components/renderers/plan-box.tsx +59 -0
  169. package/src/components/review-screen.tsx +112 -0
  170. package/src/components/scroll-to-bottom-button.tsx +34 -0
  171. package/src/components/segmented-control.tsx +187 -0
  172. package/src/components/selectable-list.tsx +248 -0
  173. package/src/components/selected-chips.tsx +80 -0
  174. package/src/components/separator.tsx +40 -0
  175. package/src/components/session-ended-banner.tsx +226 -0
  176. package/src/components/shimmer-text.tsx +248 -0
  177. package/src/components/status-bar.tsx +313 -0
  178. package/src/components/subscription-limit-banner.tsx +206 -0
  179. package/src/components/suggested-prompts.tsx +184 -0
  180. package/src/components/suggestion-menu.tsx +215 -0
  181. package/src/components/terminal-command-display.tsx +151 -0
  182. package/src/components/terminal-link.tsx +97 -0
  183. package/src/components/terminal-thinking-orb.tsx +95 -0
  184. package/src/components/text-attachment-card.tsx +77 -0
  185. package/src/components/thinking.tsx +109 -0
  186. package/src/components/tools/__tests__/apply-patch.test.tsx +84 -0
  187. package/src/components/tools/__tests__/code-search.test.tsx +45 -0
  188. package/src/components/tools/__tests__/gravity-index.test.ts +76 -0
  189. package/src/components/tools/__tests__/render-ui.test.tsx +92 -0
  190. package/src/components/tools/__tests__/run-terminal-command.test.ts +233 -0
  191. package/src/components/tools/apply-patch.tsx +95 -0
  192. package/src/components/tools/code-search.tsx +45 -0
  193. package/src/components/tools/composio.tsx +152 -0
  194. package/src/components/tools/diff-viewer.tsx +76 -0
  195. package/src/components/tools/glob.tsx +61 -0
  196. package/src/components/tools/gravity-index.tsx +77 -0
  197. package/src/components/tools/list-directory.tsx +61 -0
  198. package/src/components/tools/read-docs.tsx +37 -0
  199. package/src/components/tools/read-files.tsx +91 -0
  200. package/src/components/tools/read-subtree.tsx +44 -0
  201. package/src/components/tools/read-url.tsx +33 -0
  202. package/src/components/tools/registry.ts +126 -0
  203. package/src/components/tools/render-ui.tsx +122 -0
  204. package/src/components/tools/run-terminal-command.tsx +79 -0
  205. package/src/components/tools/skill.tsx +29 -0
  206. package/src/components/tools/str-replace.tsx +77 -0
  207. package/src/components/tools/suggest-followups.tsx +369 -0
  208. package/src/components/tools/task-completed.tsx +16 -0
  209. package/src/components/tools/tool-call-item.tsx +273 -0
  210. package/src/components/tools/types.ts +55 -0
  211. package/src/components/tools/web-search.tsx +33 -0
  212. package/src/components/tools/write-file.tsx +16 -0
  213. package/src/components/tools/write-todos.tsx +95 -0
  214. package/src/components/top-banner.tsx +178 -0
  215. package/src/components/usage-banner.tsx +244 -0
  216. package/src/components/user-error-banner.tsx +56 -0
  217. package/src/components/validation-error-popover.tsx +192 -0
  218. package/src/contexts/__tests__/chat-runtime-context.test.tsx +149 -0
  219. package/src/contexts/chat-runtime-context.tsx +240 -0
  220. package/src/data/slash-commands.test.ts +24 -0
  221. package/src/data/slash-commands.ts +249 -0
  222. package/src/entry.ts +12 -0
  223. package/src/hooks/__tests__/holds-live-freebuff-slot.test.ts +49 -0
  224. package/src/hooks/__tests__/session-fetch-signal.test.ts +63 -0
  225. package/src/hooks/__tests__/use-activity-query.test.ts +1000 -0
  226. package/src/hooks/__tests__/use-ask-user-bridge.test.ts +176 -0
  227. package/src/hooks/__tests__/use-auth-query.test.ts +21 -0
  228. package/src/hooks/__tests__/use-chat-input-remount.test.tsx +66 -0
  229. package/src/hooks/__tests__/use-clipboard.test.tsx +73 -0
  230. package/src/hooks/__tests__/use-connection-status.test.ts +112 -0
  231. package/src/hooks/__tests__/use-directory-browser.test.ts +203 -0
  232. package/src/hooks/__tests__/use-gravity-ad.test.ts +73 -0
  233. package/src/hooks/__tests__/use-grid-layout.test.ts +349 -0
  234. package/src/hooks/__tests__/use-input-history.test.ts +699 -0
  235. package/src/hooks/__tests__/use-message-queue-editing.test.tsx +209 -0
  236. package/src/hooks/__tests__/use-message-queue.test.tsx +71 -0
  237. package/src/hooks/__tests__/use-path-tab-completion.test.ts +315 -0
  238. package/src/hooks/__tests__/use-queue-controls.test.ts +68 -0
  239. package/src/hooks/__tests__/use-queue-ui.test.tsx +83 -0
  240. package/src/hooks/__tests__/use-searchable-list.test.ts +359 -0
  241. package/src/hooks/__tests__/use-send-message-timer.test.ts +146 -0
  242. package/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +361 -0
  243. package/src/hooks/__tests__/use-suggestion-engine.test.ts +485 -0
  244. package/src/hooks/__tests__/use-terminal-focus.test.ts +66 -0
  245. package/src/hooks/__tests__/use-terminal-layout.test.ts +675 -0
  246. package/src/hooks/__tests__/use-timeout.test.ts +330 -0
  247. package/src/hooks/__tests__/use-usage-query.test.ts +502 -0
  248. package/src/hooks/__tests__/use-user-details-query.test.ts +195 -0
  249. package/src/hooks/helpers/__tests__/send-message.test.ts +1918 -0
  250. package/src/hooks/helpers/send-message.ts +654 -0
  251. package/src/hooks/stream-state.ts +83 -0
  252. package/src/hooks/use-activity-query.ts +668 -0
  253. package/src/hooks/use-agent-validation.ts +74 -0
  254. package/src/hooks/use-ask-user-bridge.ts +92 -0
  255. package/src/hooks/use-auth-query.ts +253 -0
  256. package/src/hooks/use-auth-state.ts +152 -0
  257. package/src/hooks/use-chat-input.ts +105 -0
  258. package/src/hooks/use-chat-keyboard.ts +321 -0
  259. package/src/hooks/use-chat-messages.ts +254 -0
  260. package/src/hooks/use-chat-state.ts +165 -0
  261. package/src/hooks/use-chat-streaming.ts +180 -0
  262. package/src/hooks/use-chat-ui.ts +131 -0
  263. package/src/hooks/use-clipboard.ts +151 -0
  264. package/src/hooks/use-connection-status.ts +142 -0
  265. package/src/hooks/use-directory-browser.ts +88 -0
  266. package/src/hooks/use-elapsed-time.ts +116 -0
  267. package/src/hooks/use-event.ts +34 -0
  268. package/src/hooks/use-exit-handler.ts +66 -0
  269. package/src/hooks/use-fetch-login-url.ts +65 -0
  270. package/src/hooks/use-fingerprint.ts +61 -0
  271. package/src/hooks/use-freebuff-ctrl-c-exit.ts +23 -0
  272. package/src/hooks/use-freebuff-session-progress.ts +34 -0
  273. package/src/hooks/use-freebuff-session.ts +806 -0
  274. package/src/hooks/use-freebuff-streak-query.ts +61 -0
  275. package/src/hooks/use-gravity-ad.ts +656 -0
  276. package/src/hooks/use-grid-layout.ts +73 -0
  277. package/src/hooks/use-input-history.ts +173 -0
  278. package/src/hooks/use-login-keyboard-handlers.ts +75 -0
  279. package/src/hooks/use-login-polling.ts +114 -0
  280. package/src/hooks/use-logo.tsx +161 -0
  281. package/src/hooks/use-message-queue.ts +387 -0
  282. package/src/hooks/use-now.ts +20 -0
  283. package/src/hooks/use-path-tab-completion.ts +88 -0
  284. package/src/hooks/use-publish-mutation.ts +60 -0
  285. package/src/hooks/use-queue-controls.ts +68 -0
  286. package/src/hooks/use-queue-ui.ts +74 -0
  287. package/src/hooks/use-scroll-management.ts +184 -0
  288. package/src/hooks/use-searchable-list.ts +98 -0
  289. package/src/hooks/use-send-message.ts +773 -0
  290. package/src/hooks/use-sheen-animation.tsx +88 -0
  291. package/src/hooks/use-subscription-query.ts +71 -0
  292. package/src/hooks/use-suggestion-engine.ts +783 -0
  293. package/src/hooks/use-terminal-breakpoints.ts +77 -0
  294. package/src/hooks/use-terminal-dimensions.ts +46 -0
  295. package/src/hooks/use-terminal-focus.ts +29 -0
  296. package/src/hooks/use-terminal-layout.ts +189 -0
  297. package/src/hooks/use-theme.tsx +140 -0
  298. package/src/hooks/use-timeout.ts +85 -0
  299. package/src/hooks/use-update-preference.ts +66 -0
  300. package/src/hooks/use-usage-monitor.ts +55 -0
  301. package/src/hooks/use-usage-query.ts +124 -0
  302. package/src/hooks/use-user-details-query.ts +93 -0
  303. package/src/hooks/use-why-did-you-update.ts +177 -0
  304. package/src/index.tsx +441 -0
  305. package/src/init/__tests__/init-direnv.test.ts +526 -0
  306. package/src/init/init-app.ts +34 -0
  307. package/src/init/init-direnv.ts +133 -0
  308. package/src/login/constants.ts +60 -0
  309. package/src/login/login-flow.ts +204 -0
  310. package/src/login/plain-login.ts +108 -0
  311. package/src/login/utils.ts +183 -0
  312. package/src/native/ripgrep.ts +80 -0
  313. package/src/polyfills/bun-strip-ansi.ts +12 -0
  314. package/src/pre-init/tree-sitter-wasm.ts +96 -0
  315. package/src/project-files.ts +102 -0
  316. package/src/smoke/terminal-command-broker.ts +439 -0
  317. package/src/state/__tests__/chat-store-focus.test.ts +22 -0
  318. package/src/state/__tests__/feedback-store.test.ts +257 -0
  319. package/src/state/chat-history-store.ts +30 -0
  320. package/src/state/chat-store.ts +544 -0
  321. package/src/state/feedback-store.ts +160 -0
  322. package/src/state/freebuff-model-store.ts +42 -0
  323. package/src/state/freebuff-session-store.ts +53 -0
  324. package/src/state/login-store.ts +150 -0
  325. package/src/state/message-block-store.ts +149 -0
  326. package/src/state/publish-store.ts +146 -0
  327. package/src/state/queue-panel-store.ts +26 -0
  328. package/src/state/review-store.ts +24 -0
  329. package/src/testing/env.ts +46 -0
  330. package/src/types/chat-state.ts +15 -0
  331. package/src/types/chat.ts +241 -0
  332. package/src/types/contracts/send-message.ts +12 -0
  333. package/src/types/env.ts +98 -0
  334. package/src/types/freebuff-session.ts +17 -0
  335. package/src/types/function-params.ts +28 -0
  336. package/src/types/react19-compat.d.ts +19 -0
  337. package/src/types/store.ts +111 -0
  338. package/src/types/theme-system.ts +144 -0
  339. package/src/types/utils.ts +3 -0
  340. package/src/utils/__tests__/active-run.test.ts +140 -0
  341. package/src/utils/__tests__/activity-tracker.test.ts +440 -0
  342. package/src/utils/__tests__/agent-display.test.ts +139 -0
  343. package/src/utils/__tests__/analytics-client.test.ts +262 -0
  344. package/src/utils/__tests__/anonymous-id.test.ts +74 -0
  345. package/src/utils/__tests__/anvil-api.test.ts +578 -0
  346. package/src/utils/__tests__/arrays.test.ts +264 -0
  347. package/src/utils/__tests__/bash-context-processor.test.ts +38 -0
  348. package/src/utils/__tests__/block-processor.test.ts +810 -0
  349. package/src/utils/__tests__/chat-history.test.ts +203 -0
  350. package/src/utils/__tests__/chat-input-key-intercept.test.ts +40 -0
  351. package/src/utils/__tests__/chat-meta.test.ts +130 -0
  352. package/src/utils/__tests__/clipboard-linux.test.ts +359 -0
  353. package/src/utils/__tests__/clipboard.test.ts +910 -0
  354. package/src/utils/__tests__/code-search-summary.test.ts +84 -0
  355. package/src/utils/__tests__/collapse-helpers.test.ts +1109 -0
  356. package/src/utils/__tests__/error-handling.test.ts +658 -0
  357. package/src/utils/__tests__/exit-cleanly.test.ts +110 -0
  358. package/src/utils/__tests__/feedback-helpers.test.ts +444 -0
  359. package/src/utils/__tests__/feedback-submission.test.ts +26 -0
  360. package/src/utils/__tests__/fetch-usage.test.ts +355 -0
  361. package/src/utils/__tests__/fingerprint.test.ts +144 -0
  362. package/src/utils/__tests__/fixtures/terminal-command-broker-child.ts +7 -0
  363. package/src/utils/__tests__/fixtures/terminal-command-broker-entry.ts +3 -0
  364. package/src/utils/__tests__/fixtures/terminal-command-broker-owner.ts +33 -0
  365. package/src/utils/__tests__/format-elapsed-time.test.ts +103 -0
  366. package/src/utils/__tests__/format-timeout.test.ts +87 -0
  367. package/src/utils/__tests__/freebuff-instance-owner.test.ts +69 -0
  368. package/src/utils/__tests__/freebuff-model-navigation.test.ts +102 -0
  369. package/src/utils/__tests__/freebuff-premium-reset.test.ts +79 -0
  370. package/src/utils/__tests__/freebuff-referral-cache.test.ts +116 -0
  371. package/src/utils/__tests__/freebuff-session-api.test.ts +215 -0
  372. package/src/utils/__tests__/freebuff-session-display.test.ts +23 -0
  373. package/src/utils/__tests__/freebuff-streak-line.test.ts +217 -0
  374. package/src/utils/__tests__/image-dimensions.test.ts +249 -0
  375. package/src/utils/__tests__/image-processor.test.ts +209 -0
  376. package/src/utils/__tests__/implementor-helpers.test.ts +1458 -0
  377. package/src/utils/__tests__/keyboard-actions.test.ts +660 -0
  378. package/src/utils/__tests__/layout-helpers.test.ts +36 -0
  379. package/src/utils/__tests__/lazy-response-ads.test.ts +167 -0
  380. package/src/utils/__tests__/log-shipper.test.ts +53 -0
  381. package/src/utils/__tests__/markdown-renderer.test.tsx +421 -0
  382. package/src/utils/__tests__/message-block-helpers.test.ts +1263 -0
  383. package/src/utils/__tests__/message-updater.test.ts +645 -0
  384. package/src/utils/__tests__/osc-timeout-scenarios.test.ts +172 -0
  385. package/src/utils/__tests__/pending-attachments.test.ts +277 -0
  386. package/src/utils/__tests__/polling-backoff.test.ts +51 -0
  387. package/src/utils/__tests__/queue-panel-actions.test.ts +148 -0
  388. package/src/utils/__tests__/run-state-storage.test.ts +919 -0
  389. package/src/utils/__tests__/safe-json.test.ts +77 -0
  390. package/src/utils/__tests__/sdk-event-handlers.test.ts +685 -0
  391. package/src/utils/__tests__/send-message-helpers.test.ts +1898 -0
  392. package/src/utils/__tests__/send-message-timer.test.ts +64 -0
  393. package/src/utils/__tests__/settings.test.ts +99 -0
  394. package/src/utils/__tests__/strings.test.ts +192 -0
  395. package/src/utils/__tests__/terminal-color-detection.test.ts +425 -0
  396. package/src/utils/__tests__/terminal-command-broker.test.ts +528 -0
  397. package/src/utils/__tests__/terminal-enter-detection.test.ts +77 -0
  398. package/src/utils/__tests__/terminal-io.test.ts +137 -0
  399. package/src/utils/__tests__/terminal-protocol-controller.test.ts +119 -0
  400. package/src/utils/__tests__/text-layout.test.ts +82 -0
  401. package/src/utils/__tests__/theme-platform-detection.test.ts +125 -0
  402. package/src/utils/__tests__/think-tag-parser.test.ts +159 -0
  403. package/src/utils/__tests__/trace-writer.test.ts +223 -0
  404. package/src/utils/__tests__/trim-chat-logs.test.ts +78 -0
  405. package/src/utils/__tests__/usage-banner-state.test.ts +298 -0
  406. package/src/utils/__tests__/validation-error-formatting.test.ts +126 -0
  407. package/src/utils/__tests__/windows-terminal-health.test.ts +101 -0
  408. package/src/utils/__tests__/write-file-atomic.test.ts +127 -0
  409. package/src/utils/active-run.ts +131 -0
  410. package/src/utils/activity-tracker.ts +66 -0
  411. package/src/utils/agent-display.ts +87 -0
  412. package/src/utils/agent-helpers.ts +27 -0
  413. package/src/utils/agent-id-utils.ts +14 -0
  414. package/src/utils/ai-message-id.ts +12 -0
  415. package/src/utils/analytics.ts +361 -0
  416. package/src/utils/anonymous-id.ts +87 -0
  417. package/src/utils/anvil-api.ts +612 -0
  418. package/src/utils/anvil-client.ts +217 -0
  419. package/src/utils/arrays.ts +117 -0
  420. package/src/utils/auth.ts +258 -0
  421. package/src/utils/bash-context-processor.ts +47 -0
  422. package/src/utils/bash-messages.ts +108 -0
  423. package/src/utils/block-margins.ts +35 -0
  424. package/src/utils/block-operations.ts +523 -0
  425. package/src/utils/block-processor.ts +213 -0
  426. package/src/utils/chat-history.ts +253 -0
  427. package/src/utils/chat-input-key-intercept.ts +52 -0
  428. package/src/utils/chat-meta.ts +90 -0
  429. package/src/utils/chat-scroll-accel.ts +151 -0
  430. package/src/utils/clipboard-image.ts +588 -0
  431. package/src/utils/clipboard.ts +397 -0
  432. package/src/utils/code-search-summary.ts +70 -0
  433. package/src/utils/collapse-helpers.ts +256 -0
  434. package/src/utils/config-dir.ts +24 -0
  435. package/src/utils/constants.ts +190 -0
  436. package/src/utils/create-event-handler-state.ts +73 -0
  437. package/src/utils/create-run-config.ts +121 -0
  438. package/src/utils/detect-shell.ts +116 -0
  439. package/src/utils/directory-browser.ts +72 -0
  440. package/src/utils/engagement.ts +55 -0
  441. package/src/utils/env.ts +92 -0
  442. package/src/utils/error-handling.ts +255 -0
  443. package/src/utils/error-messages.ts +62 -0
  444. package/src/utils/exit-cleanly.ts +86 -0
  445. package/src/utils/feedback-helpers.ts +103 -0
  446. package/src/utils/feedback-submission.ts +22 -0
  447. package/src/utils/fetch-usage.ts +76 -0
  448. package/src/utils/fingerprint.ts +245 -0
  449. package/src/utils/format-elapsed-time.ts +33 -0
  450. package/src/utils/format-session-units.ts +6 -0
  451. package/src/utils/format-timeout.ts +28 -0
  452. package/src/utils/format-validation-errors-for-message.ts +84 -0
  453. package/src/utils/freebuff-agent-selection.ts +33 -0
  454. package/src/utils/freebuff-instance-owner.ts +66 -0
  455. package/src/utils/freebuff-model-navigation.ts +50 -0
  456. package/src/utils/freebuff-premium-reset.ts +55 -0
  457. package/src/utils/freebuff-referral-cache.ts +51 -0
  458. package/src/utils/freebuff-session-api.ts +243 -0
  459. package/src/utils/freebuff-session-display.ts +21 -0
  460. package/src/utils/freebuff-streak-line.ts +93 -0
  461. package/src/utils/git.ts +17 -0
  462. package/src/utils/helpers.ts +47 -0
  463. package/src/utils/image-display.ts +67 -0
  464. package/src/utils/image-handler.ts +325 -0
  465. package/src/utils/image-processor.ts +133 -0
  466. package/src/utils/image-thumbnail.ts +78 -0
  467. package/src/utils/implementor-helpers.ts +787 -0
  468. package/src/utils/input-modes.ts +174 -0
  469. package/src/utils/keyboard-actions.ts +392 -0
  470. package/src/utils/keypad-keys.ts +47 -0
  471. package/src/utils/layout-helpers.ts +33 -0
  472. package/src/utils/lazy-response-ads.ts +1 -0
  473. package/src/utils/local-agent-registry.ts +474 -0
  474. package/src/utils/log-shipper.ts +126 -0
  475. package/src/utils/logger.ts +327 -0
  476. package/src/utils/markdown-renderer.tsx +1090 -0
  477. package/src/utils/math.ts +3 -0
  478. package/src/utils/message-block-helpers.ts +684 -0
  479. package/src/utils/message-history.ts +143 -0
  480. package/src/utils/message-tree-utils.ts +67 -0
  481. package/src/utils/message-updater.ts +279 -0
  482. package/src/utils/open-file.ts +146 -0
  483. package/src/utils/open-url.ts +63 -0
  484. package/src/utils/path-completion.ts +111 -0
  485. package/src/utils/path-helpers.ts +38 -0
  486. package/src/utils/pending-attachments.ts +360 -0
  487. package/src/utils/polling-backoff.ts +59 -0
  488. package/src/utils/project-picker.ts +12 -0
  489. package/src/utils/queue-panel-actions.ts +70 -0
  490. package/src/utils/recent-projects.ts +156 -0
  491. package/src/utils/renderer-cleanup.ts +233 -0
  492. package/src/utils/response-ad-positions.ts +1 -0
  493. package/src/utils/run-state-storage.ts +603 -0
  494. package/src/utils/safe-json.ts +153 -0
  495. package/src/utils/sdk-event-handlers.ts +571 -0
  496. package/src/utils/send-message-helpers.ts +210 -0
  497. package/src/utils/send-message-timer.ts +110 -0
  498. package/src/utils/settings.ts +226 -0
  499. package/src/utils/skill-registry.ts +94 -0
  500. package/src/utils/spawn-agent-matcher.ts +55 -0
  501. package/src/utils/status-indicator-state.ts +107 -0
  502. package/src/utils/stream-chunk-processor.ts +57 -0
  503. package/src/utils/strings.ts +271 -0
  504. package/src/utils/subscription.ts +31 -0
  505. package/src/utils/syntax-highlighter.tsx +19 -0
  506. package/src/utils/terminal-color-detection.ts +474 -0
  507. package/src/utils/terminal-command-broker.ts +467 -0
  508. package/src/utils/terminal-enter-detection.ts +63 -0
  509. package/src/utils/terminal-images.ts +229 -0
  510. package/src/utils/terminal-io.ts +85 -0
  511. package/src/utils/terminal-protocol-controller.ts +173 -0
  512. package/src/utils/terminal-reset-sequences.ts +33 -0
  513. package/src/utils/terminal-title.ts +84 -0
  514. package/src/utils/terminal-watchdog.ts +366 -0
  515. package/src/utils/text-layout.ts +149 -0
  516. package/src/utils/theme-config.ts +141 -0
  517. package/src/utils/theme-system.ts +1206 -0
  518. package/src/utils/think-tag-parser.ts +95 -0
  519. package/src/utils/time-format.ts +21 -0
  520. package/src/utils/trace-writer.ts +162 -0
  521. package/src/utils/ui-constants.ts +69 -0
  522. package/src/utils/update-checker.ts +110 -0
  523. package/src/utils/usage-banner-state.ts +146 -0
  524. package/src/utils/validation-error-formatting.ts +86 -0
  525. package/src/utils/validation-error-helpers.ts +19 -0
  526. package/src/utils/windows-terminal-health.ts +124 -0
  527. package/src/utils/word-wrap-utils.ts +53 -0
  528. package/src/utils/write-file-atomic.ts +54 -0
  529. package/src/utils/yield-to-event-loop.ts +9 -0
@@ -0,0 +1,806 @@
1
+ import { isDirectDeepSeekMode } from '@anvil/common/constants/deepseek-direct'
2
+ import {
3
+ FALLBACK_FREEBUFF_MODEL_ID,
4
+ getFreebuffModel,
5
+ isFreebuffLimitedOfferModelId,
6
+ LIMITED_FREEBUFF_MODEL_ID,
7
+ resolveFreebuffModelForAccessTier,
8
+ } from '@anvil/common/constants/freebuff-models'
9
+ import {
10
+ getLimitedModelOffers,
11
+ getRateLimitsByModel,
12
+ getReferralInfo,
13
+ } from '@anvil/common/types/freebuff-session'
14
+ import { useEffect } from 'react'
15
+
16
+ import {
17
+ getSelectedFreebuffModel,
18
+ useFreebuffModelStore,
19
+ } from '../state/freebuff-model-store'
20
+ import { useChatStore } from '../state/chat-store'
21
+ import { useFreebuffSessionStore } from '../state/freebuff-session-store'
22
+ import { getAuthTokenDetails } from '../utils/auth'
23
+ import { stopActiveRun } from '../utils/active-run'
24
+ import { IS_FREEBUFF } from '../utils/constants'
25
+ import {
26
+ isFreebuffInstanceOwnedByDeadLocalProcess,
27
+ recordFreebuffInstanceOwner,
28
+ } from '../utils/freebuff-instance-owner'
29
+ import { logger } from '../utils/logger'
30
+ import { getSystemMessage } from '../utils/message-history'
31
+ import {
32
+ clearReferralCache,
33
+ getCachedReferral,
34
+ rememberReferral,
35
+ } from '../utils/freebuff-referral-cache'
36
+ import {
37
+ callFreebuffSession,
38
+ classifyFreebuffSessionRequestFailure,
39
+ FreebuffSessionRequestError,
40
+ holdsLiveFreebuffSlot,
41
+ isFreebuffSessionTimeoutError,
42
+ mergeCompactActiveSession,
43
+ releaseFreebuffSlot,
44
+ } from '../utils/freebuff-session-api'
45
+ import {
46
+ failedPollDelayMs,
47
+ jitterPollIntervalMs,
48
+ } from '../utils/polling-backoff'
49
+ import { saveFreebuffModelPreference } from '../utils/settings'
50
+
51
+ import type { FreebuffSessionResponse } from '../types/freebuff-session'
52
+ import type {
53
+ FreebuffCountryBlockReason,
54
+ FreebuffIpPrivacySignal,
55
+ } from '@anvil/common/types/freebuff-session'
56
+
57
+ const POLL_INTERVAL_ACTIVE_MS = 30_000
58
+
59
+ /** Play the terminal bell so users get an audible notification on admission. */
60
+ const playAdmissionSound = () => {
61
+ try {
62
+ process.stdout.write('\x07')
63
+ } catch {
64
+ // Silent fallback — some terminals/pipes disallow writing to stdout.
65
+ }
66
+ }
67
+
68
+ /** Picks the poll delay after a successful tick. Returns null when the state
69
+ * is terminal (no further polling). */
70
+ function nextDelayMs(next: FreebuffSessionResponse): number | null {
71
+ const activeCadenceMs = jitterPollIntervalMs({
72
+ intervalMs: POLL_INTERVAL_ACTIVE_MS,
73
+ })
74
+ switch (next.status) {
75
+ case 'active':
76
+ // Poll at the normal cadence, but ensure we land just after
77
+ // `expires_at` so the transition shows up promptly instead of leaving
78
+ // the countdown stuck at 0 for up to a full interval.
79
+ return Math.max(
80
+ 1_000,
81
+ Math.min(activeCadenceMs, next.remainingMs + 1_000),
82
+ )
83
+ case 'ended':
84
+ // Inside the grace window we keep checking so the post-grace transition
85
+ // (server returns `none`, we synthesize ended-no-instanceId) is prompt.
86
+ return next.instanceId ? activeCadenceMs : null
87
+ case 'none':
88
+ case 'superseded':
89
+ case 'takeover_prompt':
90
+ case 'country_blocked':
91
+ case 'banned':
92
+ case 'model_locked':
93
+ case 'rate_limited':
94
+ case 'spend_limited':
95
+ case 'ip_capped':
96
+ case 'model_unavailable':
97
+ case 'premium_slot_taken':
98
+ return null
99
+ }
100
+ }
101
+
102
+ // --- Poll-loop control surface ---------------------------------------------
103
+ //
104
+ // The hook below registers a controller object here on mount; module-level
105
+ // imperative functions (restart / mark superseded / mark ended / etc.) talk
106
+ // to it without going through React. Non-React callers (chat-completions
107
+ // gate, exit paths) hit those functions directly.
108
+
109
+ /** How the next tick should behave after a forced restart.
110
+ * - 'rejoin' → POST: claim/rotate a seat (used after explicit end-and-rejoin
111
+ * or when the chat gate kicks us back to the queue).
112
+ * - 'landing' → GET: drop to the model-picker (status 'none') so the user
113
+ * reconfirms a model before rejoining. */
114
+ type RestartMode = 'rejoin' | 'landing'
115
+
116
+ interface PollController {
117
+ /** Cancel the in-flight tick + timer and start a fresh one in `mode`. */
118
+ restart: (mode: RestartMode) => Promise<void>
119
+ apply: (next: FreebuffSessionResponse) => void
120
+ abort: () => void
121
+ }
122
+
123
+ let controller: PollController | null = null
124
+
125
+ /**
126
+ * The model of the most recent EXPLICIT user pick (startFreebuffSession),
127
+ * consumed by the first server response that follows it. Lets the
128
+ * `model_locked` branch tell a deliberate pick apart from a background
129
+ * rejoin/race: only the former deserves a visible explanation. Cleared on
130
+ * every response so a stale pick can never annotate a later, unrelated lock.
131
+ */
132
+ let pendingExplicitPickModel: string | null = null
133
+
134
+ /** Read the current instance id for outgoing chat requests. Defined via
135
+ * `holdsLiveFreebuffSlot` so the two can't drift: an id exists exactly while
136
+ * we hold a live slot (active, or `ended` inside the server-side grace
137
+ * window where the row stays alive until `expires_at + grace`). */
138
+ export function getFreebuffInstanceId(): string | undefined {
139
+ // Direct-to-DeepSeek mode has no server-side session; hand back a stable
140
+ // local id so the chat gate (which requires a live slot) lets messages
141
+ // through instead of holding them for a rejoin that can never happen.
142
+ if (isDirectDeepSeekMode()) return 'local-deepseek'
143
+ const current = useFreebuffSessionStore.getState().session
144
+ if (!current || !holdsLiveFreebuffSlot(current)) return undefined
145
+ return 'instanceId' in current ? current.instanceId : undefined
146
+ }
147
+
148
+ /** True when the session represents a server-side slot the caller is
149
+ * holding (active, or in the post-expiry grace window with a live
150
+ * instance id). Chat requests are only admissible in these states — once
151
+ * the slot is gone, `getFreebuffInstanceId` returns undefined and the
152
+ * server rejects the request — so the message queue gates on this before
153
+ * firing queued work. Same predicate gates DELETE on exit: outside these
154
+ * states there is no server row to release. */
155
+ function toLandingSession(
156
+ current: FreebuffSessionResponse | null,
157
+ ): Extract<FreebuffSessionResponse, { status: 'none' }> {
158
+ const accessTier =
159
+ current && 'accessTier' in current ? current.accessTier : undefined
160
+ const rateLimitsByModel = getRateLimitsByModel(current)
161
+ const referral = accessTier
162
+ ? (getReferralInfo(current) ?? getCachedReferral(accessTier))
163
+ : undefined
164
+ const countryCode =
165
+ current && 'countryCode' in current ? current.countryCode : undefined
166
+ const countryBlockReason =
167
+ current && 'countryBlockReason' in current
168
+ ? current.countryBlockReason
169
+ : undefined
170
+ const ipPrivacySignals =
171
+ current && 'ipPrivacySignals' in current
172
+ ? current.ipPrivacySignals
173
+ : undefined
174
+ // Carried over so the picker doesn't lose the limited-offer row for the one
175
+ // frame between synthesizing this state and the GET that refreshes it. The
176
+ // GET is authoritative: if the wave has since been spent, the next response
177
+ // simply omits the offer and the row disappears.
178
+ const limitedModelOffers = getLimitedModelOffers(current)
179
+
180
+ return {
181
+ status: 'none',
182
+ ...(accessTier ? { accessTier } : {}),
183
+ ...(rateLimitsByModel ? { rateLimitsByModel } : {}),
184
+ ...(referral ? { referral } : {}),
185
+ ...(limitedModelOffers.length > 0 ? { limitedModelOffers } : {}),
186
+ ...(countryCode ? { countryCode } : {}),
187
+ ...(countryBlockReason ? { countryBlockReason } : {}),
188
+ ...(ipPrivacySignals ? { ipPrivacySignals } : {}),
189
+ }
190
+ }
191
+
192
+ interface RestartOpts {
193
+ resetChat?: boolean
194
+ /** DELETE the held slot before restarting so the next POST starts clean. */
195
+ releaseSlot?: boolean
196
+ }
197
+
198
+ async function restartFreebuffSession(
199
+ mode: RestartMode,
200
+ opts: RestartOpts = {},
201
+ ): Promise<void> {
202
+ if (!IS_FREEBUFF) return
203
+ // A reset changes chat ownership. Stop and checkpoint the old run before
204
+ // resetting its store so late deltas cannot land in the next session.
205
+ if (opts.resetChat) {
206
+ stopActiveRun('session-transition')
207
+ useChatStore.getState().reset()
208
+ }
209
+ // Halt the running poll loop before we touch local stores or DELETE the
210
+ // slot. Otherwise an in-flight GET could land mid-reset and overwrite
211
+ // state, or the next scheduled tick could fire between DELETE and
212
+ // restart() with stale assumptions. restart() re-aborts and re-arms
213
+ // below; the extra abort here is cheap.
214
+ controller?.abort()
215
+ if (opts.releaseSlot) await releaseFreebuffSlot()
216
+ await controller?.restart(mode)
217
+ }
218
+
219
+ /**
220
+ * Re-POST to the server (rejoining the queue / rotating the instance id).
221
+ * Pass `resetChat: true` to also wipe local chat history — used when
222
+ * rejoining after a session ended so the next admitted session starts fresh.
223
+ */
224
+ export function refreshFreebuffSession(
225
+ opts: { resetChat?: boolean } = {},
226
+ ): Promise<void> {
227
+ return restartFreebuffSession('rejoin', { resetChat: opts.resetChat })
228
+ }
229
+
230
+ /**
231
+ * Drop back to the pre-join landing state (model picker) instead of auto
232
+ * re-queuing. Used after a session ends: the user lands on the picker so
233
+ * they consciously choose a model and hit Enter to join, rather than being
234
+ * silently re-queued for whatever model they last used.
235
+ */
236
+ export function returnToFreebuffLanding(
237
+ opts: { resetChat?: boolean } = {},
238
+ ): Promise<void> {
239
+ return restartFreebuffSession('landing', {
240
+ resetChat: opts.resetChat,
241
+ releaseSlot: true,
242
+ })
243
+ }
244
+
245
+ /** Refresh picker-only metadata (quota and queue depths) while staying on the
246
+ * model selection screen. Used when a midnight-Pacific session quota reset
247
+ * passes while the landing screen is open. */
248
+ export function refreshFreebuffLandingMetadata(): Promise<void> {
249
+ return restartFreebuffSession('landing')
250
+ }
251
+
252
+ /**
253
+ * Start a session on `model` (admitted immediately server-side). Dual-purpose:
254
+ * - First start: called from the pre-chat landing picker. The session starts
255
+ * at `none` (GET-only); this is the user's explicit commitment to enter.
256
+ * - Switch: called when the user picks a different model from the landing
257
+ * screen. The server admits them on the new model right away.
258
+ *
259
+ * If the server has already admitted them on a different model, it responds
260
+ * with `model_locked`; because this is a deliberate pick, the tick loop ends
261
+ * that session and re-claims on the requested model (see the model_locked
262
+ * branch). Background rejoins hitting the same lock revert silently instead.
263
+ */
264
+ export function startFreebuffSession(model: string): Promise<void> {
265
+ if (!IS_FREEBUFF) return Promise.resolve()
266
+ // This is the only explicit user-pick path (called from the picker on
267
+ // click / Enter), so persistence belongs here — and ONLY here. Server-
268
+ // driven flips (`model_locked`, `model_unavailable`, takeover) go
269
+ // through `setSelectedModel` directly, which never writes to disk.
270
+ const current = useFreebuffSessionStore.getState().session
271
+ const accessTier =
272
+ current && 'accessTier' in current ? current.accessTier : 'full'
273
+ const resolved = resolveFreebuffModelForAccessTier(model, accessTier)
274
+ // Remember that the next POST is a deliberate pick, so a `model_locked`
275
+ // rejection explains itself in chat instead of reverting silently.
276
+ pendingExplicitPickModel = resolved
277
+ useFreebuffModelStore.getState().setSelectedModel(resolved)
278
+ saveFreebuffModelPreference(resolved)
279
+ return restartFreebuffSession('rejoin')
280
+ }
281
+
282
+ let takeoverInFlight: Promise<void> | null = null
283
+
284
+ export function takeOverFreebuffSession(): Promise<void> {
285
+ if (!IS_FREEBUFF) return Promise.resolve()
286
+ if (takeoverInFlight) return takeoverInFlight
287
+
288
+ const { session } = useFreebuffSessionStore.getState()
289
+ if (session?.status !== 'takeover_prompt') {
290
+ return Promise.resolve()
291
+ }
292
+
293
+ useFreebuffModelStore.getState().setSelectedModel(session.model)
294
+ takeoverInFlight = restartFreebuffSession('rejoin').finally(() => {
295
+ takeoverInFlight = null
296
+ })
297
+ return takeoverInFlight
298
+ }
299
+
300
+ export function markFreebuffSessionSuperseded(): void {
301
+ if (!IS_FREEBUFF) return
302
+ controller?.abort()
303
+ controller?.apply({ status: 'superseded' })
304
+ }
305
+
306
+ /** Flip into the terminal `country_blocked` state from outside the poll loop.
307
+ * Used when the chat-completions gate rejects on country even though the
308
+ * session-level country check did not catch the request first.
309
+ * Transitioning the session state here unmounts the Chat surface in favor of
310
+ * the landing screen's country_blocked message, so the user can't keep typing
311
+ * and sending doomed requests. */
312
+ export function markFreebuffSessionCountryBlocked(params: {
313
+ countryCode: string
314
+ countryBlockReason?: FreebuffCountryBlockReason
315
+ ipPrivacySignals?: FreebuffIpPrivacySignal[]
316
+ }): void {
317
+ if (!IS_FREEBUFF) return
318
+ controller?.abort()
319
+ controller?.apply({ status: 'country_blocked', ...params })
320
+ // Best-effort DELETE so we don't hold a session row the server is already
321
+ // refusing to serve at chat time.
322
+ releaseFreebuffSlot().catch(() => {})
323
+ }
324
+
325
+ /** Flip into the local `ended` state without an instanceId (server has lost
326
+ * our row). The chat surface stays mounted with the rejoin banner.
327
+ * Preserves any `rateLimitsByModel` snapshot from the prior session so the
328
+ * banner can show today's session count without an extra fetch. */
329
+ export function markFreebuffSessionEnded(): void {
330
+ if (!IS_FREEBUFF) return
331
+ controller?.abort()
332
+ const current = useFreebuffSessionStore.getState().session
333
+ const rateLimitsByModel = getRateLimitsByModel(current)
334
+ controller?.apply({
335
+ status: 'ended',
336
+ accessTier:
337
+ current && 'accessTier' in current ? current.accessTier : undefined,
338
+ rateLimitsByModel,
339
+ })
340
+ }
341
+
342
+ interface UseFreebuffSessionResult {
343
+ session: FreebuffSessionResponse | null
344
+ failure: ReturnType<typeof useFreebuffSessionStore.getState>['failure']
345
+ }
346
+
347
+ /**
348
+ * Manages the freebuff session lifecycle:
349
+ * - GET on mount to probe state (no auto-join; the user picks a model in
350
+ * the landing screen, which calls startFreebuffSession)
351
+ * - if the probe sees an existing seat, auto-takes-over when the prior
352
+ * local owner process is gone; otherwise asks before POSTing to rotate
353
+ * the instance id so any other CLI on the same account is superseded
354
+ * - polls GET while active to keep state fresh
355
+ * - re-POSTs on explicit refresh (chat gate rejected us, user switched
356
+ * models, user rejoined after ending)
357
+ * - DELETE on unmount so the slot frees up for the next user
358
+ * - plays a bell on admission to an active session
359
+ */
360
+ export function useFreebuffSession(): UseFreebuffSessionResult {
361
+ const session = useFreebuffSessionStore((s) => s.session)
362
+ const failure = useFreebuffSessionStore((s) => s.failure)
363
+
364
+ useEffect(() => {
365
+ const { setSession, setFailure } = useFreebuffSessionStore.getState()
366
+
367
+ if (!IS_FREEBUFF) {
368
+ // Non-freebuff (Anvil) builds never gate on a free session; leave the
369
+ // store empty (app.tsx's session routing is all behind IS_FREEBUFF).
370
+ setSession(null)
371
+ return
372
+ }
373
+
374
+ if (isDirectDeepSeekMode()) {
375
+ // No server-side session to poll in direct-to-DeepSeek mode. Set a
376
+ // synthetic active session so the chat surface mounts normally and
377
+ // getFreebuffInstanceId() satisfies the message-send gate.
378
+ setSession({
379
+ status: 'active',
380
+ accessTier: 'full',
381
+ instanceId: 'local-deepseek',
382
+ model: getSelectedFreebuffModel() || FALLBACK_FREEBUFF_MODEL_ID,
383
+ admittedAt: new Date().toISOString(),
384
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
385
+ remainingMs: 24 * 60 * 60 * 1000,
386
+ })
387
+ setFailure(null)
388
+ return
389
+ }
390
+
391
+ const { token } = getAuthTokenDetails()
392
+ if (!token) {
393
+ logger.warn(
394
+ {},
395
+ '[freebuff-session] No auth token; skipping free-session admission',
396
+ )
397
+ setFailure({
398
+ type: 'other',
399
+ message: 'Not authenticated',
400
+ retry: null,
401
+ outcomeUnknown: false,
402
+ })
403
+ return
404
+ }
405
+
406
+ let cancelled = false
407
+ let abortController = new AbortController()
408
+ let timer: ReturnType<typeof setTimeout> | null = null
409
+ let previousStatus: FreebuffSessionResponse['status'] | null = null
410
+ // A compact response for an unexpected session identity has no safe quota
411
+ // snapshot to retain, so force exactly one rich poll to restore it.
412
+ let needsFullActivePoll = false
413
+ let restartGeneration = 0
414
+ let consecutiveFailures = 0
415
+ // Method for the NEXT tick. GET is read-only; POST claims/rotates a seat.
416
+ // Startup is GET (probe before committing). After any POST completes we
417
+ // flip back to GET. refresh() sets it to 'POST' for explicit join/rejoin;
418
+ // the startup takeover branch does the same when the probe finds a seat.
419
+ let nextMethod: 'GET' | 'POST' = 'GET'
420
+
421
+ const apply = (next: FreebuffSessionResponse) => {
422
+ rememberReferral(next)
423
+ if (next.status === 'active') {
424
+ useFreebuffModelStore.getState().setSelectedModel(next.model)
425
+ recordFreebuffInstanceOwner(next.instanceId)
426
+ } else if (next.status === 'none' && next.accessTier === 'limited') {
427
+ useFreebuffModelStore
428
+ .getState()
429
+ .setSelectedModel(LIMITED_FREEBUFF_MODEL_ID)
430
+ }
431
+ setSession(next)
432
+ setFailure(null)
433
+ previousStatus = next.status
434
+ }
435
+
436
+ const clearTimer = () => {
437
+ if (timer) {
438
+ clearTimeout(timer)
439
+ timer = null
440
+ }
441
+ }
442
+
443
+ const schedule = (ms: number) => {
444
+ if (cancelled) return
445
+ clearTimer()
446
+ timer = setTimeout(tick, ms)
447
+ }
448
+
449
+ const tick = async () => {
450
+ if (cancelled) return
451
+ const method = nextMethod
452
+ const instanceId = getFreebuffInstanceId()
453
+ const model = getSelectedFreebuffModel()
454
+ const compact =
455
+ method === 'GET' && previousStatus === 'active' && !needsFullActivePoll
456
+ const fetchController = abortController
457
+ const generation = restartGeneration
458
+ try {
459
+ const next = await callFreebuffSession(method, token, {
460
+ signal: fetchController.signal,
461
+ instanceId,
462
+ model,
463
+ compact,
464
+ })
465
+ if (
466
+ cancelled ||
467
+ fetchController.signal.aborted ||
468
+ generation !== restartGeneration
469
+ ) {
470
+ return
471
+ }
472
+ consecutiveFailures = 0
473
+ // After any successful call, default back to GET polling. The
474
+ // takeover and model_locked branches below override this when they
475
+ // need another POST.
476
+ nextMethod = 'GET'
477
+
478
+ // Consume the explicit-pick marker: it annotates exactly the first
479
+ // response after a user pick, whatever that response turns out to be.
480
+ const explicitPickModel = pendingExplicitPickModel
481
+ pendingExplicitPickModel = null
482
+
483
+ // The session is model-locked server-side: an active session on
484
+ // another model rejects the switch. Two cases:
485
+ // - DELIBERATE pick (the explicit-pick marker was set): honor the
486
+ // click — end the locked session (usually a stale row from a
487
+ // crashed CLI; DELETE is keyed on user, not instance) and
488
+ // re-claim on the requested model. The marker is consume-once,
489
+ // so if the retried POST races another instance back into
490
+ // model_locked we take the revert branch instead of looping.
491
+ // - Background rejoin racing an admission: revert the local
492
+ // selection so the active session stays intact. Reverting a
493
+ // deliberate pick silently made "I clicked GLM 5.2 and it
494
+ // switched to DeepSeek V4 Flash" a recurring bug report
495
+ // (2026-07-30): sessions live 1h even when idle, so users
496
+ // constantly pick a model while a row is still active.
497
+ if (next.status === 'model_locked') {
498
+ if (explicitPickModel && explicitPickModel !== next.currentModel) {
499
+ const current = getFreebuffModel(next.currentModel).displayName
500
+ const requested = getFreebuffModel(explicitPickModel).displayName
501
+ let released = false
502
+ try {
503
+ await callFreebuffSession('DELETE', token, {
504
+ signal: fetchController.signal,
505
+ })
506
+ released = true
507
+ } catch {
508
+ // DELETE failed — fall through to the revert-with-explanation
509
+ // path below rather than stranding the user mid-switch.
510
+ }
511
+ if (
512
+ cancelled ||
513
+ fetchController.signal.aborted ||
514
+ generation !== restartGeneration
515
+ ) {
516
+ return
517
+ }
518
+ if (released) {
519
+ useChatStore
520
+ .getState()
521
+ .setMessages((prev) => [
522
+ ...prev,
523
+ getSystemMessage(
524
+ `Ended your previous session on ${current} and switched to ${requested}.`,
525
+ ),
526
+ ])
527
+ nextMethod = 'POST'
528
+ schedule(0)
529
+ return
530
+ }
531
+ useChatStore
532
+ .getState()
533
+ .setMessages((prev) => [
534
+ ...prev,
535
+ getSystemMessage(
536
+ `You're already in an active session on ${current}, and ending it failed, so the switch to ${requested} was not applied. Run /end-session, then pick ${requested}. (Sessions end on their own after 1 hour.)`,
537
+ ),
538
+ ])
539
+ }
540
+ useFreebuffModelStore.getState().setSelectedModel(next.currentModel)
541
+ schedule(0)
542
+ return
543
+ }
544
+ if (next.status === 'model_unavailable') {
545
+ // Server says the requested model isn't available right now. Flip
546
+ // to the always-available fallback for this run. In-memory only —
547
+ // `setSelectedModel` doesn't persist, so the user's saved preference
548
+ // is preserved for their next launch.
549
+ //
550
+ // A limited-offer model gets a sentence about it. Silence is fine for
551
+ // deployment hours (the picker row says when they open), but here the
552
+ // user pressed Enter on a row that was on screen a second ago and
553
+ // would otherwise land on a different model with no explanation —
554
+ // they lost a race for the wave's last slot.
555
+ if (isFreebuffLimitedOfferModelId(next.requestedModel)) {
556
+ const requested = getFreebuffModel(next.requestedModel).displayName
557
+ const fallback = getFreebuffModel(
558
+ FALLBACK_FREEBUFF_MODEL_ID,
559
+ ).displayName
560
+ useChatStore
561
+ .getState()
562
+ .setMessages((prev) => [
563
+ ...prev,
564
+ getSystemMessage(
565
+ `${requested}'s trial sessions just ran out, so this session started on ${fallback} instead. Check back later — we release more in batches.`,
566
+ ),
567
+ ])
568
+ }
569
+ useFreebuffModelStore
570
+ .getState()
571
+ .setSelectedModel(FALLBACK_FREEBUFF_MODEL_ID)
572
+ // The unavailable response came from a POST attempt. Re-POST with
573
+ // the fallback model; a GET would only redisplay the old ended row
574
+ // and leave the restart banner stuck in its pending state.
575
+ nextMethod = 'POST'
576
+ schedule(0)
577
+ return
578
+ }
579
+
580
+ // Startup takeover: the initial probe GET saw we already hold a seat
581
+ // (from a prior CLI instance). Stop here and ask before POSTing to
582
+ // rotate our instance id; otherwise opening a second freebuff would
583
+ // immediately supersede the first one.
584
+ // `previousStatus === null` fences this to the very first tick only.
585
+ // Pin the selected model to whatever the server thinks we're on so
586
+ // an explicit takeover preserves our queue position instead of
587
+ // switching queues.
588
+ if (
589
+ method === 'GET' &&
590
+ previousStatus === null &&
591
+ next.status === 'active'
592
+ ) {
593
+ useFreebuffModelStore.getState().setSelectedModel(next.model)
594
+ // A fast restart after Ctrl+C can observe the old server row before
595
+ // best-effort DELETE lands. If the row belongs to a dead local
596
+ // process, silently do the same POST as the Take over button.
597
+ if (isFreebuffInstanceOwnedByDeadLocalProcess(next.instanceId)) {
598
+ nextMethod = 'POST'
599
+ schedule(0)
600
+ return
601
+ }
602
+ apply({ status: 'takeover_prompt', model: next.model })
603
+ return
604
+ }
605
+
606
+ // Bell on admission: the user committed to a model on the landing
607
+ // screen (status 'none'), which POSTs and lands them straight on an
608
+ // active session (admission is immediate).
609
+ if (previousStatus === 'none' && next.status === 'active') {
610
+ playAdmissionSound()
611
+ }
612
+
613
+ // active|ended → none means we've passed the server's hard cutoff.
614
+ // Synthesize a no-instanceId ended state so the chat surface stays
615
+ // mounted with the Enter-to-rejoin banner instead of looping back
616
+ // through the landing screen. Carry forward whichever rate-limit
617
+ // snapshot we have — preferring the fresh `none` snapshot, falling
618
+ // back to whatever was on the prior active/ended row — so the
619
+ // banner's "N of M used today" line stays populated.
620
+ if (
621
+ (previousStatus === 'active' || previousStatus === 'ended') &&
622
+ next.status === 'none'
623
+ ) {
624
+ const current = useFreebuffSessionStore.getState().session
625
+ const rateLimitsByModel =
626
+ next.rateLimitsByModel ?? getRateLimitsByModel(current)
627
+ apply({
628
+ status: 'ended',
629
+ accessTier:
630
+ next.accessTier ??
631
+ (current && 'accessTier' in current
632
+ ? current.accessTier
633
+ : undefined),
634
+ rateLimitsByModel,
635
+ })
636
+ return
637
+ }
638
+
639
+ if (compact && next.status === 'active') {
640
+ const merged = mergeCompactActiveSession(
641
+ useFreebuffSessionStore.getState().session,
642
+ next,
643
+ )
644
+ needsFullActivePoll = merged === null
645
+ apply(merged ?? next)
646
+ } else {
647
+ needsFullActivePoll = false
648
+ apply(next)
649
+ }
650
+ if (needsFullActivePoll) {
651
+ schedule(0)
652
+ return
653
+ }
654
+ const delay = nextDelayMs(next)
655
+ if (delay !== null) schedule(delay)
656
+ } catch (err) {
657
+ if (
658
+ cancelled ||
659
+ fetchController.signal.aborted ||
660
+ generation !== restartGeneration
661
+ ) {
662
+ return
663
+ }
664
+ const msg = err instanceof Error ? err.message : String(err)
665
+ consecutiveFailures++
666
+ const disposition = classifyFreebuffSessionRequestFailure(method, err)
667
+ const shouldRetry = disposition === 'retry'
668
+ const retryAfterMs =
669
+ err instanceof FreebuffSessionRequestError
670
+ ? err.retryAfterMs
671
+ : undefined
672
+ const delayMs = shouldRetry
673
+ ? failedPollDelayMs({
674
+ consecutiveFailures,
675
+ retryAfterMs,
676
+ })
677
+ : null
678
+ logger.warn(
679
+ { error: msg, method, consecutiveFailures, delayMs, shouldRetry },
680
+ shouldRetry
681
+ ? '[freebuff-session] fetch failed; backing off'
682
+ : '[freebuff-session] fetch failed; automatic retry stopped',
683
+ )
684
+ const retry =
685
+ delayMs === null
686
+ ? null
687
+ : {
688
+ attempt: consecutiveFailures + 1,
689
+ retryAtMs: Date.now() + delayMs,
690
+ }
691
+ const failure = {
692
+ message: msg,
693
+ retry,
694
+ outcomeUnknown: disposition === 'unknown',
695
+ }
696
+ if (err instanceof FreebuffSessionRequestError) {
697
+ setFailure({
698
+ ...failure,
699
+ type: 'http',
700
+ statusCode: err.statusCode,
701
+ })
702
+ } else if (isFreebuffSessionTimeoutError(err)) {
703
+ setFailure({ ...failure, type: 'timeout' })
704
+ } else {
705
+ setFailure({ ...failure, type: 'other' })
706
+ }
707
+ if (delayMs !== null) schedule(delayMs)
708
+ }
709
+ }
710
+
711
+ controller = {
712
+ restart: async (mode) => {
713
+ const generation = ++restartGeneration
714
+ clearTimer()
715
+ // Abort any in-flight fetch so it can't race us and overwrite state.
716
+ abortController.abort()
717
+ abortController = new AbortController()
718
+ // Reset previousStatus so the admission bell still fires after
719
+ // a forced restart, and so the active|ended → none synthesis below
720
+ // doesn't bounce a 'landing' restart straight back to 'ended'.
721
+ previousStatus = null
722
+ needsFullActivePoll = false
723
+ consecutiveFailures = 0
724
+ setFailure(null)
725
+ if (mode === 'landing') {
726
+ nextMethod = 'GET'
727
+ // Land on the picker immediately. We can't go through the normal
728
+ // tick/apply path because a server-side row that hasn't been
729
+ // swept yet would trip the startup-takeover branch into an
730
+ // auto-POST — the exact silent-rejoin this mode exists to
731
+ // prevent. But the picker still needs live quota snapshots, so kick
732
+ // off a fire-and-forget GET and extract only picker metadata from
733
+ // the response, ignoring whatever status it claims. Polling resumes
734
+ // when the user commits to a model via startFreebuffSession.
735
+ const landingSession = toLandingSession(
736
+ useFreebuffSessionStore.getState().session,
737
+ )
738
+ apply(landingSession)
739
+ const fetchController = abortController
740
+ callFreebuffSession('GET', token, {
741
+ signal: fetchController.signal,
742
+ })
743
+ .then((response) => {
744
+ if (
745
+ cancelled ||
746
+ fetchController.signal.aborted ||
747
+ generation !== restartGeneration
748
+ ) {
749
+ return
750
+ }
751
+ if (response.status === 'none') {
752
+ // Preserve cached quota/location fields only when the tier is
753
+ // unchanged (or an older response omitted it). Fresh response
754
+ // fields win through the following object spread.
755
+ const canReuseLandingMetadata =
756
+ response.accessTier === undefined ||
757
+ response.accessTier === landingSession.accessTier
758
+ apply({
759
+ ...(canReuseLandingMetadata ? landingSession : {}),
760
+ ...response,
761
+ status: 'none',
762
+ accessTier: response.accessTier ?? landingSession.accessTier,
763
+ // A clean `none` response is authoritative for referral
764
+ // state. Do not retain the cached landing value when the
765
+ // server omits it (program disabled / identity removed).
766
+ referral: response.referral,
767
+ })
768
+ }
769
+ })
770
+ .catch(() => {
771
+ // Silent — blank hints are acceptable if the fetch fails.
772
+ })
773
+ return
774
+ }
775
+ nextMethod = 'POST'
776
+ await tick()
777
+ },
778
+ apply,
779
+ abort: () => {
780
+ clearTimer()
781
+ abortController.abort()
782
+ },
783
+ }
784
+
785
+ tick()
786
+
787
+ return () => {
788
+ cancelled = true
789
+ abortController.abort()
790
+ clearTimer()
791
+ const current = useFreebuffSessionStore.getState().session
792
+ controller = null
793
+ clearReferralCache()
794
+
795
+ // Fire-and-forget DELETE. Only release if we actually held a slot so
796
+ // we don't generate spurious DELETEs (e.g. HMR before POST completes).
797
+ if (holdsLiveFreebuffSlot(current)) {
798
+ callFreebuffSession('DELETE', token).catch(() => {})
799
+ }
800
+ setSession(null)
801
+ setFailure(null)
802
+ }
803
+ }, [])
804
+
805
+ return { session, failure }
806
+ }