@oar-sh/oar 0.9.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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,387 @@
1
+ // Grok account management inside Settings → Providers → Grok.
2
+ //
3
+ // The relay drives the CLI's device-code flow: Sign in starts `grok login
4
+ // --device-auth` on the host, the authorize URL shows up here (openable on a
5
+ // phone — that is the point of the relay), and the CLI polls x.ai and finishes
6
+ // on its own. Nothing is ever pasted back, so this is the Claude flow minus a
7
+ // whole state: idle → starting → awaiting_authorization → success | error.
8
+ //
9
+ // Every visible state comes from the `grok_auth_state` socket broadcast (same
10
+ // payload as GET /api/grok/auth/status), so the flow survives closing the
11
+ // modal, switching tabs, or being finished from a different device. Rendering
12
+ // is therefore idempotent: it reads the latest payload and never assumes a
13
+ // local step sequence — success in particular arrives twice, once off the
14
+ // cached status and once when the confirming read lands.
15
+ //
16
+ // Secret hygiene: nothing secret transits the relay. The `user_code` is a
17
+ // public device code, shown only so it can be checked against what the browser
18
+ // displays, and the token is written by the CLI straight into ~/.grok/auth.json.
19
+
20
+ import {
21
+ escHtml,
22
+ openSummaryModal,
23
+ closeSummaryModal,
24
+ setSummaryModalLoading,
25
+ showTransientRelayNotice,
26
+ } from './store.js';
27
+ import { copyTextToClipboard } from './router.js';
28
+ import {
29
+ getGrokAuthStatus,
30
+ startGrokLogin,
31
+ cancelGrokLogin,
32
+ grokLogout,
33
+ } from './api-client.js';
34
+
35
+ const LOGIN_STATES = ['idle', 'starting', 'awaiting_authorization', 'success', 'error'];
36
+ // States where a login session owns the CLI: Sign in/Sign out stay disabled.
37
+ const BUSY_LOGIN_STATES = ['starting', 'awaiting_authorization'];
38
+ const SUCCESS_COLLAPSE_MS = 2500;
39
+ const COPY_RESET_MS = 1500;
40
+
41
+ let authState = null;
42
+ let lastLoginState = 'idle';
43
+ let successCollapsed = false;
44
+ let successCollapseTimer = null;
45
+ // Client-side failures (request rejected before any broadcast) shown in the
46
+ // login area; cleared by the next real state transition.
47
+ let localError = '';
48
+ let signInInFlight = false;
49
+ let cancelInFlight = false;
50
+ let logoutInFlight = false;
51
+ // Bumped by every request that changes the login state on the host. A status
52
+ // read that started before one of them captured the previous login snapshot,
53
+ // and applying it would rewind the panel — the chat "Sign in to Grok" CTA
54
+ // deliberately opens the settings modal (which refreshes) and starts a login in
55
+ // the same tick, so this ordering is routine rather than exotic.
56
+ let loginRequestSeq = 0;
57
+
58
+ function normalizeLoginState(raw) {
59
+ const value = String(raw || '').trim().toLowerCase();
60
+ return LOGIN_STATES.includes(value) ? value : 'idle';
61
+ }
62
+
63
+ function currentLoginState() {
64
+ return normalizeLoginState(authState?.login?.state);
65
+ }
66
+
67
+ function isLoginBusy() {
68
+ return BUSY_LOGIN_STATES.includes(currentLoginState());
69
+ }
70
+
71
+ /**
72
+ * `status.plan` is a billing *product* name ("GrokBuild"), often null, and
73
+ * nothing the relay can read exposes the subscription tier — so the row is
74
+ * composed from whatever of plan/usage/period actually arrived and falls back
75
+ * to a bare "Signed in" rather than inventing a label.
76
+ */
77
+ export function grokAccountLineText(status) {
78
+ if (!status) return 'Grok account status unavailable.';
79
+ // Before the first read the payload is a frozen placeholder with every key
80
+ // present; "never looked" is not the same as "signed out".
81
+ if (!status.checkedAt) return 'Checking the Grok account…';
82
+ if (status.loggedIn !== true) {
83
+ const error = String(status.error || '').trim();
84
+ return error ? `Not signed in — ${error}` : 'Not signed in';
85
+ }
86
+ const parts = [];
87
+ const plan = String(status.plan || '').trim();
88
+ if (plan) parts.push(plan);
89
+ // Number(null) is 0, which would render an absent quota as "0% used"; exactly
90
+ // 0% is real data and must still show.
91
+ const usage = status.usagePercent == null ? NaN : Number(status.usagePercent);
92
+ if (Number.isFinite(usage)) {
93
+ const period = String(status.periodType || '').trim();
94
+ parts.push(`${Math.round(usage)}% of ${period ? `${period} ` : ''}quota used`);
95
+ }
96
+ const line = parts.length ? parts.join(' · ') : 'Signed in';
97
+ // An elapsed expiry is not a logged-out verdict: the CLI refreshes the token
98
+ // in place, so it is a hint that the next turn may ask for a new sign-in.
99
+ return status.expired === true ? `${line} · token expired, may need a new sign-in` : line;
100
+ }
101
+
102
+ export function grokAccountLineState(status) {
103
+ if (!status) return 'error';
104
+ if (!status.checkedAt) return 'pending';
105
+ if (status.loggedIn === true) return 'active';
106
+ return String(status.error || '').trim() ? 'error' : 'unconfigured';
107
+ }
108
+
109
+ export function grokLoginStatusText(login, status) {
110
+ const state = normalizeLoginState(login?.state);
111
+ if (localError && (state === 'idle' || state === 'error')) return localError;
112
+ switch (state) {
113
+ case 'starting':
114
+ return 'Starting the Grok sign-in on the relay host…';
115
+ case 'awaiting_authorization':
116
+ return 'Open the link below (a phone works), confirm the code, and this panel flips by itself.';
117
+ case 'success': {
118
+ const plan = String(status?.plan || '').trim();
119
+ return plan ? `Signed in to Grok (${plan}).` : 'Signed in to Grok.';
120
+ }
121
+ case 'error':
122
+ return String(login?.error || '').trim() || 'Sign-in failed. Press Sign in to try again.';
123
+ default:
124
+ return '';
125
+ }
126
+ }
127
+
128
+ export function grokLoginStatusState(login) {
129
+ const state = normalizeLoginState(login?.state);
130
+ if (state === 'success') return 'active';
131
+ if (state === 'error' || (localError && state === 'idle')) return 'error';
132
+ return 'pending';
133
+ }
134
+
135
+ function setHidden(element, hidden) {
136
+ if (element) element.hidden = !!hidden;
137
+ }
138
+
139
+ function setDisabled(element, disabled) {
140
+ if (element) element.disabled = !!disabled;
141
+ }
142
+
143
+ function clearSuccessCollapseTimer() {
144
+ if (successCollapseTimer === null) return;
145
+ clearTimeout(successCollapseTimer);
146
+ successCollapseTimer = null;
147
+ }
148
+
149
+ export function renderGrokAuthSection() {
150
+ const section = document.getElementById('grok-auth-section');
151
+ if (!section) return;
152
+ const status = authState?.status || null;
153
+ const login = authState?.login || {};
154
+ const state = normalizeLoginState(login.state);
155
+
156
+ const account = document.getElementById('grok-auth-account');
157
+ if (account) {
158
+ account.textContent = grokAccountLineText(status);
159
+ account.dataset.state = grokAccountLineState(status);
160
+ }
161
+
162
+ const busy = BUSY_LOGIN_STATES.includes(state);
163
+ setDisabled(document.getElementById('grok-auth-signin-btn'), busy || signInInFlight);
164
+ setDisabled(
165
+ document.getElementById('grok-auth-logout-btn'),
166
+ busy || logoutInFlight || status?.loggedIn !== true,
167
+ );
168
+
169
+ // The success line lingers for a beat and then the whole area folds away;
170
+ // `successCollapsed` keeps a later re-render (the second success broadcast,
171
+ // or any sticky payload) from popping it back open.
172
+ const areaVisible = (state !== 'idle' && !(state === 'success' && successCollapsed))
173
+ || (!!localError && state === 'idle');
174
+ setHidden(document.getElementById('grok-auth-login-area'), !areaVisible);
175
+
176
+ const loginStatus = document.getElementById('grok-auth-login-status');
177
+ if (loginStatus) {
178
+ loginStatus.textContent = grokLoginStatusText(login, status);
179
+ loginStatus.dataset.state = grokLoginStatusState(login);
180
+ }
181
+
182
+ const authUrl = String(login.authUrl || '').trim();
183
+ const showUrl = state === 'awaiting_authorization' && !!authUrl;
184
+ setHidden(document.getElementById('grok-auth-url-row'), !showUrl);
185
+ const link = document.getElementById('grok-auth-url-link');
186
+ if (link) {
187
+ link.href = showUrl ? authUrl : '#';
188
+ link.textContent = showUrl ? authUrl : 'Open the Grok sign-in page';
189
+ }
190
+
191
+ // Display only: the code rides in the URL already, and is shown so it can be
192
+ // checked against what the browser puts on screen before confirming.
193
+ const userCode = String(login.userCode || '').trim();
194
+ const showCode = state === 'awaiting_authorization' && !!userCode;
195
+ setHidden(document.getElementById('grok-auth-code-row'), !showCode);
196
+ const codeEl = document.getElementById('grok-auth-user-code');
197
+ if (codeEl) codeEl.textContent = showCode ? userCode : '';
198
+
199
+ // Cancel while the CLI holds a session, Dismiss to clear a failed one — the
200
+ // same route serves both, and it is kept out of the code row so a scrape that
201
+ // never found the code still leaves the session cancellable.
202
+ const showCancel = busy || state === 'error';
203
+ setHidden(document.getElementById('grok-auth-actions-row'), !showCancel);
204
+ const cancelButton = document.getElementById('grok-auth-cancel-btn');
205
+ if (cancelButton) cancelButton.textContent = state === 'error' ? 'Dismiss' : 'Cancel';
206
+ setDisabled(cancelButton, cancelInFlight);
207
+
208
+ if (state === 'success' && !successCollapsed && successCollapseTimer === null) {
209
+ successCollapseTimer = setTimeout(() => {
210
+ successCollapseTimer = null;
211
+ successCollapsed = true;
212
+ renderGrokAuthSection();
213
+ }, SUCCESS_COLLAPSE_MS);
214
+ }
215
+ }
216
+
217
+ // Single entry point for every payload (GET response, POST response, socket
218
+ // broadcast). Transitions — not local expectations — reset the transient bits.
219
+ // Within one login session (same startedAt) states only ever move forward.
220
+ // Terminal outcomes share the top rank so neither can clobber the other.
221
+ const LOGIN_STATE_RANK = { starting: 1, awaiting_authorization: 2, success: 3, error: 3 };
222
+
223
+ function isStaleLoginPayload(currentLogin, incomingLogin, incomingState) {
224
+ const currentRank = LOGIN_STATE_RANK[normalizeLoginState(currentLogin?.state)] || 0;
225
+ const incomingRank = LOGIN_STATE_RANK[incomingState] || 0;
226
+ if (!currentRank || !incomingRank) return false;
227
+ if (!currentLogin?.startedAt || currentLogin.startedAt !== incomingLogin?.startedAt) return false;
228
+ return incomingRank < currentRank;
229
+ }
230
+
231
+ export function applyGrokAuthState(payload) {
232
+ if (!payload || typeof payload !== 'object') return authState;
233
+ const login = payload.login && typeof payload.login === 'object' ? payload.login : {};
234
+ const nextState = normalizeLoginState(login.state);
235
+ // A socket broadcast can outrun the HTTP response that triggered it (the
236
+ // awaiting_authorization push with the device URL lands before POST
237
+ // login/start returns its "starting" snapshot). Never let the older snapshot
238
+ // regress the UI.
239
+ if (isStaleLoginPayload(authState?.login, login, nextState)) return authState;
240
+ authState = {
241
+ status: payload.status && typeof payload.status === 'object' ? payload.status : null,
242
+ login: { ...login, state: nextState },
243
+ runningGrokWorkers: Number(payload.runningGrokWorkers || 0) || 0,
244
+ };
245
+ if (nextState !== lastLoginState) {
246
+ lastLoginState = nextState;
247
+ localError = '';
248
+ clearSuccessCollapseTimer();
249
+ successCollapsed = false;
250
+ // The relay re-emits `success` once the confirming status read lands, but a
251
+ // client whose socket is asleep would otherwise sit on the cached (still
252
+ // signed-out) account line.
253
+ if (nextState === 'success') void refreshGrokAuthSection();
254
+ }
255
+ renderGrokAuthSection();
256
+ return authState;
257
+ }
258
+
259
+ export async function refreshGrokAuthSection() {
260
+ const seq = loginRequestSeq;
261
+ const payload = await getGrokAuthStatus();
262
+ if (!payload) {
263
+ const account = document.getElementById('grok-auth-account');
264
+ if (account) {
265
+ account.textContent = 'Unable to load the Grok account status.';
266
+ account.dataset.state = 'error';
267
+ }
268
+ return null;
269
+ }
270
+ // A login or cancel issued while this read was in flight already produced a
271
+ // newer login state: take the account half and leave the login half alone.
272
+ if (seq !== loginRequestSeq) return applyGrokAuthState({ ...payload, login: authState?.login || {} });
273
+ return applyGrokAuthState(payload);
274
+ }
275
+
276
+ export async function startGrokSignIn() {
277
+ if (signInInFlight || isLoginBusy()) return;
278
+ signInInFlight = true;
279
+ loginRequestSeq += 1;
280
+ localError = '';
281
+ renderGrokAuthSection();
282
+ try {
283
+ applyGrokAuthState(await startGrokLogin());
284
+ } catch (error) {
285
+ localError = String(error?.message || 'Failed to start the Grok sign-in.');
286
+ } finally {
287
+ signInInFlight = false;
288
+ renderGrokAuthSection();
289
+ }
290
+ }
291
+
292
+ export async function cancelGrokSignIn() {
293
+ if (cancelInFlight) return;
294
+ cancelInFlight = true;
295
+ loginRequestSeq += 1;
296
+ renderGrokAuthSection();
297
+ try {
298
+ applyGrokAuthState(await cancelGrokLogin());
299
+ } catch (error) {
300
+ localError = String(error?.message || 'Failed to cancel the Grok sign-in.');
301
+ await refreshGrokAuthSection().catch(() => {});
302
+ } finally {
303
+ cancelInFlight = false;
304
+ renderGrokAuthSection();
305
+ }
306
+ }
307
+
308
+ export async function copyGrokLoginUrl() {
309
+ const url = String(authState?.login?.authUrl || '').trim();
310
+ if (!url) return;
311
+ let copied = false;
312
+ try {
313
+ copied = await copyTextToClipboard(url);
314
+ } catch {
315
+ copied = false;
316
+ }
317
+ const button = document.getElementById('grok-auth-copy-btn');
318
+ if (button) {
319
+ button.textContent = copied ? '✓ Copied' : 'Copy failed';
320
+ clearTimeout(button.__grokAuthCopyTimer);
321
+ button.__grokAuthCopyTimer = setTimeout(() => {
322
+ button.textContent = 'Copy link';
323
+ }, COPY_RESET_MS);
324
+ }
325
+ // Without a clipboard (plain http on some browsers) at least surface the URL.
326
+ if (!copied) showTransientRelayNotice(url, 10000);
327
+ }
328
+
329
+ export async function openGrokLogoutConfirmation() {
330
+ if (logoutInFlight) return;
331
+ // Fresh read: the worker count in the warning must not be a stale number.
332
+ let payload = null;
333
+ try {
334
+ payload = await getGrokAuthStatus();
335
+ } catch {
336
+ payload = null;
337
+ }
338
+ if (payload) applyGrokAuthState(payload);
339
+ const status = authState?.status || null;
340
+ if (isLoginBusy()) {
341
+ showTransientRelayNotice('Finish or cancel the Grok sign-in in progress first.');
342
+ return;
343
+ }
344
+ if (status?.loggedIn !== true) {
345
+ showTransientRelayNotice('No Grok account is signed in on the relay host.');
346
+ return;
347
+ }
348
+ const account = String(status.plan || '').trim() || 'this Grok account';
349
+ const workers = Number(authState?.runningGrokWorkers || 0) || 0;
350
+ const workerNote = workers > 0
351
+ ? `${workers} Grok worker${workers === 1 ? '' : 's'} ${workers === 1 ? 'is' : 'are'} currently running; `
352
+ + 'they keep their sessions, but new Grok turns will fail until you sign in again.'
353
+ : 'No Grok workers are running right now; new Grok turns will fail until you sign in again.';
354
+ openSummaryModal({
355
+ title: 'Sign out of Grok',
356
+ subtitle: account,
357
+ kind: 'grok-logout',
358
+ bodyHtml: `
359
+ <p>Sign out of <strong>${escHtml(account)}</strong> on the relay host?</p>
360
+ <p>${escHtml(workerNote)}</p>
361
+ <div class="summary-modal-actions">
362
+ <button class="chat-title-action-btn danger-btn" type="button" onclick="confirmGrokLogout()">Sign out</button>
363
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
364
+ </div>
365
+ `,
366
+ });
367
+ }
368
+
369
+ export async function confirmGrokLogout() {
370
+ if (logoutInFlight) return;
371
+ logoutInFlight = true;
372
+ setSummaryModalLoading(true);
373
+ try {
374
+ const payload = await grokLogout();
375
+ closeSummaryModal();
376
+ applyGrokAuthState(payload);
377
+ showTransientRelayNotice('Signed out of Grok on the relay host.');
378
+ } catch (error) {
379
+ closeSummaryModal();
380
+ alert(error?.message || 'Failed to sign out of Grok.');
381
+ await refreshGrokAuthSection().catch(() => {});
382
+ } finally {
383
+ logoutInFlight = false;
384
+ setSummaryModalLoading(false);
385
+ renderGrokAuthSection();
386
+ }
387
+ }
@@ -0,0 +1,174 @@
1
+ // Client-side image downscaling for oversized pastes/uploads.
2
+ // Every browser API is injected so the decision logic and the re-encode path can
3
+ // both be exercised in Node.
4
+
5
+ export const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
6
+ export const DEFAULT_MAX_DIMENSION = 2560;
7
+ export const DEFAULT_QUALITY = 0.85;
8
+
9
+ // Re-encoding these throws away information a raster round-trip cannot restore:
10
+ // GIF loses animation and SVG stops being a vector.
11
+ const NEVER_DOWNSCALE = new Set(['image/gif', 'image/svg+xml', 'image/apng']);
12
+
13
+ // Formats the model is documented to read. Anything else has to be converted
14
+ // regardless of size, or it arrives as an image the model cannot see.
15
+ export const VISION_SAFE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif']);
16
+
17
+ export function normalizeMimeType(value) {
18
+ return String(value || '').trim().toLowerCase().split(';')[0];
19
+ }
20
+
21
+ export function isDownscalableImage(mimeType) {
22
+ const normalized = normalizeMimeType(mimeType);
23
+ if (!normalized.startsWith('image/')) return false;
24
+ return !NEVER_DOWNSCALE.has(normalized);
25
+ }
26
+
27
+ export function isVisionSafeType(mimeType) {
28
+ return VISION_SAFE_TYPES.has(normalizeMimeType(mimeType));
29
+ }
30
+
31
+ /**
32
+ * Size-based gate applied before an image is ever decoded. Dimension-based
33
+ * downscaling is decided later, once the bitmap is available.
34
+ */
35
+ export function shouldDownscale(file, { maxBytes = DEFAULT_MAX_BYTES } = {}) {
36
+ if (!file) return false;
37
+ if (!isDownscalableImage(file.type)) return false;
38
+ return Number(file.size || 0) > Math.max(0, Number(maxBytes) || 0);
39
+ }
40
+
41
+ /**
42
+ * Why an image needs re-encoding, or null when it can be sent untouched.
43
+ *
44
+ * `'size'` keeps bandwidth down and may be abandoned if it fails to help.
45
+ * `'format'` is about the model being able to see the image at all, so it is
46
+ * worth doing even when the result is no smaller.
47
+ */
48
+ export function reencodeReason(file, { maxBytes = DEFAULT_MAX_BYTES } = {}) {
49
+ if (!file) return null;
50
+ if (!isDownscalableImage(file.type)) return null;
51
+ if (!isVisionSafeType(file.type)) return 'format';
52
+ return shouldDownscale(file, { maxBytes }) ? 'size' : null;
53
+ }
54
+
55
+ export function computeTargetDimensions(width, height, maxDimension = DEFAULT_MAX_DIMENSION) {
56
+ const w = Math.max(0, Math.round(Number(width) || 0));
57
+ const h = Math.max(0, Math.round(Number(height) || 0));
58
+ const limit = Math.max(1, Math.round(Number(maxDimension) || DEFAULT_MAX_DIMENSION));
59
+ if (!w || !h) return { width: w, height: h, scaled: false };
60
+ const longest = Math.max(w, h);
61
+ if (longest <= limit) return { width: w, height: h, scaled: false };
62
+ const ratio = limit / longest;
63
+ return {
64
+ width: Math.max(1, Math.round(w * ratio)),
65
+ height: Math.max(1, Math.round(h * ratio)),
66
+ scaled: true,
67
+ };
68
+ }
69
+
70
+ // Vision input is the whole point of attaching an image, and Copilot's docs are
71
+ // explicit that PNG and JPEG are "the most widely supported formats". WebP
72
+ // compresses better, but a smaller image the model cannot read is worthless, so
73
+ // the re-encode never leaves the PNG/JPEG pair.
74
+ export function pickOutputMimeType(sourceType, { allowLossless = false } = {}) {
75
+ const normalized = normalizeMimeType(sourceType);
76
+ // PNG sources are usually screenshots/line art, where JPEG artefacts destroy
77
+ // exactly the fine text an attached screenshot is meant to convey.
78
+ if (allowLossless && normalized === 'image/png') return 'image/png';
79
+ return 'image/jpeg';
80
+ }
81
+
82
+ export function renameForOutputType(name, outputMimeType) {
83
+ const base = String(name || 'image').replace(/\.[^./\\]+$/, '') || 'image';
84
+ const ext = outputMimeType === 'image/png' ? 'png' : 'jpg';
85
+ return `${base}.${ext}`;
86
+ }
87
+
88
+ /**
89
+ * Re-encodes an image when it is too large, or when its format is one the model
90
+ * cannot read. Returns the ORIGINAL file whenever the re-encode fails or is
91
+ * unsupported: the composer treats optimisation as best-effort, never required,
92
+ * so this function must never reject.
93
+ */
94
+ export async function downscaleImageFile(file, options = {}, deps = {}) {
95
+ const {
96
+ maxBytes = DEFAULT_MAX_BYTES,
97
+ maxDimension = DEFAULT_MAX_DIMENSION,
98
+ quality = DEFAULT_QUALITY,
99
+ } = options;
100
+
101
+ const {
102
+ createImageBitmap: createBitmap,
103
+ createCanvas,
104
+ createFile,
105
+ closeBitmap,
106
+ } = deps;
107
+
108
+ const reason = reencodeReason(file, { maxBytes });
109
+ if (!reason) return file;
110
+ if (typeof createBitmap !== 'function' || typeof createCanvas !== 'function' || typeof createFile !== 'function') {
111
+ return file;
112
+ }
113
+
114
+ let bitmap = null;
115
+ try {
116
+ bitmap = await createBitmap(file, { imageOrientation: 'from-image' });
117
+ // A format conversion keeps the original dimensions; only an oversized
118
+ // image is scaled down.
119
+ const target = reason === 'size'
120
+ ? computeTargetDimensions(bitmap?.width, bitmap?.height, maxDimension)
121
+ : { width: bitmap?.width, height: bitmap?.height, scaled: false };
122
+ if (!target.width || !target.height) return file;
123
+
124
+ const canvas = createCanvas(target.width, target.height);
125
+ const context = canvas?.getContext?.('2d');
126
+ if (!context) return file;
127
+ context.drawImage(bitmap, 0, 0, target.width, target.height);
128
+
129
+ let outputType = pickOutputMimeType(file.type);
130
+ let blob = await canvas.convertToBlob?.({ type: outputType, quality });
131
+ if (!blob) {
132
+ outputType = 'image/png';
133
+ blob = await canvas.convertToBlob?.({ type: outputType, quality });
134
+ }
135
+ if (!blob) return file;
136
+
137
+ // Shrinking that failed to shrink is a pessimisation, so the original wins.
138
+ // A format conversion is kept even when it grows: a bigger image the model
139
+ // can actually read beats a smaller one it cannot.
140
+ if (reason === 'size' && Number(blob.size || 0) >= Number(file.size || 0)) return file;
141
+
142
+ // Some engines silently ignore the requested type. Shipping a format the
143
+ // model cannot read is worse than shipping the untouched original, so an
144
+ // unexpected output type is rejected outright.
145
+ const resultType = normalizeMimeType(blob.type) || outputType;
146
+ if (!isVisionSafeType(resultType)) return file;
147
+
148
+ const next = createFile(blob, renameForOutputType(file.name, resultType), resultType);
149
+ return next || file;
150
+ } catch {
151
+ return file;
152
+ } finally {
153
+ if (bitmap && typeof closeBitmap === 'function') {
154
+ try { closeBitmap(bitmap); } catch {}
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Builds the browser-backed dependency set. Returns an empty object when the
161
+ * required APIs are unavailable, which makes downscaling a silent no-op.
162
+ */
163
+ export function browserDownscaleDeps(scope = globalThis) {
164
+ const hasBitmap = typeof scope?.createImageBitmap === 'function';
165
+ const hasOffscreen = typeof scope?.OffscreenCanvas === 'function';
166
+ const hasFile = typeof scope?.File === 'function';
167
+ if (!hasBitmap || !hasOffscreen || !hasFile) return {};
168
+ return {
169
+ createImageBitmap: (file, opts) => scope.createImageBitmap(file, opts),
170
+ createCanvas: (width, height) => new scope.OffscreenCanvas(width, height),
171
+ createFile: (blob, name, type) => new scope.File([blob], name, { type }),
172
+ closeBitmap: (bitmap) => bitmap?.close?.(),
173
+ };
174
+ }
@@ -0,0 +1,40 @@
1
+ // Stable fingerprint of a conversation's in-flight turn payload so the live
2
+ // poll can skip tearing down and rebuilding the streaming bubble when nothing
3
+ // actually changed since the previous tick.
4
+
5
+ export function buildInFlightSnapshotKey(inFlight) {
6
+ if (!inFlight || typeof inFlight !== 'object') return 'none';
7
+ const thoughts = (Array.isArray(inFlight.thoughts) ? inFlight.thoughts : []).map((entry) => [
8
+ String(entry?.reasoningId || ''),
9
+ String(entry?.text || ''),
10
+ !!entry?.done,
11
+ String(entry?.subagentRunId || ''),
12
+ ]);
13
+ const activities = (Array.isArray(inFlight.activities) ? inFlight.activities : []).map((entry) => (
14
+ typeof entry === 'string'
15
+ ? entry
16
+ : [String(entry?.text || ''), String(entry?.subagentRunId || '')]
17
+ ));
18
+ const subagentRuns = (Array.isArray(inFlight.subagentRuns) ? inFlight.subagentRuns : []).map((entry) => [
19
+ String(entry?.subagentRunId || ''),
20
+ String(entry?.status || ''),
21
+ String(entry?.parentSubagentId || ''),
22
+ String(entry?.displayName || ''),
23
+ ]);
24
+ const streamEvents = (Array.isArray(inFlight.streamEvents) ? inFlight.streamEvents : []).map((entry) => [
25
+ Number(entry?.seq) || 0,
26
+ String(entry?.text || ''),
27
+ !!entry?.done,
28
+ String(entry?.subagentRunId || ''),
29
+ ]);
30
+ return JSON.stringify({
31
+ messageId: String(inFlight.messageId || ''),
32
+ status: String(inFlight.status || ''),
33
+ lastStreamSeq: Number(inFlight.lastStreamSeq) || 0,
34
+ streamDone: !!inFlight.streamDone,
35
+ thoughts,
36
+ activities,
37
+ subagentRuns,
38
+ streamEvents,
39
+ });
40
+ }