@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,629 @@
1
+ import { capThought } from '../../shared/thought-cap.mjs';
2
+ import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
3
+
4
+ const MAX_TOOL_DETAIL_LENGTH = 140;
5
+ const REDACTED_THINKING_PLACEHOLDER = '[Reasoning redacted by the model provider]';
6
+ const SUBAGENT_TOOL_NAMES = new Set(['task', 'agent']);
7
+
8
+ function truncate(text, maxLength = MAX_TOOL_DETAIL_LENGTH) {
9
+ const value = String(text || '').trim();
10
+ if (value.length <= maxLength) return value;
11
+ return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
12
+ }
13
+
14
+ function formatCompactTokens(value) {
15
+ const n = Number(value);
16
+ if (!Number.isFinite(n)) return '';
17
+ if (Math.abs(n) < 1000) return String(Math.round(n));
18
+ return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
19
+ }
20
+
21
+ /**
22
+ * One user-readable line for a `system`/`api_retry` stream message. Exported
23
+ * because the session process needs the identical wording for retries that
24
+ * arrive between turns (carried into the next context as a pending activity).
25
+ */
26
+ export function formatApiRetryNotice(sdkMessage) {
27
+ const status = Number(sdkMessage?.error_status) || 0;
28
+ const attempt = Number(sdkMessage?.attempt) || 0;
29
+ const max = Number(sdkMessage?.max_retries) || 0;
30
+ const delaySec = Math.round((Number(sdkMessage?.retry_delay_ms) || 0) / 1000);
31
+ const cause = status === 529
32
+ ? 'Anthropic API overloaded (529)'
33
+ : `Anthropic API error${status ? ` (${status})` : ''}`;
34
+ const counter = attempt && max ? ` ${attempt}/${max}` : '';
35
+ const wait = delaySec > 0 ? ` in ~${delaySec}s` : '';
36
+ return `${cause} — retrying${counter}${wait}…`;
37
+ }
38
+
39
+ /**
40
+ * The single activity action a `compact_boundary` publishes. Exported because
41
+ * a compaction at resume arrives with no turn context — the session process
42
+ * buffers this same action onto the next one rather than losing the boundary
43
+ * (the per-turn normalizer never sees it).
44
+ *
45
+ * `post_tokens` is optional in the SDK type and absent from every auto-compact
46
+ * payload observed live, so the metadata degrades to a pre-only row instead of
47
+ * being dropped. The wire shape is snake_case under `compact_metadata` (the
48
+ * CLI converts its internal camelCase before yielding); the camelCase read is
49
+ * a cheap hedge, since the on-disk transcript uses that spelling.
50
+ */
51
+ export function compactBoundaryActivityAction(sdkMessage) {
52
+ const metadata = sdkMessage?.compact_metadata || sdkMessage?.compactMetadata || {};
53
+ const preTokens = Number(metadata.pre_tokens ?? metadata.preTokens);
54
+ const postTokens = Number(metadata.post_tokens ?? metadata.postTokens);
55
+ const hasPre = Number.isFinite(preTokens);
56
+ const hasPost = Number.isFinite(postTokens);
57
+ let detail = '';
58
+ if (hasPre && hasPost) detail = ` (${formatCompactTokens(preTokens)} → ${formatCompactTokens(postTokens)} tokens)`;
59
+ else if (hasPre) detail = ` (was ${formatCompactTokens(preTokens)} tokens)`;
60
+ return {
61
+ channel: 'activity',
62
+ payload: {
63
+ text: `Context compacted${detail}`,
64
+ subagentRunId: null,
65
+ // Structured twin of the prose: the transcript promotes this row to a
66
+ // full-width break instead of burying it in the tool-activity details,
67
+ // and needs the token counts to label it.
68
+ metadata: {
69
+ kind: 'compact_boundary',
70
+ preTokens: hasPre ? preTokens : null,
71
+ postTokens: hasPost ? postTokens : null,
72
+ },
73
+ },
74
+ };
75
+ }
76
+
77
+ // MCP tools reach the transcript namespaced as `mcp__<server>__<tool>`; which
78
+ // server carries a tool is relay plumbing, so the chip shows the bare name.
79
+ const MCP_TOOL_NAME = /^mcp__(?:.+?)__(.+)$/;
80
+
81
+ export function displayToolName(name) {
82
+ const value = String(name || '').trim();
83
+ return MCP_TOOL_NAME.exec(value)?.[1] || value;
84
+ }
85
+
86
+ export function isSubagentToolName(name) {
87
+ return SUBAGENT_TOOL_NAMES.has(String(name || '').trim().toLowerCase());
88
+ }
89
+
90
+ export function summarizeToolInput(toolName, input) {
91
+ if (!input || typeof input !== 'object') return '';
92
+ const candidates = [
93
+ input.command,
94
+ input.file_path,
95
+ input.path,
96
+ input.pattern,
97
+ input.description,
98
+ input.url,
99
+ input.query,
100
+ input.prompt,
101
+ ];
102
+ for (const candidate of candidates) {
103
+ const value = String(candidate ?? '').trim();
104
+ if (value) return value;
105
+ }
106
+ try {
107
+ const serialized = JSON.stringify(input);
108
+ return serialized === '{}' ? '' : serialized;
109
+ } catch {
110
+ return '';
111
+ }
112
+ }
113
+
114
+ export function formatToolActivityText(toolName, input) {
115
+ const summary = summarizeToolInput(toolName, input);
116
+ return truncate(summary ? `Tool (${toolName}): ${summary}` : `Tool (${toolName})`);
117
+ }
118
+
119
+ // Re-exported (rather than defined here) so existing importers of this
120
+ // normalizer keep working; the rule itself lives in shared/.
121
+ export { shouldEmitStreamUpdate };
122
+
123
+ /**
124
+ * Stateful normalizer mapping Claude Agent SDK messages onto relay channel
125
+ * actions. One instance per turn.
126
+ *
127
+ * `normalize(sdkMessage)` returns an array of `{ channel, payload }` actions:
128
+ * - `init` → `{ sessionId, model }`
129
+ * - `stream` → `{ text, done, subagentRunId? }` (cumulative snapshots per thread)
130
+ * - `thought` → `{ reasoningId, text, done, subagentRunId? }` (thinking blocks
131
+ * and interim narration; ids are stable across partial/complete frames so the
132
+ * server upserts rather than duplicating)
133
+ * - `activity` → `{ text, subagentRunId? }`
134
+ * - `subagent` → `{ subagentRunId, parentSubagentId, displayName, status }`
135
+ * - `background_tasks` → `{ tasks: [{ taskId, taskType, description }] }`
136
+ * (REPLACE semantics: the full live set after each membership change)
137
+ * - `background_task_settled` → `{ taskId, status }`
138
+ * - `result` → `{ text, isError, subtype, sessionId, model, usage }`
139
+ */
140
+ export function createSdkMessageNormalizer() {
141
+ const threads = new Map(); // threadKey -> per-thread stream/thinking bookkeeping
142
+ const knownSubagentRuns = new Map(); // toolUseId -> { displayName, parentSubagentId }
143
+ let initSessionId = '';
144
+ let initModel = '';
145
+
146
+ function threadKeyFor(parentToolUseId) {
147
+ return String(parentToolUseId || '').trim() || 'main';
148
+ }
149
+
150
+ function threadState(parentToolUseId) {
151
+ const key = threadKeyFor(parentToolUseId);
152
+ if (!threads.has(key)) {
153
+ threads.set(key, {
154
+ key,
155
+ streamText: '',
156
+ lastEmittedStreamText: '',
157
+ thinking: new Map(), // block index -> { reasoningId, text }
158
+ // The SDK delivers one complete `assistant` event PER CONTENT BLOCK
159
+ // (all sharing the message's API id, each arriving before that block's
160
+ // content_block_stop), so positional bookkeeping alone cannot pair a
161
+ // complete thinking block with its streamed frames. The streamed
162
+ // reasoning ids are recorded here per API message id and consumed in
163
+ // block order by the complete events — the server upserts thoughts by
164
+ // reasoningId, so the complete republish updates the streamed thought
165
+ // in place instead of duplicating it.
166
+ currentMessageId: '',
167
+ streamedThinkingIds: new Map(), // API message id -> [reasoningId, ...] in stream order
168
+ consumedThinkingIds: new Map(), // API message id -> count consumed by complete events
169
+ // Assistant-message counter for the thread; the fallback id key when
170
+ // partial frames (and thus recorded streamed ids) are unavailable.
171
+ messageIndex: 0,
172
+ // Set when `message_start` opens a message, so the complete `assistant`
173
+ // message reuses that index instead of allocating a new one. Without
174
+ // partial messages there is no `message_start`, and each complete
175
+ // message allocates its own index.
176
+ messageStarted: false,
177
+ // Which complete message the thread is currently inside, for the
178
+ // one-event-per-block delivery model.
179
+ lastCompleteMessageId: '',
180
+ currentComplete: null,
181
+ });
182
+ }
183
+ return threads.get(key);
184
+ }
185
+
186
+ function thoughtIdFor(state, kind, blockIndex) {
187
+ return `claude-${kind}-${state.key}-${state.messageIndex}-${blockIndex}`;
188
+ }
189
+
190
+ // Reasoning id for a thinking block on a complete assistant event: reuse the
191
+ // id its streamed frames already published (matched by API message id, in
192
+ // block order), falling back to a fresh positional id when no partial frames
193
+ // were seen for the message.
194
+ function completeThinkingIdFor(state, messageId, blockIndex) {
195
+ const streamedIds = messageId ? state.streamedThinkingIds.get(messageId) : null;
196
+ if (streamedIds?.length) {
197
+ const consumed = state.consumedThinkingIds.get(messageId) || 0;
198
+ if (consumed < streamedIds.length) {
199
+ state.consumedThinkingIds.set(messageId, consumed + 1);
200
+ return streamedIds[consumed];
201
+ }
202
+ }
203
+ return thoughtIdFor(state, 'thought', blockIndex);
204
+ }
205
+
206
+ // Called when a new assistant message begins on a thread, from whichever
207
+ // signal arrives first (`message_start`, or the complete message itself).
208
+ // Complete events arrive one per content block, all sharing message.id, so
209
+ // later blocks of the same message must not open a new message (that would
210
+ // drift the positional thought ids and re-trigger the boundary logic).
211
+ function beginAssistantMessage(state, { fromStreamEvent, messageId = '' }) {
212
+ if (fromStreamEvent) {
213
+ state.messageIndex += 1;
214
+ state.messageStarted = true;
215
+ state.thinking.clear();
216
+ // New API message: the pending bubble must show this message's prose,
217
+ // not the previous message's narration concatenated onto it. The
218
+ // stream channel publishes cumulative text, so the accumulator resets
219
+ // at the message boundary.
220
+ state.streamText = '';
221
+ state.lastEmittedStreamText = '';
222
+ return;
223
+ }
224
+ if (messageId && state.lastCompleteMessageId === messageId) return;
225
+ state.lastCompleteMessageId = messageId;
226
+ if (state.messageStarted) {
227
+ state.messageStarted = false;
228
+ return;
229
+ }
230
+ state.messageIndex += 1;
231
+ }
232
+
233
+ function subagentRunIdFor(parentToolUseId) {
234
+ const value = String(parentToolUseId || '').trim();
235
+ return value || null;
236
+ }
237
+
238
+ function actionsForToolUseBlock(block, parentToolUseId) {
239
+ const actions = [];
240
+ const toolName = displayToolName(block?.name) || 'unknown';
241
+ const toolUseId = String(block?.id || '').trim();
242
+ const input = block?.input && typeof block.input === 'object' ? block.input : {};
243
+ if (isSubagentToolName(toolName) && toolUseId) {
244
+ const displayName = String(
245
+ input.description
246
+ || input.name
247
+ || input.subagent_type
248
+ || 'Subagent',
249
+ ).trim() || 'Subagent';
250
+ const parentSubagentId = subagentRunIdFor(parentToolUseId);
251
+ knownSubagentRuns.set(toolUseId, { displayName, parentSubagentId });
252
+ actions.push({
253
+ channel: 'subagent',
254
+ payload: {
255
+ subagentRunId: toolUseId,
256
+ parentSubagentId,
257
+ displayName,
258
+ status: 'running',
259
+ },
260
+ });
261
+ }
262
+ actions.push({
263
+ channel: 'activity',
264
+ payload: {
265
+ text: formatToolActivityText(toolName, input),
266
+ subagentRunId: subagentRunIdFor(parentToolUseId),
267
+ },
268
+ });
269
+ return actions;
270
+ }
271
+
272
+ function actionsForToolResultBlock(block, parentToolUseId) {
273
+ const actions = [];
274
+ const toolUseId = String(block?.tool_use_id || '').trim();
275
+ const isError = block?.is_error === true;
276
+ if (toolUseId && knownSubagentRuns.has(toolUseId)) {
277
+ const run = knownSubagentRuns.get(toolUseId);
278
+ actions.push({
279
+ channel: 'subagent',
280
+ payload: {
281
+ subagentRunId: toolUseId,
282
+ parentSubagentId: run.parentSubagentId,
283
+ displayName: run.displayName,
284
+ status: isError ? 'failed' : 'completed',
285
+ },
286
+ });
287
+ }
288
+ if (isError) {
289
+ const errorText = typeof block?.content === 'string'
290
+ ? block.content
291
+ : (Array.isArray(block?.content)
292
+ ? block.content.map((entry) => String(entry?.text || '')).join(' ')
293
+ : '');
294
+ actions.push({
295
+ channel: 'activity',
296
+ payload: {
297
+ text: truncate(`Tool failed: ${errorText || 'unknown error'}`),
298
+ subagentRunId: subagentRunIdFor(parentToolUseId),
299
+ },
300
+ });
301
+ }
302
+ return actions;
303
+ }
304
+
305
+ /**
306
+ * Complete assistant messages carry the authoritative copy of every block.
307
+ * Thinking is republished here (same reasoningId as the streamed frames, so
308
+ * it upserts) which is also the only path that surfaces thinking at all when
309
+ * partial messages are unavailable. Assistant text that shares a message with
310
+ * tool calls is interim narration and becomes a thought, mirroring the
311
+ * Copilot extension's reasoning-stream bridge; text on a final, tool-free
312
+ * message is the answer itself and must not be duplicated as a thought.
313
+ */
314
+ function actionsForAssistantMessage(content, parentToolUseId, messageId) {
315
+ const actions = [];
316
+ const state = threadState(parentToolUseId);
317
+ const subagentRunId = subagentRunIdFor(parentToolUseId);
318
+ beginAssistantMessage(state, { fromStreamEvent: false, messageId });
319
+
320
+ // Whether a text block is interim narration (a thought) or the answer is
321
+ // a property of the whole API message, but the SDK delivers one complete
322
+ // event per content block — so within one event a text block can never
323
+ // share `content` with a tool_use. Track tool-bearing per message.id:
324
+ // text seen before the message's first tool_use buffers until either a
325
+ // tool_use proves it narration or the message ends (answer text is
326
+ // published from the result message and must never double as a thought).
327
+ if (messageId && state.currentComplete?.id !== messageId) {
328
+ state.currentComplete = { id: messageId, sawToolUse: false, pendingTexts: [] };
329
+ }
330
+ const tracker = messageId ? state.currentComplete : null;
331
+ const hasToolUseInEvent = content.some((block) => block?.type === 'tool_use');
332
+
333
+ const emitNarration = (text, index) => {
334
+ const capped = capThought(text || '');
335
+ if (!capped.trim()) return;
336
+ actions.push({
337
+ channel: 'thought',
338
+ payload: {
339
+ reasoningId: thoughtIdFor(state, 'narration', index),
340
+ text: capped,
341
+ done: true,
342
+ subagentRunId,
343
+ },
344
+ });
345
+ };
346
+
347
+ for (const [index, block] of content.entries()) {
348
+ const blockType = String(block?.type || '');
349
+ if (blockType === 'thinking' || blockType === 'redacted_thinking') {
350
+ // Consume the pairing slot BEFORE the empty check: the summarized
351
+ // display can deliver an empty raw block ahead of the summary block,
352
+ // and skipping the empty one would hand its streamed id to the summary.
353
+ const reasoningId = completeThinkingIdFor(state, messageId, index);
354
+ const text = blockType === 'redacted_thinking'
355
+ // Redacted blocks carry encrypted `data`, never `thinking` — a
356
+ // placeholder keeps the reasoning visible instead of vanishing.
357
+ ? (String(block?.data || '').trim() ? REDACTED_THINKING_PLACEHOLDER : '')
358
+ : capThought(block?.thinking || '');
359
+ if (!text.trim()) continue;
360
+ actions.push({
361
+ channel: 'thought',
362
+ payload: {
363
+ reasoningId,
364
+ text,
365
+ done: true,
366
+ subagentRunId,
367
+ },
368
+ });
369
+ continue;
370
+ }
371
+ if (blockType === 'text') {
372
+ if (tracker) {
373
+ if (tracker.sawToolUse) emitNarration(block?.text, index);
374
+ else tracker.pendingTexts.push({ index, text: String(block?.text || '') });
375
+ } else if (hasToolUseInEvent) {
376
+ // No message id to correlate on: fall back to the per-event check.
377
+ emitNarration(block?.text, index);
378
+ }
379
+ continue;
380
+ }
381
+ if (blockType === 'tool_use') {
382
+ if (tracker) {
383
+ tracker.sawToolUse = true;
384
+ for (const pending of tracker.pendingTexts.splice(0)) {
385
+ emitNarration(pending.text, pending.index);
386
+ }
387
+ }
388
+ actions.push(...actionsForToolUseBlock(block, parentToolUseId));
389
+ }
390
+ }
391
+ return actions;
392
+ }
393
+
394
+ function normalizeStreamEvent(sdkMessage) {
395
+ const actions = [];
396
+ const event = sdkMessage?.event || {};
397
+ const parentToolUseId = sdkMessage?.parent_tool_use_id || null;
398
+ const state = threadState(parentToolUseId);
399
+ const subagentRunId = subagentRunIdFor(parentToolUseId);
400
+ const eventType = String(event?.type || '');
401
+
402
+ if (eventType === 'message_start') {
403
+ beginAssistantMessage(state, { fromStreamEvent: true });
404
+ state.currentMessageId = String(event?.message?.id || '').trim();
405
+ return actions;
406
+ }
407
+
408
+ if (eventType === 'content_block_start') {
409
+ const blockType = String(event?.content_block?.type || '');
410
+ if (blockType === 'thinking' || blockType === 'redacted_thinking') {
411
+ const index = Number(event?.index ?? 0);
412
+ const reasoningId = thoughtIdFor(state, 'thought', index);
413
+ state.thinking.set(index, { reasoningId, text: '' });
414
+ if (state.currentMessageId) {
415
+ const ids = state.streamedThinkingIds.get(state.currentMessageId) || [];
416
+ ids.push(reasoningId);
417
+ state.streamedThinkingIds.set(state.currentMessageId, ids);
418
+ }
419
+ }
420
+ return actions;
421
+ }
422
+
423
+ if (eventType === 'content_block_delta') {
424
+ const deltaType = String(event?.delta?.type || '');
425
+ const index = Number(event?.index ?? 0);
426
+ if (deltaType === 'text_delta') {
427
+ state.streamText += String(event?.delta?.text || '');
428
+ if (shouldEmitStreamUpdate(state.streamText, state.lastEmittedStreamText)) {
429
+ state.lastEmittedStreamText = state.streamText;
430
+ actions.push({
431
+ channel: 'stream',
432
+ payload: { text: state.streamText, done: false, subagentRunId },
433
+ });
434
+ }
435
+ return actions;
436
+ }
437
+ if (deltaType === 'thinking_delta') {
438
+ const thinking = state.thinking.get(index);
439
+ if (thinking) {
440
+ thinking.text = capThought(thinking.text + String(event?.delta?.thinking || ''));
441
+ actions.push({
442
+ channel: 'thought',
443
+ payload: {
444
+ reasoningId: thinking.reasoningId,
445
+ text: thinking.text,
446
+ done: false,
447
+ subagentRunId,
448
+ },
449
+ });
450
+ }
451
+ return actions;
452
+ }
453
+ return actions;
454
+ }
455
+
456
+ if (eventType === 'content_block_stop') {
457
+ const index = Number(event?.index ?? 0);
458
+ const thinking = state.thinking.get(index);
459
+ if (thinking) {
460
+ state.thinking.delete(index);
461
+ if (thinking.text.trim()) {
462
+ actions.push({
463
+ channel: 'thought',
464
+ payload: {
465
+ reasoningId: thinking.reasoningId,
466
+ text: thinking.text,
467
+ done: true,
468
+ subagentRunId,
469
+ },
470
+ });
471
+ }
472
+ }
473
+ return actions;
474
+ }
475
+
476
+ return actions;
477
+ }
478
+
479
+ function normalize(sdkMessage) {
480
+ if (!sdkMessage || typeof sdkMessage !== 'object') return [];
481
+ const type = String(sdkMessage.type || '');
482
+
483
+ if (type === 'system' && sdkMessage.subtype === 'init') {
484
+ initSessionId = String(sdkMessage.session_id || '').trim();
485
+ initModel = String(sdkMessage.model || '').trim();
486
+ return [{ channel: 'init', payload: { sessionId: initSessionId, model: initModel } }];
487
+ }
488
+
489
+ // The CLI retried a safeguards-refused request on a fallback model
490
+ // (e.g. Fable 5 → Opus 4.8, scope "session"). From here on this turn's
491
+ // output comes from the fallback model, so the recorded model must follow
492
+ // it — the init-channel action updates the turn's responseModel in place.
493
+ // The CLI's own notice is surfaced as an activity line; silently flipping
494
+ // models is exactly the confusion this exists to prevent (conv 3366b9d3).
495
+ if (type === 'system' && sdkMessage.subtype === 'model_refusal_fallback') {
496
+ const actions = [];
497
+ const fallbackModel = String(sdkMessage.fallbackModel || '').trim();
498
+ if (fallbackModel) {
499
+ initModel = fallbackModel;
500
+ actions.push({ channel: 'init', payload: { sessionId: initSessionId, model: fallbackModel } });
501
+ }
502
+ const notice = String(sdkMessage.content || '').trim()
503
+ || `Model switched to ${fallbackModel || 'a fallback model'} after a refusal.`;
504
+ actions.push({ channel: 'activity', payload: { text: truncate(notice, 500), subagentRunId: null } });
505
+ return actions;
506
+ }
507
+
508
+ // An upstream API request failed and the CLI is retrying with backoff.
509
+ // While that happens the CLI emits no assistant traffic at all, so
510
+ // without this line the relay UI shows a bare typing indicator that is
511
+ // indistinguishable from a wedged worker (three 529 stalls read as relay
512
+ // freezes on 2026-08-18). At most max_retries lines per request.
513
+ if (type === 'system' && sdkMessage.subtype === 'api_retry') {
514
+ return [{
515
+ channel: 'activity',
516
+ payload: { text: formatApiRetryNotice(sdkMessage), subagentRunId: null },
517
+ }];
518
+ }
519
+
520
+ if (type === 'stream_event') {
521
+ return normalizeStreamEvent(sdkMessage);
522
+ }
523
+
524
+ if (type === 'assistant') {
525
+ const parentToolUseId = sdkMessage?.parent_tool_use_id || null;
526
+ const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
527
+ const messageId = String(sdkMessage?.message?.id || '').trim();
528
+ return actionsForAssistantMessage(content, parentToolUseId, messageId);
529
+ }
530
+
531
+ if (type === 'user') {
532
+ const actions = [];
533
+ const parentToolUseId = sdkMessage?.parent_tool_use_id || null;
534
+ const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
535
+ for (const block of content) {
536
+ if (block?.type === 'tool_result') {
537
+ actions.push(...actionsForToolResultBlock(block, parentToolUseId));
538
+ }
539
+ }
540
+ return actions;
541
+ }
542
+
543
+ if (type === 'system' && sdkMessage.subtype === 'compact_boundary') {
544
+ return [compactBoundaryActivityAction(sdkMessage)];
545
+ }
546
+
547
+ // The live background-task set, emitted on every membership change with
548
+ // REPLACE semantics. The turn runner gates the input-gate release on it: a
549
+ // released gate closes the CLI's control transport, and background agents
550
+ // that outlive the visible turn then lose every permission request.
551
+ if (type === 'system' && sdkMessage.subtype === 'background_tasks_changed') {
552
+ const tasks = (Array.isArray(sdkMessage.tasks) ? sdkMessage.tasks : [])
553
+ .map((task) => ({
554
+ taskId: String(task?.task_id || '').trim(),
555
+ taskType: String(task?.task_type || '').trim(),
556
+ description: String(task?.description || '').trim(),
557
+ }))
558
+ .filter((task) => task.taskId);
559
+ return [{ channel: 'background_tasks', payload: { tasks } }];
560
+ }
561
+
562
+ if (type === 'system' && sdkMessage.subtype === 'task_notification') {
563
+ const taskId = String(sdkMessage.task_id || '').trim() || 'unknown';
564
+ const status = String(sdkMessage.status || '').trim() || 'unknown';
565
+ return [
566
+ // Settling is the edge the runner pairs with the level signal above: a
567
+ // settled session-level task means a continuation turn is about to be
568
+ // dequeued, so the gate must stay held even though the set is empty.
569
+ { channel: 'background_task_settled', payload: { taskId, status } },
570
+ {
571
+ channel: 'activity',
572
+ payload: {
573
+ text: truncate(`Background task ${taskId} ${status}: ${String(sdkMessage.summary || '').trim()}`),
574
+ subagentRunId: null,
575
+ },
576
+ },
577
+ ];
578
+ }
579
+
580
+ if (type === 'result') {
581
+ // A resumed session whose previous CLI process died with background tasks
582
+ // still tracked replays a bookkeeping turn for the orphaned-task
583
+ // notification and emits a zero-work result BEFORE the delivered user
584
+ // message's turn (num_turns 0, duration_api_ms 0). Treating that phantom
585
+ // as the turn result makes the runner release the input gate, which
586
+ // closes the CLI's control transport — every subsequent permission
587
+ // request in the real turn then fails with "AbortError: Stream closed"
588
+ // (canUseTool is never reached). Skip it; the real result follows.
589
+ const isPhantom = sdkMessage.subtype === 'success'
590
+ && sdkMessage.is_error !== true
591
+ && Number(sdkMessage.num_turns) === 0
592
+ && Number(sdkMessage.duration_api_ms) === 0;
593
+ if (isPhantom) return [];
594
+ const isError = sdkMessage.subtype !== 'success' || sdkMessage.is_error === true;
595
+ return [{
596
+ channel: 'result',
597
+ payload: {
598
+ text: String(sdkMessage.result || '').trim(),
599
+ isError,
600
+ subtype: String(sdkMessage.subtype || ''),
601
+ sessionId: String(sdkMessage.session_id || initSessionId || '').trim(),
602
+ model: initModel,
603
+ usage: sdkMessage.usage || null,
604
+ // `modelUsage[model].contextWindow` is the authoritative per-model
605
+ // window; it is the fallback when the context-usage control request
606
+ // is unavailable. Its token counts are cumulative across the whole
607
+ // turn, so they must not be read as context occupancy.
608
+ modelUsage: sdkMessage.modelUsage || null,
609
+ totalCostUsd: Number.isFinite(Number(sdkMessage.total_cost_usd))
610
+ ? Number(sdkMessage.total_cost_usd)
611
+ : null,
612
+ },
613
+ }];
614
+ }
615
+
616
+ return [];
617
+ }
618
+
619
+ function finalStreamText() {
620
+ return threadState(null).streamText;
621
+ }
622
+
623
+ return {
624
+ normalize,
625
+ finalStreamText,
626
+ get sessionId() { return initSessionId; },
627
+ get model() { return initModel; },
628
+ };
629
+ }