@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,455 @@
1
+ // Provider CLI install / update rows inside Settings → Providers.
2
+ //
3
+ // The relay runs the vendors' own install one-liners on the host, so a missing
4
+ // `grok` (the relay.grok-cli-missing turn failure) is fixable from a phone
5
+ // instead of from a shell on the host — the exact thing the relay exists to
6
+ // avoid. One renderer serves every provider sub-tab; which buttons a row offers
7
+ // is read straight off the payload's `commands` keys and never re-derived here,
8
+ // so the frozen server-side descriptor table stays the single source of truth
9
+ // for what can be run.
10
+ //
11
+ // Every visible state comes from the `cli_install_state` socket broadcast (same
12
+ // payload as GET /api/cli/status), so an install survives closing the modal,
13
+ // switching tabs, or being started from another device. Rendering is therefore
14
+ // idempotent: it reads the latest payload and never assumes a local step
15
+ // sequence — the log is redrawn from the whole retained buffer whenever the
16
+ // monotonic `logSeq` moves, rather than appended chunk by chunk.
17
+ //
18
+ // Output hygiene: the log is host process output. The service escape-strips it
19
+ // before broadcasting; this module writes it with textContent (never innerHTML)
20
+ // and escapes everything it does put through innerHTML with escHtml.
21
+
22
+ import {
23
+ escHtml,
24
+ openSummaryModal,
25
+ closeSummaryModal,
26
+ setSummaryModalLoading,
27
+ showTransientRelayNotice,
28
+ } from './store.js';
29
+ import { getCliStatus, startCliInstall, cancelCliInstall } from './api-client.js';
30
+
31
+ // The panels that carry a row. Copilot is detect-only (npm-global on this host),
32
+ // so its payload arrives with an empty `commands` and the buttons stay hidden.
33
+ export const CLI_PROVIDER_IDS = ['grok', 'claude', 'copilot'];
34
+ const INSTALL_STATES = ['idle', 'running', 'success', 'error', 'cancelled'];
35
+ const INSTALL_ACTIONS = ['install', 'update', 'migrate'];
36
+ const ACTION_BUTTON_LABELS = {
37
+ install: 'Install',
38
+ update: 'Update',
39
+ migrate: 'Switch to native installer',
40
+ };
41
+ const ACTION_TITLES = {
42
+ install: 'Install',
43
+ update: 'Update',
44
+ migrate: 'Switch to the native installer',
45
+ };
46
+ const STATE_WORDS = {
47
+ running: 'Running',
48
+ success: 'Finished',
49
+ error: 'Failed',
50
+ cancelled: 'Cancelled',
51
+ };
52
+ // Anthropic's own prescribed fix for an unwritable npm global folder, quoted
53
+ // with its consequence rather than paraphrased.
54
+ const MIGRATE_NOTE = 'The npm copy stays where it is; the native build takes PATH precedence.';
55
+
56
+ const EMPTY_INSTALL = Object.freeze({
57
+ state: 'idle',
58
+ providerId: null,
59
+ action: null,
60
+ command: null,
61
+ log: '',
62
+ logSeq: 0,
63
+ error: null,
64
+ startedAt: null,
65
+ finishedAt: null,
66
+ active: false,
67
+ });
68
+
69
+ let cliState = { providers: {}, install: { ...EMPTY_INSTALL } };
70
+ // Redraw key for the log body: `logSeq` alone is not enough, because it is
71
+ // monotonic across the whole relay lifetime and a fresh install starts with an
72
+ // empty buffer at the sequence the previous one ended on.
73
+ let renderedLogKey = '';
74
+ // One-shot: a failed install expands its own log, but a later re-render of the
75
+ // same sticky payload must not fight the user closing it again.
76
+ let autoExpandProviderId = '';
77
+ let lastInstallSignature = '';
78
+ // Client-side failures (request rejected before any broadcast), shown on the
79
+ // row that asked for them and cleared by the next real state transition.
80
+ let localErrors = {};
81
+ let installInFlight = false;
82
+ let cancelInFlight = false;
83
+ // The status read currently in flight, as `{ promise, force }`, so a second
84
+ // caller can *join* it instead of being dropped — see refreshCliInstallSections.
85
+ let statusRefresh = null;
86
+
87
+ function normalizeInstallState(raw) {
88
+ const value = String(raw || '').trim().toLowerCase();
89
+ return INSTALL_STATES.includes(value) ? value : 'idle';
90
+ }
91
+
92
+ function normalizeProviderId(raw) {
93
+ const value = String(raw || '').trim().toLowerCase();
94
+ return CLI_PROVIDER_IDS.includes(value) ? value : '';
95
+ }
96
+
97
+ function normalizeAction(raw) {
98
+ const value = String(raw || '').trim().toLowerCase();
99
+ return INSTALL_ACTIONS.includes(value) ? value : '';
100
+ }
101
+
102
+ /**
103
+ * The row's status line (§3): `not installed` / `1.0.13 · ~/.grok/bin/grok ·
104
+ * native · update available`. Every segment degrades on its own — an
105
+ * unparseable version reads as "version unknown" rather than hiding the
106
+ * install, and `updateAvailable: null` (unknown) simply drops that segment.
107
+ */
108
+ export function cliRowText(row) {
109
+ if (!row) return 'CLI status unavailable.';
110
+ // `checkedAt: null` is "never probed", which is not the same as "not there".
111
+ if (!row.checkedAt) return 'Checking…';
112
+ if (row.installed !== true) {
113
+ const error = String(row.error || '').trim();
114
+ return error ? `Not installed — ${error}` : 'Not installed';
115
+ }
116
+ const parts = [String(row.version || '').trim() || 'installed (version unknown)'];
117
+ const path = String(row.path || '').trim();
118
+ if (path) parts.push(path);
119
+ const method = String(row.installMethod || '').trim();
120
+ if (method) parts.push(method);
121
+ if (row.updateAvailable === true) {
122
+ const latest = String(row.latestVersion || '').trim();
123
+ parts.push(latest ? `update available (${latest})` : 'update available');
124
+ } else if (row.updateAvailable === false) {
125
+ parts.push('up to date');
126
+ }
127
+ return parts.join(' · ');
128
+ }
129
+
130
+ export function cliRowState(row) {
131
+ if (!row) return 'error';
132
+ if (!row.checkedAt) return 'pending';
133
+ if (row.installed === true) return 'active';
134
+ return String(row.error || '').trim() ? 'error' : 'unconfigured';
135
+ }
136
+
137
+ /**
138
+ * The line under the row: why a provider cannot be installed from here, and the
139
+ * doctor warnings verbatim (they are the CLI's own words, not a paraphrase).
140
+ */
141
+ export function cliRowNote(row) {
142
+ if (!row) return '';
143
+ const parts = [];
144
+ const blocked = String(row.blockedReason || '').trim();
145
+ if (blocked) parts.push(blocked);
146
+ const warnings = Array.isArray(row.doctor?.warnings) ? row.doctor.warnings : [];
147
+ for (const warning of warnings) {
148
+ const text = String(warning || '').trim();
149
+ if (text) parts.push(text);
150
+ }
151
+ if (row.doctor?.npmGlobalNotWritable === true && row.commands?.migrate) parts.push(MIGRATE_NOTE);
152
+ return parts.join(' ');
153
+ }
154
+
155
+ /** Which action buttons this row offers, in the order they are rendered. */
156
+ export function cliRowActions(row) {
157
+ const commands = row?.commands && typeof row.commands === 'object' ? row.commands : {};
158
+ return INSTALL_ACTIONS.filter((action) => {
159
+ if (!commands[action]) return false;
160
+ // The migration is offered only in the case it fixes: an npm-global install
161
+ // that can no longer update itself. Otherwise it would install a second
162
+ // Claude that silently shadows the first.
163
+ if (action === 'migrate') return row?.doctor?.npmGlobalNotWritable === true;
164
+ return true;
165
+ });
166
+ }
167
+
168
+ export function installLogSummary(install) {
169
+ const state = normalizeInstallState(install?.state);
170
+ const word = STATE_WORDS[state] || 'Install';
171
+ const command = String(install?.command || '').trim();
172
+ return command ? `${word} · ${command}` : `${word} · install log`;
173
+ }
174
+
175
+ function setHidden(element, hidden) {
176
+ if (element) element.hidden = !!hidden;
177
+ }
178
+
179
+ function setDisabled(element, disabled) {
180
+ if (element) element.disabled = !!disabled;
181
+ }
182
+
183
+ function el(providerId, suffix) {
184
+ return document.getElementById(`cli-${providerId}-${suffix}`);
185
+ }
186
+
187
+ function currentInstall() {
188
+ return cliState.install || EMPTY_INSTALL;
189
+ }
190
+
191
+ /** True while any install owns the host: the single flight is relay-wide. */
192
+ export function isCliInstallBusy() {
193
+ return normalizeInstallState(currentInstall().state) === 'running';
194
+ }
195
+
196
+ function renderCliInstallRow(providerId) {
197
+ const section = document.getElementById(`cli-${providerId}-section`);
198
+ if (!section) return;
199
+ const row = cliState.providers?.[providerId] || null;
200
+ const install = currentInstall();
201
+ const state = normalizeInstallState(install.state);
202
+ const mine = normalizeProviderId(install.providerId) === providerId;
203
+ const busy = state === 'running';
204
+
205
+ const label = el(providerId, 'label');
206
+ if (label && String(row?.label || '').trim()) label.textContent = String(row.label).trim();
207
+
208
+ const status = el(providerId, 'status');
209
+ if (status) {
210
+ status.textContent = localErrors[providerId] || cliRowText(row);
211
+ status.dataset.state = localErrors[providerId] ? 'error' : cliRowState(row);
212
+ }
213
+
214
+ const offered = cliRowActions(row);
215
+ for (const action of INSTALL_ACTIONS) {
216
+ const button = el(providerId, `${action}-btn`);
217
+ if (!button) continue;
218
+ setHidden(button, !offered.includes(action));
219
+ button.textContent = ACTION_BUTTON_LABELS[action];
220
+ setDisabled(button, busy || installInFlight || Boolean(statusRefresh));
221
+ }
222
+
223
+ // Cancel while this row's install runs; Dismiss once it has settled — the
224
+ // same route, which is also how the terminal state is cleared.
225
+ setHidden(el(providerId, 'cancel-btn'), !(mine && busy));
226
+ setDisabled(el(providerId, 'cancel-btn'), cancelInFlight);
227
+ const settled = mine && (state === 'success' || state === 'error' || state === 'cancelled');
228
+ setHidden(el(providerId, 'dismiss-btn'), !settled);
229
+ setDisabled(el(providerId, 'dismiss-btn'), cancelInFlight);
230
+
231
+ const note = el(providerId, 'note');
232
+ const noteText = mine && install.error && state === 'error'
233
+ ? String(install.error).trim()
234
+ : cliRowNote(row);
235
+ if (note) {
236
+ note.textContent = noteText;
237
+ note.dataset.state = (mine && state === 'error') ? 'error' : '';
238
+ }
239
+ setHidden(note, !noteText);
240
+
241
+ const details = el(providerId, 'log-details');
242
+ const showLog = mine && state !== 'idle';
243
+ setHidden(details, !showLog);
244
+ const summary = el(providerId, 'log-summary');
245
+ if (summary) summary.textContent = installLogSummary(install);
246
+ const logBox = el(providerId, 'log');
247
+ // Keyed on the session, not on logSeq alone: logSeq never resets, so a new
248
+ // install would otherwise redraw nothing and leave the previous log on screen.
249
+ const logKey = `${providerId}|${install.startedAt || ''}|${install.logSeq || 0}`;
250
+ if (logBox && showLog && logKey !== renderedLogKey) {
251
+ // textContent, never innerHTML: this is raw host process output.
252
+ logBox.textContent = String(install.log || '');
253
+ logBox.scrollTop = logBox.scrollHeight;
254
+ renderedLogKey = logKey;
255
+ }
256
+ if (details && showLog && autoExpandProviderId === providerId) {
257
+ details.open = true;
258
+ autoExpandProviderId = '';
259
+ }
260
+ }
261
+
262
+ export function renderCliInstallSections() {
263
+ for (const providerId of CLI_PROVIDER_IDS) renderCliInstallRow(providerId);
264
+ }
265
+
266
+ // A broadcast can outrun the HTTP response that triggered it (the first log
267
+ // chunk lands before POST /api/cli/install returns its "running" snapshot).
268
+ // Within one install session (same startedAt) the sequence only moves forward,
269
+ // so an older snapshot must never regress the log or the state.
270
+ function isStaleInstallPayload(current, incoming) {
271
+ if (!current || !incoming) return false;
272
+ // The service never leaves `running` for `idle` — a cancel lands on
273
+ // `cancelled` — so an idle snapshot arriving against a running one is a
274
+ // status read that started before this install did.
275
+ if (normalizeInstallState(current.state) === 'running'
276
+ && normalizeInstallState(incoming.state) === 'idle') return true;
277
+ // Different sessions: ISO stamps compare lexicographically, and an older one
278
+ // can only have snapshotted a previous install.
279
+ if (current.startedAt && incoming.startedAt && incoming.startedAt < current.startedAt) return true;
280
+ if (!current.startedAt || current.startedAt !== incoming.startedAt) return false;
281
+ const currentSeq = Number(current.logSeq || 0);
282
+ const incomingSeq = Number(incoming.logSeq || 0);
283
+ if (incomingSeq < currentSeq) return true;
284
+ // Equal sequences: a settled install must not be reopened by the slower
285
+ // response that snapshotted its start.
286
+ return incomingSeq === currentSeq
287
+ && normalizeInstallState(current.state) !== 'running'
288
+ && normalizeInstallState(incoming.state) === 'running';
289
+ }
290
+
291
+ /**
292
+ * Single entry point for every payload (GET response, POST response, socket
293
+ * broadcast). Both keys are optional so an error body that only carries one of
294
+ * them cannot blank the other half of the panel.
295
+ */
296
+ export function applyCliInstallState(payload) {
297
+ if (!payload || typeof payload !== 'object') return cliState;
298
+ const providers = (payload.providers && typeof payload.providers === 'object')
299
+ ? payload.providers
300
+ : cliState.providers;
301
+ const incoming = (payload.install && typeof payload.install === 'object') ? payload.install : null;
302
+ if (incoming && isStaleInstallPayload(cliState.install, incoming)) {
303
+ cliState = { ...cliState, providers };
304
+ renderCliInstallSections();
305
+ return cliState;
306
+ }
307
+ const install = incoming
308
+ ? { ...EMPTY_INSTALL, ...incoming, state: normalizeInstallState(incoming.state) }
309
+ : cliState.install;
310
+ cliState = { providers, install };
311
+
312
+ const signature = `${install.providerId || ''}|${install.startedAt || ''}|${install.state}`;
313
+ if (signature !== lastInstallSignature) {
314
+ lastInstallSignature = signature;
315
+ localErrors = {};
316
+ // Only a failure opens the log on its own; a running install stays folded
317
+ // until the user asks for it.
318
+ if (install.state === 'error') autoExpandProviderId = normalizeProviderId(install.providerId);
319
+ }
320
+ renderCliInstallSections();
321
+ return cliState;
322
+ }
323
+
324
+ /**
325
+ * Reads the provider rows, joining a read already in flight rather than
326
+ * dropping the second caller.
327
+ *
328
+ * The joining matters because callers arrive in pairs: the chat "Install Grok
329
+ * CLI" CTA opens the settings modal — which starts a forced refresh — and then
330
+ * opens the confirm sheet in the same tick, and that sheet needs the very rows
331
+ * the refresh is fetching. Returning null to the second caller left it looking
332
+ * at an empty `cliState`, so the first click after the failure that prompted it
333
+ * showed "that CLI has no install command on this host" instead of the sheet.
334
+ *
335
+ * A forced caller only joins a *forced* read: joining a cached one could hand it
336
+ * rows up to the service's 30s TTL out of date, which is exactly what the force
337
+ * exists to avoid.
338
+ */
339
+ export function refreshCliInstallSections({ force = false } = {}) {
340
+ if (statusRefresh && (statusRefresh.force || !force)) return statusRefresh.promise;
341
+ const previous = statusRefresh?.promise || null;
342
+ // Published before the read starts, so the `finally` below always sees the
343
+ // entry it is meant to clear however early it runs.
344
+ const entry = { force, promise: null };
345
+ statusRefresh = entry;
346
+ entry.promise = (async () => {
347
+ // Never two GETs at once: a forced read that could not join queues behind
348
+ // the one in flight instead of racing it.
349
+ if (previous) await previous.catch(() => {});
350
+ try {
351
+ const payload = await getCliStatus({ force });
352
+ if (!payload) {
353
+ for (const providerId of CLI_PROVIDER_IDS) {
354
+ const status = el(providerId, 'status');
355
+ if (!status) continue;
356
+ status.textContent = 'Unable to load the CLI status.';
357
+ status.dataset.state = 'error';
358
+ }
359
+ return null;
360
+ }
361
+ return applyCliInstallState(payload);
362
+ } finally {
363
+ if (statusRefresh === entry) statusRefresh = null;
364
+ renderCliInstallSections();
365
+ }
366
+ })();
367
+ renderCliInstallSections();
368
+ return entry.promise;
369
+ }
370
+
371
+ /**
372
+ * The confirm sheet. It names the literal command and the directory it writes
373
+ * into, because `curl … | bash` is a trust decision and the user is entitled to
374
+ * read the thing before it runs. The command shown is the server's own
375
+ * `commands[action].display` — this module never composes one.
376
+ */
377
+ export async function confirmCliInstall(providerId, action) {
378
+ const id = normalizeProviderId(providerId);
379
+ const wanted = normalizeAction(action);
380
+ if (!id || !wanted) return;
381
+ if (isCliInstallBusy()) {
382
+ showTransientRelayNotice('Another CLI install is already running on the relay host.');
383
+ return;
384
+ }
385
+ // The chat-error CTA can fire before the settings panel has ever loaded, so
386
+ // the sheet reads a fresh status rather than an empty one.
387
+ if (!cliState.providers?.[id]?.checkedAt) await refreshCliInstallSections({ force: true });
388
+ const row = cliState.providers?.[id] || null;
389
+ const command = row?.commands?.[wanted] || null;
390
+ if (!command) {
391
+ const blocked = String(row?.blockedReason || '').trim();
392
+ showTransientRelayNotice(blocked || `${row?.label || 'That CLI'} has no ${wanted} command on this host.`);
393
+ return;
394
+ }
395
+ const label = String(row?.label || 'the CLI').trim();
396
+ const display = String(command.display || '').trim();
397
+ const targetDir = String(command.targetDir || '').trim();
398
+ const migrateNote = wanted === 'migrate' ? `<p>${escHtml(MIGRATE_NOTE)}</p>` : '';
399
+ openSummaryModal({
400
+ title: `${ACTION_TITLES[wanted]} ${label}`,
401
+ subtitle: label,
402
+ kind: 'cli-install',
403
+ bodyHtml: `
404
+ <p>Run this on the relay host, as the relay user and without sudo?</p>
405
+ <pre class="cli-install-command">${escHtml(display)}</pre>
406
+ ${targetDir ? `<p>Installs into <code>${escHtml(targetDir)}</code>.</p>` : ''}
407
+ ${migrateNote}
408
+ <p>Sessions already running keep the binary they launched with.</p>
409
+ <div class="summary-modal-actions">
410
+ <button class="chat-title-action-btn" type="button" onclick="runCliInstall('${escHtml(id)}', '${escHtml(wanted)}')">${escHtml(ACTION_TITLES[wanted])}</button>
411
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
412
+ </div>
413
+ `,
414
+ });
415
+ }
416
+
417
+ export async function runCliInstall(providerId, action) {
418
+ const id = normalizeProviderId(providerId);
419
+ const wanted = normalizeAction(action);
420
+ if (!id || !wanted || installInFlight) return;
421
+ installInFlight = true;
422
+ setSummaryModalLoading(true);
423
+ renderCliInstallSections();
424
+ try {
425
+ const payload = await startCliInstall(id, wanted);
426
+ closeSummaryModal();
427
+ applyCliInstallState(payload);
428
+ } catch (error) {
429
+ closeSummaryModal();
430
+ localErrors = { [id]: String(error?.message || 'Failed to start the CLI install.') };
431
+ // The refusal body carries the current rows too, but it arrived as a
432
+ // throw; re-read so the row is not left describing a state that changed.
433
+ await refreshCliInstallSections().catch(() => {});
434
+ } finally {
435
+ installInFlight = false;
436
+ setSummaryModalLoading(false);
437
+ renderCliInstallSections();
438
+ }
439
+ }
440
+
441
+ /** Cancel a running install, and the dismiss path for a settled one. */
442
+ export async function cancelCliInstallRun() {
443
+ if (cancelInFlight) return;
444
+ cancelInFlight = true;
445
+ renderCliInstallSections();
446
+ try {
447
+ applyCliInstallState(await cancelCliInstall());
448
+ } catch (error) {
449
+ showTransientRelayNotice(String(error?.message || 'Failed to cancel the CLI install.'));
450
+ await refreshCliInstallSections().catch(() => {});
451
+ } finally {
452
+ cancelInFlight = false;
453
+ renderCliInstallSections();
454
+ }
455
+ }
@@ -0,0 +1,85 @@
1
+ // Copy buttons for rendered code blocks. Selecting a long <pre> by hand on a
2
+ // phone is miserable, so every fenced block gets a small overlay button that
3
+ // copies the block's text in one tap.
4
+ //
5
+ // Buttons are injected by the same enhancement passes that run hljs (the
6
+ // rendered nodes are already being touched there), while the click handling
7
+ // is one document-level delegated listener — transcript bubbles are re-rendered
8
+ // and streamed-patched constantly, so per-button listeners would die with the
9
+ // first re-render.
10
+
11
+ const BUTTON_CLASS = 'code-copy-btn';
12
+ const RESET_MS = 1500;
13
+
14
+ async function copyText(text) {
15
+ // Clipboard API first (requires a secure context — localhost and the
16
+ // cloudflared tunnel both qualify), execCommand as the plain-http fallback.
17
+ try {
18
+ if (navigator.clipboard?.writeText) {
19
+ await navigator.clipboard.writeText(text);
20
+ return true;
21
+ }
22
+ } catch {}
23
+ try {
24
+ const holder = document.createElement('textarea');
25
+ holder.value = text;
26
+ holder.setAttribute('readonly', '');
27
+ holder.style.position = 'fixed';
28
+ holder.style.opacity = '0';
29
+ document.body.appendChild(holder);
30
+ holder.select();
31
+ const copied = document.execCommand('copy');
32
+ holder.remove();
33
+ return copied;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ let delegatedBound = false;
40
+
41
+ function bindDelegatedClick() {
42
+ if (delegatedBound || typeof document === 'undefined') return;
43
+ delegatedBound = true;
44
+ document.addEventListener('click', async (event) => {
45
+ const btn = event.target?.closest?.(`.${BUTTON_CLASS}`);
46
+ if (!btn) return;
47
+ event.preventDefault();
48
+ event.stopPropagation();
49
+ // The button lives inside the <pre> but the copied text comes from the
50
+ // <code> element, so the button's own label never rides along.
51
+ const code = btn.parentElement?.querySelector('code');
52
+ const copied = await copyText(code ? code.textContent : '');
53
+ btn.textContent = copied ? '✓ Copied' : 'Copy failed';
54
+ btn.classList.toggle('is-copied', copied);
55
+ clearTimeout(btn.__copyResetTimer);
56
+ btn.__copyResetTimer = setTimeout(() => {
57
+ btn.textContent = 'Copy';
58
+ btn.classList.remove('is-copied');
59
+ }, RESET_MS);
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Add a copy button to every `<pre><code>` block under `root` that doesn't
65
+ * have one yet. Idempotent, so the streaming re-enhancement passes can call
66
+ * it repeatedly on the same nodes.
67
+ */
68
+ export function attachCodeCopyButtons(root) {
69
+ if (!(root instanceof Element)) return;
70
+ bindDelegatedClick();
71
+ const scopes = root.matches?.('pre') ? [root] : root.querySelectorAll('pre');
72
+ for (const pre of scopes) {
73
+ if (!pre.querySelector('code')) continue;
74
+ if (pre.querySelector(`:scope > .${BUTTON_CLASS}`)) continue;
75
+ const btn = document.createElement('button');
76
+ btn.type = 'button';
77
+ btn.className = BUTTON_CLASS;
78
+ btn.textContent = 'Copy';
79
+ btn.setAttribute('aria-label', 'Copy code block');
80
+ // The class (not :has()) anchors the absolutely-positioned button, so the
81
+ // rule works on every browser that renders the transcript.
82
+ pre.classList.add('has-code-copy');
83
+ pre.appendChild(btn);
84
+ }
85
+ }
@@ -0,0 +1,151 @@
1
+ // Serialization rules for the per-conversation composer attachment cache.
2
+ // Only already-uploaded attachments are persisted: the draft row stores content
3
+ // hashes, never file bytes, so switching conversations is cheap and reload-safe.
4
+
5
+ import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
6
+
7
+ // Attachments added before a conversation exists live under this key and are
8
+ // migrated onto the real conversation id the first time a message is sent.
9
+ export const PENDING_CONVERSATION_KEY = '__new__';
10
+
11
+ export const DRAFT_UPLOAD_MESSAGE_ID = '__draft__';
12
+
13
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/;
14
+
15
+ export function isSha256(value) {
16
+ return SHA256_PATTERN.test(String(value || '').trim().toLowerCase());
17
+ }
18
+
19
+ export function isUploadedAttachment(attachment) {
20
+ if (!attachment) return false;
21
+ if (attachment.uploadState && attachment.uploadState !== 'uploaded') return false;
22
+ return isSha256(attachment.sha256 || attachment.uploaded?.sha256);
23
+ }
24
+
25
+ function attachmentSha(attachment) {
26
+ return String(attachment?.sha256 || attachment?.uploaded?.sha256 || '').trim().toLowerCase();
27
+ }
28
+
29
+ /**
30
+ * Reduces composer attachments to the minimal persistable descriptor. Anything
31
+ * still uploading or failed is skipped, because a draft must only ever reference
32
+ * blobs that actually exist on the server.
33
+ */
34
+ export function serializeDraftAttachments(attachments = [], { max = 6 } = {}) {
35
+ const list = Array.isArray(attachments) ? attachments : [];
36
+ const limit = Math.max(0, Number(max) || 0);
37
+ const seen = new Set();
38
+ const rows = [];
39
+
40
+ for (const attachment of list) {
41
+ if (rows.length >= limit) break;
42
+ if (!isUploadedAttachment(attachment)) continue;
43
+ const sha256 = attachmentSha(attachment);
44
+ if (seen.has(sha256)) continue;
45
+ seen.add(sha256);
46
+ const source = attachment.uploaded && typeof attachment.uploaded === 'object' ? attachment.uploaded : attachment;
47
+ rows.push({
48
+ sha256,
49
+ name: String(source.name || attachment.name || '').trim().slice(0, 255) || `upload-${sha256.slice(0, 12)}`,
50
+ type: String(source.type || attachment.type || 'application/octet-stream').trim().toLowerCase().slice(0, 127),
51
+ size: Math.max(0, Number(source.size || attachment.size || 0) || 0),
52
+ });
53
+ }
54
+
55
+ return rows;
56
+ }
57
+
58
+ /**
59
+ * Rebuilds composer records from persisted rows. The server already serves the
60
+ * bytes, so previews point at the content URL instead of an object URL that
61
+ * would have to be revoked.
62
+ */
63
+ export function hydrateDraftAttachments(rows = [], { contentUrlFor } = {}) {
64
+ const list = Array.isArray(rows) ? rows : [];
65
+ const resolveUrl = typeof contentUrlFor === 'function'
66
+ ? contentUrlFor
67
+ : (sha256) => `/api/upload/${sha256}/content`;
68
+
69
+ const seen = new Set();
70
+ const hydrated = [];
71
+
72
+ for (const row of list) {
73
+ const sha256 = String(row?.sha256 || '').trim().toLowerCase();
74
+ if (!isSha256(sha256) || seen.has(sha256)) continue;
75
+ seen.add(sha256);
76
+
77
+ const type = String(row?.type || 'application/octet-stream').trim().toLowerCase();
78
+ const name = String(row?.name || '').trim() || `upload-${sha256.slice(0, 12)}`;
79
+ const size = Math.max(0, Number(row?.size || 0) || 0);
80
+ const contentUrl = resolveUrl(sha256);
81
+ const isImage = type.startsWith('image/');
82
+
83
+ hydrated.push({
84
+ id: `draft-${sha256}`,
85
+ name,
86
+ type,
87
+ size,
88
+ sha256,
89
+ file: null,
90
+ isImage,
91
+ // Server-hosted, so there is no object URL to revoke on removal.
92
+ previewUrl: isImage ? contentUrl : '',
93
+ previewUrlIsObjectUrl: false,
94
+ uploadState: 'uploaded',
95
+ uploaded: { sha256, name, type, size, contentUrl },
96
+ error: '',
97
+ });
98
+ }
99
+
100
+ return hydrated;
101
+ }
102
+
103
+ export function draftAttachmentsEqual(a = [], b = []) {
104
+ const left = serializeDraftAttachments(a, { max: Number.MAX_SAFE_INTEGER });
105
+ const right = serializeDraftAttachments(b, { max: Number.MAX_SAFE_INTEGER });
106
+ if (left.length !== right.length) return false;
107
+ return left.every((row, index) => row.sha256 === right[index].sha256);
108
+ }
109
+
110
+ /**
111
+ * Decides whether a pushed draft update should replace local composer state.
112
+ * Local echoes and out-of-order updates are ignored so a slow broadcast cannot
113
+ * resurrect an attachment the user just removed.
114
+ */
115
+ export function mergeDraftAttachmentUpdate({
116
+ existing = [],
117
+ incoming = [],
118
+ existingUpdatedAt = null,
119
+ incomingUpdatedAt = null,
120
+ isLocalEcho = false,
121
+ } = {}) {
122
+ if (isLocalEcho) {
123
+ return { attachments: existing, changed: false, reason: 'local-echo' };
124
+ }
125
+
126
+ const stale = isIncomingDraftTimestampStale({
127
+ existingMs: normalizeDraftTimestampMs(existingUpdatedAt),
128
+ incomingMs: normalizeDraftTimestampMs(incomingUpdatedAt),
129
+ });
130
+ if (stale) {
131
+ return { attachments: existing, changed: false, reason: 'stale' };
132
+ }
133
+
134
+ if (draftAttachmentsEqual(existing, incoming)) {
135
+ return { attachments: existing, changed: false, reason: 'unchanged' };
136
+ }
137
+
138
+ return { attachments: incoming, changed: true, reason: 'applied' };
139
+ }
140
+
141
+ export function parseDraftAttachmentsColumn(value) {
142
+ if (Array.isArray(value)) return value;
143
+ const raw = String(value || '').trim();
144
+ if (!raw) return [];
145
+ try {
146
+ const parsed = JSON.parse(raw);
147
+ return Array.isArray(parsed) ? parsed : [];
148
+ } catch {
149
+ return [];
150
+ }
151
+ }