@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,703 @@
1
+ import {
2
+ BASE,
3
+ TOKEN,
4
+ CLIENT_ID,
5
+ DEVICE_ID,
6
+ currentConvId,
7
+ conversations,
8
+ seenMessageIds,
9
+ pendingUserMessageIds,
10
+ relayActivities,
11
+ relayThoughts,
12
+ relayQuestions,
13
+ relayBoards,
14
+ relayQuestionDrafts,
15
+ repoBrowserState,
16
+ setRelayOnline,
17
+ setCliOnline,
18
+ setCloudflaredTunnelState,
19
+ setCurrentConv,
20
+ updateWorkspaceRootHints,
21
+ updateCompactButton,
22
+ updateSessionPill,
23
+ clearPendingUserMessage,
24
+ hasPendingUserMessageDuplicate,
25
+ isMessagesAtBottom,
26
+ upsertSubagentRun,
27
+ addSubagentActivity,
28
+ addSubagentThought,
29
+ clearSubagentCancelInFlight,
30
+ setConversationWatcherCount,
31
+ } from './store.js';
32
+ import { scheduleContextUsageRefresh } from './api-client.js';
33
+ import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
34
+ import { renderConvList, refreshConversations, openConversation } from './journal-view.js';
35
+ import {
36
+ upsertRelayQuestion,
37
+ loadRelayQuestions,
38
+ updatePendingQuestionBanner,
39
+ } from './ask-user-view.js';
40
+ import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
41
+ import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
42
+ import { setPreviews } from './preview-cards.mjs';
43
+ import { applyClaudeAuthState } from './claude-auth-ui.js';
44
+ import { applyGrokAuthState } from './grok-auth-ui.js';
45
+ import { applyCliInstallState } from './cli-install-ui.js';
46
+ import {
47
+ showThinking,
48
+ removeThinking,
49
+ collapseThinkingThoughts,
50
+ renderThinkingActivities,
51
+ renderThinkingThoughts,
52
+ renderRestoredSubagentBubbles,
53
+ appendThinkingActivity,
54
+ appendThinkingThought,
55
+ applyRelayStreamEvent,
56
+ clearRelayStreamStateForMessage,
57
+ applyConversationTurnStatus,
58
+ renderMessages,
59
+ appendMessage,
60
+ applyIncomingConversationDraftUpdate,
61
+ getRenderedConversationMessageFingerprints,
62
+ clearBubbleCancelState,
63
+ removeUserBubbleCancelButton,
64
+ updateSubagentBubbleFromStatus,
65
+ markSubagentStopUnsupported,
66
+ } from './conversation-view.js';
67
+ import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
68
+ import { clearMessageSearchRuntimeState } from './message-search-view.js';
69
+ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
70
+ import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
71
+ import { mergeRelayThoughts } from './relay-thoughts.mjs';
72
+
73
+ const FALLBACK_MODE = 'agent';
74
+
75
+ // How long a liveness probe waits for its ack before declaring the transport a
76
+ // zombie. Comfortably above one mobile round-trip, far below the ~45s the
77
+ // engine's own ping timeout would need — which is the timer an Android freeze
78
+ // may have discarded, making the probe necessary in the first place.
79
+ const SOCKET_LIVENESS_TIMEOUT_MS = 5000;
80
+
81
+ /** @type {import('socket.io-client').Socket | null} */
82
+ let socket = null;
83
+ let socketActivityEnabled = true;
84
+ let livenessProbeInFlight = false;
85
+ let lastSocketErrorSignature = '';
86
+ let lastSocketErrorAt = 0;
87
+
88
+ /** @type {SocketHandlerDeps | null} */
89
+ let deps = null;
90
+
91
+ /**
92
+ * @typedef {Object} SocketHandlerDeps
93
+ * @property {() => (void | Promise<void>)} refreshCurrentView
94
+ * @property {() => (void | Promise<void>)} refreshSessionWorkerStatus
95
+ * @property {(force?: boolean) => (void | Promise<void>)} refreshModelCatalog
96
+ * @property {(payload?: object) => void} updateModelCatalogState
97
+ * @property {() => (void | Promise<void>)} reconcileOpenModelVariantModal
98
+ * @property {(payload?: object) => void} applyConversationWorkspaceRootUpdate
99
+ * @property {(conversationId: string, title: string, updatedAt?: string | number | null) => void} applyConversationTitleUpdate
100
+ * @property {() => void} syncChatTitleControls
101
+ * @property {(conversationId: string, payload?: object) => void} applyConversationPreferencesForConversation
102
+ * @property {(payload?: object) => void} applyOpenAISettingsState
103
+ * @property {(payload?: object) => void} applyClaudeSettingsState
104
+ * @property {(payload?: object) => void} applyGrokSettingsState
105
+ * @property {(payload?: object) => void} applyCursorSettingsState
106
+ */
107
+
108
+ /**
109
+ * Register bootstrap-local callbacks required by socket event handlers.
110
+ * @param {SocketHandlerDeps} nextDeps
111
+ */
112
+ export function initSocketHandlers(nextDeps) {
113
+ deps = nextDeps;
114
+ }
115
+
116
+ export function getSocket() {
117
+ return socket;
118
+ }
119
+
120
+ /**
121
+ * Report this device's foreground state to the server, which uses it to decide
122
+ * whether push notifications should be suppressed. Safe to call in any state:
123
+ * a disconnected or absent socket makes it a no-op (the connect handler
124
+ * re-asserts visibility as soon as the socket is back).
125
+ */
126
+ export function emitDeviceVisibility(visible) {
127
+ if (!socket?.connected) return;
128
+ socket.emit('device_visibility', { deviceId: DEVICE_ID, visible: visible === true });
129
+ }
130
+
131
+ /**
132
+ * Close the transport rather than the namespace socket when backgrounding.
133
+ *
134
+ * socket.disconnect() makes the server report "client namespace disconnect", which
135
+ * is absent from socket.io's RECOVERABLE_DISCONNECT_REASONS, so the session is
136
+ * discarded and connectionStateRecovery replays nothing when the phone returns.
137
+ * Closing the engine surfaces as "transport close", which is recoverable. Manager
138
+ * reconnection is suspended first so the close does not immediately trigger the
139
+ * backoff loop we are trying to avoid while hidden.
140
+ */
141
+ function suspendSocketForBackground() {
142
+ // The "now hidden" heartbeat has to leave before the transport closes or it
143
+ // never arrives and the device looks active until its socket drops.
144
+ emitDeviceVisibility(false);
145
+ const manager = socket?.io;
146
+ manager?.reconnection(false);
147
+ if (manager?.engine) {
148
+ manager.engine.close();
149
+ return;
150
+ }
151
+ if (socket?.connected || socket?.active) socket.disconnect();
152
+ }
153
+
154
+ function resumeSocketFromBackground() {
155
+ socket?.io?.reconnection(true);
156
+ if (socket && !socket.connected) socket.connect();
157
+ }
158
+
159
+ export function setSocketActivityEnabled(value) {
160
+ socketActivityEnabled = !!value;
161
+ if (!socket) return;
162
+ if (!socketActivityEnabled) {
163
+ suspendSocketForBackground();
164
+ return;
165
+ }
166
+ resumeSocketFromBackground();
167
+ }
168
+
169
+ /**
170
+ * Bring the relay socket back up, whatever state the manager is in. Safe to call on a
171
+ * timer: connect() skips the manager while it is mid-backoff, so this cannot compete
172
+ * with socket.io's own retry schedule.
173
+ *
174
+ * `socket.active` only distinguishes the two cases for the caller's benefit. It stays
175
+ * true while the manager works through its backoff, and goes false once it gives up
176
+ * for good — which is what happens after an explicit disconnect() or a rejected
177
+ * handshake, since both destroy the socket instead of scheduling a retry. That is the
178
+ * case worth reporting, because nothing else would have reconnected.
179
+ * @returns {'connected'|'retrying'|'forced'|'disabled'}
180
+ */
181
+ export function ensureSocketConnected() {
182
+ if (!socketActivityEnabled || !socket) return 'disabled';
183
+ if (socket.connected) return 'connected';
184
+ const wasRetrying = socket.active;
185
+ // Reconnection is suspended while backgrounded, so re-arm it before asking the
186
+ // socket to connect. connect() is a no-op while the manager is mid-backoff.
187
+ socket.io?.reconnection(true);
188
+ socket.connect();
189
+ return wasRetrying ? 'retrying' : 'forced';
190
+ }
191
+
192
+ /**
193
+ * Last-resort recovery for manager states connect() cannot escape: a
194
+ * `_reconnecting` flag whose backoff timer was dropped by an Android freeze, or
195
+ * a connect attempt whose timers died with it. disconnect() forcibly clears the
196
+ * manager state machine (skipReconnect/_reconnecting) and destroys whatever
197
+ * engine is left; connect() then opens a clean handshake, and the socket's
198
+ * retained recovery offset still lets the server replay missed events when the
199
+ * outage stayed inside the recovery window.
200
+ */
201
+ export function hardResetSocket() {
202
+ if (!socket) return;
203
+ try {
204
+ socket.disconnect();
205
+ } catch {}
206
+ if (!socketActivityEnabled) return;
207
+ socket.io?.reconnection(true);
208
+ socket.connect();
209
+ }
210
+
211
+ /**
212
+ * Distrust a socket that claims to be connected right after the app returns to
213
+ * the foreground. A page frozen mid-connection can resume with `connected`
214
+ * still true on a transport the server dropped hours ago, and if the engine's
215
+ * ping-timeout timer was discarded with the freeze nothing ever notices — the
216
+ * watchdog sees "connected" and stands down. An acked ping settles it: no ack
217
+ * within the timeout means zombie, and closing the engine hands the manager a
218
+ * real close event to reconnect from.
219
+ */
220
+ export function verifySocketLiveness() {
221
+ if (!socketActivityEnabled || !socket?.connected || livenessProbeInFlight) return;
222
+ livenessProbeInFlight = true;
223
+ socket.timeout(SOCKET_LIVENESS_TIMEOUT_MS).emit('client_ping', (err) => {
224
+ livenessProbeInFlight = false;
225
+ if (!err) return;
226
+ // The engine may already have noticed and reconnected (or been suspended)
227
+ // while the probe was outstanding; only kill a socket still playing alive.
228
+ if (!socket?.connected) return;
229
+ recordStatusEvent('relay-zombie-socket', { timeoutMs: SOCKET_LIVENESS_TIMEOUT_MS });
230
+ socket.io?.engine?.close();
231
+ });
232
+ }
233
+
234
+ function requireDeps() {
235
+ if (!deps) {
236
+ throw new Error('socket-handlers: call initSocketHandlers() before connectSocket()');
237
+ }
238
+ return deps;
239
+ }
240
+
241
+ export async function connectSocket(overrideDeps) {
242
+ if (overrideDeps) {
243
+ deps = overrideDeps;
244
+ }
245
+ const {
246
+ refreshCurrentView,
247
+ refreshSessionWorkerStatus,
248
+ refreshModelCatalog,
249
+ updateModelCatalogState,
250
+ reconcileOpenModelVariantModal = async () => {},
251
+ applyConversationWorkspaceRootUpdate,
252
+ applyConversationTitleUpdate,
253
+ syncChatTitleControls,
254
+ applyConversationPreferencesForConversation,
255
+ } = requireDeps();
256
+
257
+ if (socket) {
258
+ if (socketActivityEnabled && !socket.connected) socket.connect();
259
+ return socket;
260
+ }
261
+
262
+ socket = io({
263
+ path: `${BASE}/socket.io/`,
264
+ auth: TOKEN ? { token: TOKEN, clientId: CLIENT_ID } : { clientId: CLIENT_ID },
265
+ autoConnect: false,
266
+ reconnection: true,
267
+ reconnectionDelay: 1000,
268
+ reconnectionDelayMax: 10000,
269
+ randomizationFactor: 0.5,
270
+ timeout: 10000,
271
+ transports: ['websocket', 'polling'],
272
+ });
273
+
274
+ // The connect-time view resync is the only correction path for state whose
275
+ // change events this client missed while suspended (e.g. a background-task
276
+ // set the server has since emptied — an empty store never re-announces
277
+ // itself). A connect that races the relay's own restart loses a one-shot
278
+ // resync to a failed fetch and the stale state then survives indefinitely,
279
+ // so failures retry with backoff until one pass lands on a live server.
280
+ let connectResyncTimer = null;
281
+ let connectResyncGeneration = 0;
282
+ function resyncAfterConnect(attempt = 0) {
283
+ const generation = attempt === 0 ? ++connectResyncGeneration : connectResyncGeneration;
284
+ if (connectResyncTimer) {
285
+ clearTimeout(connectResyncTimer);
286
+ connectResyncTimer = null;
287
+ }
288
+ refreshCurrentView().catch(() => {
289
+ if (generation !== connectResyncGeneration || attempt >= 4 || !socket?.connected) return;
290
+ connectResyncTimer = setTimeout(() => {
291
+ connectResyncTimer = null;
292
+ resyncAfterConnect(attempt + 1);
293
+ }, 2000 * (attempt + 1));
294
+ });
295
+ }
296
+
297
+ socket.on('connect', () => {
298
+ lastSocketErrorSignature = '';
299
+ lastSocketErrorAt = 0;
300
+ console.log('Socket connected');
301
+ // Connect-time heartbeat. Also re-asserts visibility after a recovered
302
+ // session, whose restored socket.data the server deliberately resets.
303
+ emitDeviceVisibility(document.visibilityState === 'visible');
304
+ clearMessageSearchRuntimeState();
305
+ setRelayOnline(true);
306
+ setCliOnline(true);
307
+ renderConvList();
308
+ resyncAfterConnect();
309
+ refreshSessionWorkerStatus().catch(() => {});
310
+ refreshModelCatalog().catch(() => {});
311
+ });
312
+ socket.on('connect_error', (e) => {
313
+ setRelayOnline(false);
314
+ const message = String(e?.message || 'unknown').trim() || 'unknown';
315
+ const signature = `socket-error:${message}`;
316
+ const now = Date.now();
317
+ if (signature !== lastSocketErrorSignature || (now - lastSocketErrorAt) > 8000) {
318
+ lastSocketErrorSignature = signature;
319
+ lastSocketErrorAt = now;
320
+ console.error('Socket error:', message);
321
+ }
322
+ });
323
+ socket.on('disconnect', () => {
324
+ setRelayOnline(false);
325
+ });
326
+ socket.on('cli_status', ({ online }) => {
327
+ setCliOnline(online);
328
+ renderConvList();
329
+ if (online) refreshCurrentView().catch(() => {});
330
+ refreshSessionWorkerStatus().catch(() => {});
331
+ if (online) refreshModelCatalog().catch(() => {});
332
+ });
333
+ // Keeps the relay dot's Cloudflare Tunnel colour live between status polls.
334
+ socket.on('cloudflared_tunnel_status', (payload) => {
335
+ setCloudflaredTunnelState(payload || null);
336
+ });
337
+ socket.on('models_updated', (payload) => {
338
+ updateModelCatalogState(payload || {});
339
+ void reconcileOpenModelVariantModal();
340
+ });
341
+ socket.on('openai_settings_updated', (payload) => {
342
+ deps?.applyOpenAISettingsState?.(payload || {});
343
+ if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
344
+ void Promise.resolve()
345
+ .then(() => deps?.refreshCurrentView?.())
346
+ .catch(() => {});
347
+ }
348
+ });
349
+ // No view refresh here: the engine switch rebinds nothing — it only changes
350
+ // which worker the NEXT Copilot conversation spawns.
351
+ socket.on('copilot_settings_updated', (payload) => {
352
+ deps?.applyCopilotSettingsState?.(payload || {});
353
+ });
354
+ socket.on('claude_settings_updated', (payload) => {
355
+ deps?.applyClaudeSettingsState?.(payload || {});
356
+ if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
357
+ void Promise.resolve()
358
+ .then(() => deps?.refreshCurrentView?.())
359
+ .catch(() => {});
360
+ }
361
+ });
362
+ // Login/logout transitions on the relay host. Broadcast to every client so a
363
+ // login started on the desktop can be finished on a phone (and vice versa).
364
+ socket.on('claude_auth_state', (payload) => {
365
+ applyClaudeAuthState(payload || null);
366
+ });
367
+ // Same contract for Grok, one state shorter (no code to paste back). Success
368
+ // arrives twice — once off the cached status, once when the confirming read
369
+ // lands — so the renderer is idempotent rather than transition-driven.
370
+ socket.on('grok_auth_state', (payload) => {
371
+ applyGrokAuthState(payload || null);
372
+ });
373
+ // CLI install transitions AND log chunks. The payload always carries the whole
374
+ // retained buffer plus a monotonic logSeq, so a client that connects
375
+ // mid-install renders the full log instead of a suffix.
376
+ socket.on('cli_install_state', (payload) => {
377
+ applyCliInstallState(payload || null);
378
+ });
379
+ socket.on('grok_settings_updated', (payload) => {
380
+ deps?.applyGrokSettingsState?.(payload || {});
381
+ if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
382
+ void Promise.resolve()
383
+ .then(() => deps?.refreshCurrentView?.())
384
+ .catch(() => {});
385
+ }
386
+ });
387
+ socket.on('cursor_settings_updated', (payload) => {
388
+ deps?.applyCursorSettingsState?.(payload || {});
389
+ if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
390
+ void Promise.resolve()
391
+ .then(() => deps?.refreshCurrentView?.())
392
+ .catch(() => {});
393
+ }
394
+ });
395
+ socket.on('shared_access', (event) => {
396
+ publishStatusEvent(event);
397
+ });
398
+ socket.on('workspace_root_changed', (payload) => {
399
+ updateWorkspaceRootHints(payload || {});
400
+ if (repoBrowserState.activeRoot !== 'workspace') return;
401
+ repoBrowserState.currentPath = '';
402
+ if (repoBrowserState.open) {
403
+ void loadRepoBrowserTree();
404
+ }
405
+ });
406
+ socket.on('conversation_workspace_root_updated', (payload) => {
407
+ updateWorkspaceRootHints(payload || {});
408
+ applyConversationWorkspaceRootUpdate(payload || {});
409
+ });
410
+ socket.on('user_message', ({ conversationId, messageId, senderClientId, message }) => {
411
+ const normalizedMessage = {
412
+ ...(message && typeof message === 'object' ? message : {}),
413
+ text: stripRelayPromptContext(message?.text, message?.mode),
414
+ };
415
+ if (senderClientId && senderClientId === CLIENT_ID) {
416
+ pendingUserMessageIds.delete(messageId);
417
+ return;
418
+ }
419
+ if (messageId && (pendingUserMessageIds.has(messageId) || seenMessageIds?.has(messageId))) {
420
+ pendingUserMessageIds.delete(messageId);
421
+ return;
422
+ }
423
+ if (conversationId === currentConvId) {
424
+ const renderedMessages = getRenderedConversationMessageFingerprints(24);
425
+ const hasPendingTextMatch = hasPendingUserMessageDuplicate(conversationId, normalizedMessage.text);
426
+ if (isLikelyLiveDuplicateMessage({
427
+ incomingMessageId: messageId,
428
+ incomingMessage: normalizedMessage,
429
+ existingMessages: renderedMessages,
430
+ hasPendingTextMatch,
431
+ })) {
432
+ return;
433
+ }
434
+ appendMessage(normalizedMessage, true, messageId);
435
+ }
436
+ });
437
+ socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
438
+ const isCurrentConversation = conversationId === currentConvId;
439
+ const autoScroll = isCurrentConversation ? isMessagesAtBottom() : false;
440
+ collapseThinkingThoughts();
441
+ removeThinking();
442
+ if ((!message?.activities || !message.activities.length) && sourceMessageId) {
443
+ const cached = relayActivities.get(sourceMessageId) || [];
444
+ if (cached.length) message.activities = cached.slice(0, 48);
445
+ }
446
+ if (sourceMessageId) {
447
+ const persistedThoughts = Array.isArray(message?.thoughts) ? message.thoughts : [];
448
+ const cachedThoughts = relayThoughts.get(sourceMessageId);
449
+ const mergedThoughts = mergeRelayThoughts(persistedThoughts, cachedThoughts);
450
+ if (mergedThoughts.length) {
451
+ message.thoughts = mergedThoughts;
452
+ }
453
+ }
454
+ if (messageId && seenMessageIds?.has(messageId)) return;
455
+ if (isCurrentConversation) {
456
+ appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
457
+ scheduleContextUsageRefresh(conversationId, 120);
458
+ }
459
+ if (sourceMessageId) relayActivities.delete(sourceMessageId);
460
+ if (sourceMessageId) relayThoughts.delete(sourceMessageId);
461
+ if (sourceMessageId) clearRelayStreamStateForMessage(sourceMessageId);
462
+ refreshSessionWorkerStatus().catch(() => {});
463
+ });
464
+ socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
465
+ socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
466
+ socket.on('relay_question_changed', () => {
467
+ loadRelayQuestions(currentConvId);
468
+ });
469
+ socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
470
+ socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
471
+ socket.on('relay_board_changed', () => {
472
+ loadRelayBoards();
473
+ });
474
+ socket.on('background_tasks', ({ conversationId, tasks }) => {
475
+ setConversationBackgroundTasks(conversationId, tasks);
476
+ });
477
+ // REPLACE semantics for the whole set: the preview registry is relay-owned
478
+ // and global, so every change ships the full list.
479
+ socket.on('previews', ({ previews }) => {
480
+ setPreviews(previews);
481
+ });
482
+ socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
483
+ if (!messageId || !text) return;
484
+ const entry = {
485
+ text: String(text || '').trim(),
486
+ subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
487
+ metadata: (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) ? metadata : null,
488
+ };
489
+ if (!entry.text) return;
490
+ const items = relayActivities.get(messageId) || [];
491
+ const last = items[items.length - 1];
492
+ const lastText = typeof last === 'string' ? last : String(last?.text || '');
493
+ const lastSubagentRunId = typeof last === 'object' && last ? (last.subagentRunId || null) : null;
494
+ if (lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId) {
495
+ relayActivities.set(messageId, items.concat(entry).slice(-24));
496
+ }
497
+ if (entry.subagentRunId) {
498
+ upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
499
+ addSubagentActivity(entry.subagentRunId, entry.text);
500
+ }
501
+ if (conversationId === currentConvId) {
502
+ const autoScroll = isMessagesAtBottom();
503
+ appendThinkingActivity(entry, entry.subagentRunId, autoScroll);
504
+ }
505
+ });
506
+ socket.on('relay_stream', ({ conversationId, messageId, text, done, seq, subagentRunId }) => {
507
+ if (!messageId) return;
508
+ if (subagentRunId) {
509
+ upsertSubagentRun({ subagentRunId: String(subagentRunId).trim(), messageId, conversationId });
510
+ }
511
+ if (conversationId !== currentConvId) return;
512
+ const autoScroll = isMessagesAtBottom();
513
+ applyRelayStreamEvent({
514
+ messageId,
515
+ text: String(text || ''),
516
+ done: !!done,
517
+ seq,
518
+ subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
519
+ autoScroll,
520
+ });
521
+ });
522
+ socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId }) => {
523
+ if (!messageId) return;
524
+ const key = String(reasoningId || 'reasoning');
525
+ const thoughtMap = relayThoughts.get(messageId) || new Map();
526
+ thoughtMap.set(key, { reasoningId: key, text: String(text || ''), done: !!done, subagentRunId: subagentRunId || null });
527
+ relayThoughts.set(messageId, thoughtMap);
528
+ if (subagentRunId) {
529
+ upsertSubagentRun({ subagentRunId, messageId, conversationId });
530
+ addSubagentThought(subagentRunId, { reasoningId: key, text: String(text || ''), done: !!done });
531
+ }
532
+ if (conversationId === currentConvId) {
533
+ const autoScroll = isMessagesAtBottom();
534
+ appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
535
+ }
536
+ });
537
+ socket.on('subagent_status', ({ conversationId, messageId, subagentRunId, parentSubagentId, displayName, status, timestamp, stopUnsupported }) => {
538
+ if (!messageId || !subagentRunId) return;
539
+ upsertSubagentRun({
540
+ subagentRunId,
541
+ messageId,
542
+ conversationId,
543
+ parentSubagentId,
544
+ displayName,
545
+ status,
546
+ timestamp,
547
+ });
548
+ clearSubagentCancelInFlight(subagentRunId);
549
+ // The provider answered "not supported": pin the state so the button does
550
+ // not re-arm into an endless click-and-fail loop.
551
+ if (stopUnsupported) markSubagentStopUnsupported(subagentRunId);
552
+ if (conversationId === currentConvId) {
553
+ updateSubagentBubbleFromStatus(subagentRunId, status);
554
+ }
555
+ });
556
+ socket.on('conversation_compacted', async ({ sourceConversationId, targetConversationId }) => {
557
+ if (!sourceConversationId || !targetConversationId) return;
558
+ await refreshConversations();
559
+ if (currentConvId === sourceConversationId) {
560
+ await openConversation(targetConversationId);
561
+ } else {
562
+ updateCompactButton();
563
+ }
564
+ });
565
+ socket.on('conversation_title_updated', ({ conversationId, title, updatedAt }) => {
566
+ applyConversationTitleUpdate(conversationId, title, updatedAt);
567
+ syncChatTitleControls();
568
+ });
569
+ socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, senderClientId }) => {
570
+ if (senderClientId && senderClientId === CLIENT_ID) return;
571
+ const id = String(conversationId || '').trim();
572
+ if (!id || !conversations[id]) return;
573
+ conversations[id] = {
574
+ ...conversations[id],
575
+ preferredRelayMode: preferredRelayMode || conversations[id].preferredRelayMode || FALLBACK_MODE,
576
+ preferredModel: preferredModel || conversations[id].preferredModel || '',
577
+ preferredReasoningEffort: preferredReasoningEffort || conversations[id].preferredReasoningEffort || '',
578
+ // null is a real value here (Auto), so only an omitted field keeps the
579
+ // previously known one.
580
+ autoCompactWindow: autoCompactWindow === undefined
581
+ ? (conversations[id].autoCompactWindow ?? null)
582
+ : (autoCompactWindow ?? null),
583
+ // Same semantics: null = Host default for enabled, and the display is
584
+ // an explicit mode string; only an omitted field keeps the known value.
585
+ thinkingEnabled: thinkingEnabled === undefined
586
+ ? (conversations[id].thinkingEnabled ?? null)
587
+ : (thinkingEnabled ?? null),
588
+ thinkingDisplay: thinkingDisplay === undefined
589
+ ? (conversations[id].thinkingDisplay ?? null)
590
+ : (thinkingDisplay ?? null),
591
+ };
592
+ if (String(currentConvId || '').trim() === id) {
593
+ applyConversationPreferencesForConversation(id, {
594
+ preferredRelayMode,
595
+ preferredModel,
596
+ preferredReasoningEffort,
597
+ });
598
+ }
599
+ });
600
+ socket.on('conversation_draft_updated', (payload = {}) => {
601
+ applyIncomingConversationDraftUpdate(payload || {});
602
+ });
603
+ socket.on('conversation_watchers', ({ conversationId, watcherCount }) => {
604
+ setConversationWatcherCount(conversationId, watcherCount);
605
+ deps?.syncChatTitleControls?.();
606
+ });
607
+ socket.on('conversation_session_bound', async ({ conversationId, sdkSessionId, runtimeSessionId }) => {
608
+ const id = String(conversationId || '').trim();
609
+ if (!id) return;
610
+ if (conversations[id]) {
611
+ conversations[id] = {
612
+ ...conversations[id],
613
+ sdkSessionId: String(sdkSessionId || conversations[id].sdkSessionId || '').trim() || null,
614
+ runtimeSessionId: String(runtimeSessionId || conversations[id].runtimeSessionId || '').trim() || null,
615
+ };
616
+ }
617
+ await refreshConversations();
618
+ if (currentConvId === id) {
619
+ await openConversation(id);
620
+ }
621
+ });
622
+ socket.on('message_status', ({ messageId, conversationId, status }) => {
623
+ const normalizedStatus = String(status || '').trim().toLowerCase();
624
+ const clearsProcessingStatus = ['done', 'failed', 'dropped', 'pending', 'parked', 'cancelled'].includes(normalizedStatus);
625
+ // pending/parked fire mid-turn (queueing, park/re-queue); only genuinely
626
+ // terminal statuses may tear down the live bubble and reload the view.
627
+ const isTerminalStatus = ['done', 'failed', 'dropped', 'cancelled'].includes(normalizedStatus);
628
+ applyConversationTurnStatus({ conversationId, messageId, status });
629
+ if (conversationId && conversations[conversationId]) {
630
+ const conversation = conversations[conversationId];
631
+ if (normalizedStatus === 'processing') {
632
+ conversation.localTurnStatus = 'processing';
633
+ conversation.localTurnStatusUpdatedAt = Date.now();
634
+ conversation.localTurnMessageId = String(messageId || '').trim() || null;
635
+ } else if (clearsProcessingStatus) {
636
+ const trackedMessageId = String(conversation.localTurnMessageId || '').trim();
637
+ const incomingMessageId = String(messageId || '').trim();
638
+ if (!trackedMessageId || !incomingMessageId || trackedMessageId === incomingMessageId) {
639
+ delete conversation.localTurnStatus;
640
+ delete conversation.localTurnStatusUpdatedAt;
641
+ delete conversation.localTurnMessageId;
642
+ }
643
+ }
644
+ }
645
+ if (conversationId === currentConvId && normalizedStatus === 'processing') {
646
+ const autoScroll = isMessagesAtBottom();
647
+ showThinking(messageId || null, autoScroll);
648
+ renderThinkingActivities();
649
+ renderThinkingThoughts();
650
+ if (messageId) renderRestoredSubagentBubbles(messageId);
651
+ if (messageId) removeUserBubbleCancelButton(messageId);
652
+ }
653
+ if (clearsProcessingStatus) {
654
+ clearPendingUserMessage(messageId);
655
+ if (messageId) clearRelayStreamStateForMessage(messageId);
656
+ if (messageId) clearBubbleCancelState(messageId);
657
+ if (messageId) removeUserBubbleCancelButton(messageId);
658
+ }
659
+ if (conversationId === currentConvId && isTerminalStatus) {
660
+ collapseThinkingThoughts();
661
+ removeThinking();
662
+ void refreshCurrentView().catch(() => {});
663
+ scheduleContextUsageRefresh(conversationId, 220);
664
+ refreshSessionWorkerStatus().catch(() => {});
665
+ // Files the agent created during the turn appear now, via the restoring
666
+ // path that keeps open folders and the current selection.
667
+ refreshRepoBrowserIfWorkspaceOpen();
668
+ }
669
+ renderConvList();
670
+ });
671
+ socket.on('conversation_deleted', ({ conversationId }) => {
672
+ delete conversations[conversationId];
673
+ for (const [id, question] of relayQuestions.entries()) {
674
+ if (question?.conversationId === conversationId) relayQuestions.delete(id);
675
+ }
676
+ for (const [id, board] of relayBoards.entries()) {
677
+ if (board?.conversationId === conversationId) relayBoards.delete(id);
678
+ }
679
+ for (const id of relayQuestionDrafts.keys()) {
680
+ const q = relayQuestionDrafts.get(id);
681
+ if (!q || q.conversationId === conversationId) relayQuestionDrafts.delete(id);
682
+ }
683
+ updatePendingQuestionBanner();
684
+ renderRelayBoards();
685
+ renderConvList();
686
+ if (currentConvId === conversationId) {
687
+ setCurrentConv(null);
688
+ renderMessages([]);
689
+ document.getElementById('chat-title').textContent = 'Select or start a conversation';
690
+ syncChatTitleControls();
691
+ updateSessionPill(null, null);
692
+ updateCompactButton();
693
+ scheduleContextUsageRefresh(null);
694
+ } else {
695
+ updateCompactButton();
696
+ }
697
+ });
698
+
699
+ if (socketActivityEnabled) {
700
+ socket.connect();
701
+ }
702
+ return socket;
703
+ }