@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,800 @@
1
+ // Pure mapping from Copilot SDK `SessionEvent`s onto the relay's channel
2
+ // actions — the same six channels the Claude and Cursor workers publish on
3
+ // (init / stream / thought / activity / subagent / result), so the transcript,
4
+ // reasoning bubbles and activity rows render identically whichever engine ran
5
+ // the turn. No I/O, no SDK import: one instance per turn, fed every event the
6
+ // session emits while that turn is in flight.
7
+ //
8
+ // Shapes are taken from the phase-0 spike dumps, which are checked in under
9
+ // ./fixtures and replayed by the tests. Four findings drive most of the design
10
+ // decisions below and are worth stating up front:
11
+ //
12
+ // 1. `assistant.turn_end` fires once per MODEL CALL, not once per user
13
+ // request — a tool-using turn emits turn_start/turn_end, runs the tool,
14
+ // then opens a second turn. Only `session.idle` ends the interaction.
15
+ // 2. A quota-failed turn never reaches `session.idle` at all (the spike's
16
+ // event-dump has 2 idles for 3 turns), so `session.error` has to be a
17
+ // terminator in its own right or the worker hangs on every quota failure.
18
+ // 3. `turnId` is the string "0" in every event of every dump. It is not an
19
+ // identifier; `interactionId` is.
20
+ // 4. Subagent events are tagged with `agentId` on the event ENVELOPE (not on
21
+ // `data`), and it is absent for the root agent. See `isSubagentEvent`.
22
+ import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
23
+ import { capThought } from '../../shared/thought-cap.mjs';
24
+ import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
25
+
26
+ const MAX_TOOL_DETAIL_LENGTH = 140;
27
+
28
+ function truncate(text, maxLength = MAX_TOOL_DETAIL_LENGTH) {
29
+ const value = String(text || '').trim();
30
+ if (value.length <= maxLength) return value;
31
+ return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
32
+ }
33
+
34
+ /** The arguments most worth showing in an activity row, in preference order. */
35
+ const TOOL_SUMMARY_KEYS = [
36
+ 'command',
37
+ 'fullCommandText',
38
+ 'file_path',
39
+ 'filePath',
40
+ 'path',
41
+ 'pattern',
42
+ 'description',
43
+ 'url',
44
+ 'query',
45
+ 'question',
46
+ 'prompt',
47
+ ];
48
+
49
+ function renderScalar(value) {
50
+ if (typeof value === 'string') return truncate(value);
51
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
52
+ if (value === null) return 'null';
53
+ if (Array.isArray(value)) return `[${value.length} items]`;
54
+ if (typeof value === 'object') return '{…}';
55
+ return '';
56
+ }
57
+
58
+ export function summarizeToolInput(input) {
59
+ if (!input || typeof input !== 'object') return '';
60
+ for (const key of TOOL_SUMMARY_KEYS) {
61
+ const value = String(input[key] ?? '').trim();
62
+ if (value) return value;
63
+ }
64
+ // Fallback for tools with no recognised argument. Bounded on purpose: the
65
+ // result is truncated to 140 characters anyway, and a tool called with a
66
+ // megabyte of file contents must not be serialized in full to produce it.
67
+ const parts = [];
68
+ let budget = MAX_TOOL_DETAIL_LENGTH;
69
+ for (const [key, value] of Object.entries(input)) {
70
+ if (value === undefined || typeof value === 'function') continue;
71
+ const rendered = renderScalar(value);
72
+ if (!rendered) continue;
73
+ parts.push(`${key}=${rendered}`);
74
+ budget -= key.length + rendered.length + 2;
75
+ if (budget <= 0) break;
76
+ }
77
+ return parts.join(' ');
78
+ }
79
+
80
+ export function formatToolActivityText(toolName, input) {
81
+ const name = String(toolName || '').trim() || 'tool';
82
+ const summary = summarizeToolInput(input);
83
+ return truncate(summary ? `Tool (${name}): ${summary}` : `Tool (${name})`);
84
+ }
85
+
86
+ /**
87
+ * True when an event belongs to a SUBAGENT rather than the root agent.
88
+ *
89
+ * Every event interface in the SDK's generated schema carries the same
90
+ * optional envelope field: "Sub-agent instance identifier. Absent for events
91
+ * from the root/main agent and session-level events." Note it is on the
92
+ * envelope, NOT on `data`.
93
+ *
94
+ * The runtime forwards subagent streaming by default
95
+ * (`includeSubAgentStreamingEvents`, default true), and turning that flag off
96
+ * is not an option: a live probe against runtime 1.0.82 showed it also
97
+ * collapses the PARENT's tool-call argument streaming (`streaming_delta`
98
+ * 33→2, `tool_call_delta` 32→1), gutting main-transcript streaming. So the
99
+ * routing lives here instead: phase 1 dropped these events, phase 2 routes
100
+ * them to the subagent lane keyed on `agentId`.
101
+ */
102
+ export function isSubagentEvent(event) {
103
+ return !!String(event?.agentId || '').trim();
104
+ }
105
+
106
+ /**
107
+ * The run id for a subagent event.
108
+ *
109
+ * Lifecycle events (`subagent.started` / `completed` / `failed`) carry
110
+ * `data.toolCallId` — the spawning tool call — while the tagged
111
+ * `assistant.message` that holds the subagent's actual reply carries only the
112
+ * envelope `agentId`. Whichever of the two is present is registered as an
113
+ * ALIAS of one canonical run id (see `resolveSubagentRun`), so the lane still
114
+ * assembles if a runtime version populates only one of them.
115
+ */
116
+ export function subagentEventKeys(event) {
117
+ const data = event?.data && typeof event.data === 'object' ? event.data : {};
118
+ return {
119
+ agentId: sanitizeSubagentRunId(event?.agentId),
120
+ toolCallId: sanitizeSubagentRunId(data.toolCallId),
121
+ };
122
+ }
123
+
124
+ /** A short, human-readable label for a subagent, for the lane header. */
125
+ export function subagentDisplayName(data) {
126
+ return String(data?.agentDisplayName || data?.agentName || '').trim() || 'Subagent';
127
+ }
128
+
129
+ /** "12.3k tokens · 4 tool calls · 8.1s" — whichever parts the runtime reported. */
130
+ export function formatSubagentStats(data) {
131
+ const parts = [];
132
+ const tokens = Number(data?.totalTokens);
133
+ if (Number.isFinite(tokens) && tokens > 0) parts.push(`${tokens} tokens`);
134
+ const toolCalls = Number(data?.totalToolCalls);
135
+ if (Number.isFinite(toolCalls) && toolCalls > 0) parts.push(`${toolCalls} tool calls`);
136
+ const durationMs = Number(data?.durationMs);
137
+ if (Number.isFinite(durationMs) && durationMs > 0) parts.push(`${(durationMs / 1000).toFixed(1)}s`);
138
+ return parts.join(' · ');
139
+ }
140
+
141
+ /**
142
+ * Per-turn token/cost accounting. `assistant.usage` fires once per model call,
143
+ * so a tool-using turn reports several — they are summed, while latency
144
+ * figures keep the FIRST call's time-to-first-token (that is the number a user
145
+ * experiences as "did it start answering").
146
+ */
147
+ function createUsageAccumulator() {
148
+ const totals = {
149
+ inputTokens: 0,
150
+ outputTokens: 0,
151
+ cacheReadTokens: 0,
152
+ cacheWriteTokens: 0,
153
+ reasoningTokens: 0,
154
+ // `cost` is the PREMIUM MULTIPLIER, not money — the field name is
155
+ // misleading and there is no `premiumRequests` field at all. Real spend is
156
+ // `copilotUsage.totalNanoAiu`, summed separately below, which is what a
157
+ // usage card must report.
158
+ cost: 0,
159
+ totalNanoAiu: 0,
160
+ modelCalls: 0,
161
+ subagentModelCalls: 0,
162
+ durationMs: 0,
163
+ timeToFirstTokenMs: null,
164
+ model: '',
165
+ isByok: null,
166
+ quotaSnapshots: null,
167
+ };
168
+ function add(data, { allowModel = true } = {}) {
169
+ if (!data || typeof data !== 'object') return;
170
+ totals.modelCalls += 1;
171
+ if (!allowModel) totals.subagentModelCalls += 1;
172
+ for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'cost']) {
173
+ const value = Number(data[key]);
174
+ if (Number.isFinite(value)) totals[key] += value;
175
+ }
176
+ const nanoAiu = Number(data.copilotUsage?.totalNanoAiu);
177
+ if (Number.isFinite(nanoAiu)) totals.totalNanoAiu += nanoAiu;
178
+ const duration = Number(data.duration);
179
+ if (Number.isFinite(duration)) totals.durationMs += duration;
180
+ const ttft = Number(data.timeToFirstTokenMs);
181
+ if (totals.timeToFirstTokenMs === null && Number.isFinite(ttft)) totals.timeToFirstTokenMs = ttft;
182
+ // A subagent's usage counts toward spend but must not rename the turn's
183
+ // model: `task` can override the model per subagent.
184
+ const model = allowModel ? String(data.model || '').trim() : '';
185
+ if (model) totals.model = model;
186
+ if (typeof data.isByok === 'boolean') totals.isByok = data.isByok;
187
+ // Usually `{}` on a healthy call; a populated snapshot (the quota path)
188
+ // must not be overwritten by a later empty one.
189
+ if (data.quotaSnapshots && Object.keys(data.quotaSnapshots).length) {
190
+ totals.quotaSnapshots = data.quotaSnapshots;
191
+ }
192
+ }
193
+ return {
194
+ add,
195
+ setQuotaSnapshots(snapshots) {
196
+ if (snapshots && typeof snapshots === 'object' && Object.keys(snapshots).length) {
197
+ totals.quotaSnapshots = snapshots;
198
+ }
199
+ },
200
+ snapshot() {
201
+ return totals.modelCalls ? { ...totals } : (totals.quotaSnapshots ? { ...totals } : null);
202
+ },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Stateful per-turn normalizer. `normalize(event)` returns zero or more
208
+ * `{ channel, payload }` actions; the session process dispatches them to the
209
+ * relay and treats the first `result` action as the turn's terminator.
210
+ */
211
+ export function createCopilotEventNormalizer() {
212
+ let sessionModel = '';
213
+ let sessionId = '';
214
+ let lastEmittedStreamText = '';
215
+ let aborted = false;
216
+ let terminalEmitted = false;
217
+ let contextUsage = null;
218
+ let interactionId = '';
219
+ const usage = createUsageAccumulator();
220
+
221
+ // Assistant text is assembled per `messageId`. A tool-using request produces
222
+ // several assistant messages (the first typically empty with `toolRequests`,
223
+ // a later one carrying the prose), so the reply is the ordered join of the
224
+ // non-empty ones rather than "the last message wins".
225
+ //
226
+ // `cachedPrefix` is the joined text of every message EXCEPT the newest one.
227
+ // Deltas only ever touch the newest, so composing the reply per delta is an
228
+ // append rather than a re-join of the whole conversation — the difference
229
+ // between O(n) and O(n²) work across a long streamed answer. It is dropped
230
+ // (rebuilt lazily) whenever a new message opens or an older one is rewritten.
231
+ const messageOrder = [];
232
+ const messageTexts = new Map();
233
+ let cachedPrefix = null;
234
+ const openThoughts = new Map(); // reasoningId -> accumulated text
235
+ const emittedThoughts = new Map(); // reasoningId -> last published text
236
+ const toolNames = new Map(); // toolCallId -> toolName
237
+ const permissionRequests = new Map(); // requestId -> summary
238
+
239
+ // ---- prompt segments (steering attribution) -------------------------------
240
+ //
241
+ // One interaction can answer SEVERAL prompts: a message sent mid-turn is
242
+ // queued by the runtime and picked up at the next model-call boundary, and
243
+ // the whole thing closes with a single `session.idle` (live-verified, see
244
+ // §4b/§5 of the plan). Each prompt the runtime actually starts work on opens
245
+ // with its own `user.message` event, so those events are the prompt
246
+ // boundaries — and they are what lets a steered queue row be answered with
247
+ // ITS OWN text instead of the whole interaction's transcript.
248
+ const segments = []; // array of arrays of messageIds, one per user.message
249
+
250
+ function currentSegment() {
251
+ if (!segments.length) segments.push([]);
252
+ return segments[segments.length - 1];
253
+ }
254
+
255
+ function noteMessage(messageId) {
256
+ const id = String(messageId || '').trim();
257
+ if (!id) return '';
258
+ if (!messageTexts.has(id)) {
259
+ messageTexts.set(id, '');
260
+ messageOrder.push(id);
261
+ currentSegment().push(id);
262
+ // The message that was newest is now part of the prefix.
263
+ cachedPrefix = null;
264
+ }
265
+ return id;
266
+ }
267
+
268
+ /** The reply text for prompt `index` (0 = the prompt the turn opened with). */
269
+ function segmentText(index) {
270
+ const ids = segments[index];
271
+ if (!Array.isArray(ids) || !ids.length) return '';
272
+ const parts = [];
273
+ for (const id of ids) {
274
+ const text = String(messageTexts.get(id) || '');
275
+ if (text.trim()) parts.push(text);
276
+ }
277
+ return parts.join('\n\n');
278
+ }
279
+
280
+ // ---- subagent lane --------------------------------------------------------
281
+ //
282
+ // Keyed on the envelope `agentId`, which a live BYOK probe confirmed is
283
+ // present on the lifecycle events AND on the tagged `assistant.message`
284
+ // carrying the reply (and is NOT equal to `data.toolCallId`). `toolCallId` is
285
+ // registered as an alias anyway so the lane still assembles if a future
286
+ // runtime tags only one of them.
287
+ const subagentRuns = new Map(); // runId -> { displayName, status, opened }
288
+ const subagentAliases = new Map(); // agentId|toolCallId -> runId
289
+
290
+ /**
291
+ * `useToolCallId` is true only for `subagent.*` lifecycle events, where
292
+ * `data.toolCallId` names the tool call that SPAWNED this subagent.
293
+ *
294
+ * On any other event `toolCallId` names a tool call the subagent is MAKING,
295
+ * which is a different thing entirely — aliasing it would mean that if a
296
+ * subagent ever spawned a nested one, the child's `subagent.started` (whose
297
+ * `toolCallId` is that same inner call) would resolve to the PARENT's run:
298
+ * the child's text would merge into the parent's lane and the child's
299
+ * `completed` would close the parent's bubble while it was still running.
300
+ */
301
+ function resolveSubagentRun(event, { useToolCallId = false } = {}) {
302
+ const { agentId, toolCallId } = subagentEventKeys(event);
303
+ const alias = useToolCallId ? toolCallId : null;
304
+ const runId = (agentId && subagentAliases.get(agentId))
305
+ || (alias && subagentAliases.get(alias))
306
+ || agentId
307
+ || alias
308
+ || null;
309
+ if (!runId) return null;
310
+ if (agentId) subagentAliases.set(agentId, runId);
311
+ if (alias) subagentAliases.set(alias, runId);
312
+ return runId;
313
+ }
314
+
315
+ /**
316
+ * Open a lane the moment anything references the run, even if
317
+ * `subagent.started` was missed or arrives out of order — a lane that only
318
+ * ever appears on a perfectly ordered event stream is a lane that silently
319
+ * vanishes on the day the stream is not perfectly ordered.
320
+ */
321
+ function ensureSubagentRun(runId, displayName) {
322
+ let run = subagentRuns.get(runId);
323
+ if (!run) {
324
+ run = { displayName: displayName || 'Subagent', status: 'running', opened: false };
325
+ subagentRuns.set(runId, run);
326
+ } else if (displayName && run.displayName === 'Subagent') {
327
+ run.displayName = displayName;
328
+ }
329
+ if (run.opened) return [];
330
+ run.opened = true;
331
+ return [{
332
+ channel: 'subagent',
333
+ payload: {
334
+ subagentRunId: runId,
335
+ // Copilot reports one flat level: `subagent.started` names the parent
336
+ // TOOL CALL, not a parent agent, so there is no id to nest under. Same
337
+ // choice the Cursor worker makes.
338
+ parentSubagentId: null,
339
+ displayName: run.displayName,
340
+ status: 'running',
341
+ },
342
+ }];
343
+ }
344
+
345
+ function closeSubagentRun(runId, status, displayName) {
346
+ const actions = ensureSubagentRun(runId, displayName);
347
+ const run = subagentRuns.get(runId);
348
+ if (run) run.status = status;
349
+ actions.push({
350
+ channel: 'subagent',
351
+ payload: {
352
+ subagentRunId: runId,
353
+ parentSubagentId: null,
354
+ displayName: run?.displayName || displayName || 'Subagent',
355
+ status,
356
+ },
357
+ });
358
+ return actions;
359
+ }
360
+
361
+ // The agent's explicit closing message, delivered via session.task_complete.
362
+ // In autopilot agent mode the model can finish with BOTH durable
363
+ // assistant.messages empty — the whole answer lives in the task summary
364
+ // (live burn-in, 2026-08-31: "echo CWD" produced 145 output tokens and an
365
+ // empty reply). The extension already treats task_complete({ summary }) as
366
+ // "the agent's explicit closing message — highest priority"
367
+ // (runtime/session-io.mjs); the same precedence applies here.
368
+ let taskCompleteSummary = '';
369
+
370
+ /** What the user should read: the task summary outranks composed text. */
371
+ function finalText() {
372
+ return taskCompleteSummary || composeText();
373
+ }
374
+
375
+ function composeText() {
376
+ if (!messageOrder.length) return '';
377
+ if (cachedPrefix === null) {
378
+ const parts = [];
379
+ for (let index = 0; index < messageOrder.length - 1; index += 1) {
380
+ const text = String(messageTexts.get(messageOrder[index]) || '');
381
+ if (text.trim()) parts.push(text);
382
+ }
383
+ cachedPrefix = parts.join('\n\n');
384
+ }
385
+ const newest = String(messageTexts.get(messageOrder[messageOrder.length - 1]) || '');
386
+ if (!newest.trim()) return cachedPrefix;
387
+ return cachedPrefix ? `${cachedPrefix}\n\n${newest}` : newest;
388
+ }
389
+
390
+ function streamActions() {
391
+ const text = composeText();
392
+ if (!shouldEmitStreamUpdate(text, lastEmittedStreamText)) return [];
393
+ lastEmittedStreamText = text;
394
+ return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
395
+ }
396
+
397
+ function thoughtAction(reasoningId, text, done) {
398
+ return {
399
+ channel: 'thought',
400
+ payload: {
401
+ // Namespaced so a reasoning id can never collide with another
402
+ // provider's ids in the relay's thought rows.
403
+ reasoningId: `copilot-thought-${sanitizeSubagentRunId(reasoningId) || 'main'}`,
404
+ text,
405
+ done,
406
+ subagentRunId: null,
407
+ },
408
+ };
409
+ }
410
+
411
+ function activityAction(text, subagentRunId = null) {
412
+ return { channel: 'activity', payload: { text, subagentRunId } };
413
+ }
414
+
415
+ /**
416
+ * Build the single terminal `result` action for this turn. Guarded so a
417
+ * `session.error` arriving after a `session.idle` (or a second error) cannot
418
+ * publish a second response for the same queue row.
419
+ */
420
+ function terminalAction({ isError = false, subtype = 'completed', errorMessage = null, errorData = null } = {}) {
421
+ if (terminalEmitted) return [];
422
+ terminalEmitted = true;
423
+ // A subagent still marked running when the interaction ends would render as
424
+ // a bubble spinning forever: the relay only reconciles open runs when the
425
+ // queue row FAILS, so a successful turn has to close its own. Cursor learnt
426
+ // the same lesson (`activeSubagentRuns` on handle recreation).
427
+ const strays = [];
428
+ for (const [runId, run] of subagentRuns) {
429
+ if (run.status !== 'running') continue;
430
+ run.status = 'failed';
431
+ strays.push({
432
+ channel: 'subagent',
433
+ payload: {
434
+ subagentRunId: runId,
435
+ parentSubagentId: null,
436
+ displayName: run.displayName,
437
+ status: 'failed',
438
+ },
439
+ });
440
+ }
441
+ return [...strays, {
442
+ channel: 'result',
443
+ payload: {
444
+ text: finalText(),
445
+ // Per-prompt texts, so an interaction that absorbed a steered message
446
+ // can answer each queue row with the reply that belongs to it. The
447
+ // task summary closes the LAST prompt — earlier segments keep their
448
+ // own streamed replies.
449
+ segmentTexts: segments.map((_, index) => (
450
+ taskCompleteSummary && index === segments.length - 1
451
+ ? taskCompleteSummary
452
+ : segmentText(index)
453
+ )),
454
+ promptCount: segments.length,
455
+ isError,
456
+ aborted,
457
+ subtype,
458
+ errorMessage,
459
+ // The raw payload travels with the result so the session process can
460
+ // run it through the shared terminal-error classifier without the
461
+ // normalizer needing to know about failure taxonomies.
462
+ errorData,
463
+ usage: usage.snapshot(),
464
+ contextUsage,
465
+ model: sessionModel,
466
+ interactionId,
467
+ },
468
+ }];
469
+ }
470
+
471
+ function normalize(event) {
472
+ if (!event || typeof event !== 'object') return [];
473
+ const type = String(event.type || '');
474
+ const data = event.data && typeof event.data === 'object' ? event.data : {};
475
+
476
+ // ---- subagent lifecycle (session-scoped, tagged with the subagent's id) --
477
+ switch (type) {
478
+ case 'subagent.started': {
479
+ const runId = resolveSubagentRun(event, { useToolCallId: true });
480
+ if (!runId) return [];
481
+ return ensureSubagentRun(runId, subagentDisplayName(data));
482
+ }
483
+ case 'subagent.configured':
484
+ case 'subagent.selected': {
485
+ // `subagent.configured` is UNDOCUMENTED — it appears in no published
486
+ // schema and a live probe found it between started and completed,
487
+ // carrying `agentId` but neither `toolCallId` nor a display name. It is
488
+ // handled rather than ignored so the lane still opens if it ever
489
+ // arrives first, and it must never clobber the roster's display name
490
+ // with its own absent one (hence the `||` in ensureSubagentRun).
491
+ const runId = resolveSubagentRun(event, { useToolCallId: true });
492
+ if (!runId) return [];
493
+ return ensureSubagentRun(runId, subagentDisplayName(data));
494
+ }
495
+ case 'subagent.completed': {
496
+ const runId = resolveSubagentRun(event, { useToolCallId: true });
497
+ if (!runId) return [];
498
+ const actions = closeSubagentRun(runId, 'completed', subagentDisplayName(data));
499
+ const stats = formatSubagentStats(data);
500
+ if (stats) actions.push(activityAction(truncate(`Finished · ${stats}`), runId));
501
+ return actions;
502
+ }
503
+ case 'subagent.failed': {
504
+ const runId = resolveSubagentRun(event, { useToolCallId: true });
505
+ if (!runId) return [];
506
+ const name = subagentDisplayName(data);
507
+ const reason = String(data.error || '').trim() || 'unknown error';
508
+ const actions = closeSubagentRun(runId, 'failed', name);
509
+ actions.push(activityAction(truncate(`Subagent failed: ${reason}`), runId));
510
+ // ALSO on the main thread. The parent agent recovers from a failed
511
+ // subagent silently — it just carries on — so a failure reported only
512
+ // inside a collapsed lane bubble is a failure the user never learns
513
+ // about. This is the line that makes it visible.
514
+ actions.push(activityAction(truncate(`Subagent failed (${name}): ${reason}`)));
515
+ return actions;
516
+ }
517
+ case 'subagent.deselected':
518
+ return [];
519
+ default:
520
+ break;
521
+ }
522
+
523
+ // ---- subagent-tagged events → the lane -----------------------------------
524
+ //
525
+ // The runtime forwards subagent streaming by default and the flag that
526
+ // suppresses it also breaks PARENT streaming (see `isSubagentEvent`), so
527
+ // these are routed here rather than suppressed at the source. Hosted
528
+ // subagents emit no deltas at all: the whole reply arrives as one tagged
529
+ // `assistant.message` (live-verified), which is why this publishes it as a
530
+ // completed stream rather than accumulating.
531
+ if (isSubagentEvent(event)) {
532
+ const runId = resolveSubagentRun(event);
533
+ if (!runId) return [];
534
+ if (type === 'assistant.message') {
535
+ const content = String(data.content || '');
536
+ if (!content.trim()) return [];
537
+ const actions = ensureSubagentRun(runId);
538
+ actions.push({
539
+ channel: 'stream',
540
+ payload: { text: content, done: true, subagentRunId: runId },
541
+ });
542
+ return actions;
543
+ }
544
+ if (type === 'tool.execution_start') {
545
+ const toolName = String(data.toolName || '').trim() || 'tool';
546
+ const actions = ensureSubagentRun(runId);
547
+ actions.push(activityAction(formatToolActivityText(toolName, data.arguments), runId));
548
+ return actions;
549
+ }
550
+ if (type === 'assistant.usage') {
551
+ // Counted toward the turn's spend — a subagent's model call costs real
552
+ // money — but it must not set the turn's REPORTED model, which is the
553
+ // main thread's. Subagents can run a different model entirely
554
+ // (`task`'s `model` override).
555
+ usage.add(data, { allowModel: false });
556
+ return [];
557
+ }
558
+ // Everything else the subagent emits (deltas, turn_start/end, reasoning,
559
+ // its own user/system messages) is deliberately dropped: it would
560
+ // otherwise merge into the main reply or the main thinking bubble.
561
+ return [];
562
+ }
563
+
564
+ switch (type) {
565
+ // ---- prompt boundary --------------------------------------------------
566
+ case 'user.message': {
567
+ // The runtime has started work on a prompt. Opens a new segment so a
568
+ // message that was steered into this interaction gets its own reply
569
+ // text. Deliberately NOT gated on segment emptiness: two prompts in a
570
+ // row with no assistant output between them are still two prompts.
571
+ segments.push([]);
572
+ cachedPrefix = null;
573
+ return [];
574
+ }
575
+ // ---- session identity -------------------------------------------------
576
+ case 'session.start':
577
+ case 'session.resume': {
578
+ sessionId = String(data.sessionId || sessionId || '').trim();
579
+ const model = String(data.selectedModel || '').trim();
580
+ if (model) sessionModel = model;
581
+ return [{
582
+ channel: 'init',
583
+ payload: {
584
+ sessionId,
585
+ model: sessionModel,
586
+ resumed: type === 'session.resume',
587
+ eventCount: Number(data.eventCount) || 0,
588
+ },
589
+ }];
590
+ }
591
+ case 'session.model_change': {
592
+ const model = String(data.newModel || '').trim();
593
+ if (model) sessionModel = model;
594
+ return [{ channel: 'init', payload: { sessionId, model: sessionModel, resumed: false, eventCount: 0 } }];
595
+ }
596
+
597
+ // ---- assistant text ---------------------------------------------------
598
+ case 'assistant.message_start': {
599
+ noteMessage(data.messageId);
600
+ return [];
601
+ }
602
+ case 'assistant.message_delta': {
603
+ const id = noteMessage(data.messageId);
604
+ if (!id) return [];
605
+ messageTexts.set(id, messageTexts.get(id) + String(data.deltaContent || ''));
606
+ if (id !== messageOrder[messageOrder.length - 1]) cachedPrefix = null;
607
+ return streamActions();
608
+ }
609
+ case 'assistant.message': {
610
+ const id = noteMessage(data.messageId);
611
+ const model = String(data.model || '').trim();
612
+ if (model) sessionModel = model;
613
+ interactionId = String(data.interactionId || interactionId || '').trim();
614
+ // The durable message is authoritative over whatever the deltas
615
+ // accumulated — but ONLY when it actually carries content. The
616
+ // tool-request message is documented to arrive with empty content, and
617
+ // letting that overwrite the accumulated deltas would blank an answer
618
+ // the user already watched stream in.
619
+ const content = String(data.content || '');
620
+ if (id && content) {
621
+ messageTexts.set(id, content);
622
+ if (id !== messageOrder[messageOrder.length - 1]) cachedPrefix = null;
623
+ }
624
+ return streamActions();
625
+ }
626
+
627
+ // ---- reasoning --------------------------------------------------------
628
+ case 'assistant.reasoning_delta': {
629
+ const id = String(data.reasoningId || '').trim();
630
+ if (!id) return [];
631
+ const next = capThought((openThoughts.get(id) || '') + String(data.deltaContent || ''));
632
+ openThoughts.set(id, next);
633
+ // Same gating as the stream channel, per reasoning block. Without it a
634
+ // long thought publishes a POST per token, and once the 16 KiB cap is
635
+ // reached every further delta would re-publish identical text forever.
636
+ // It also drops the empty-content case: hosted models return encrypted
637
+ // reasoning, whose events carry no text at all.
638
+ if (!shouldEmitStreamUpdate(next, emittedThoughts.get(id) || '')) return [];
639
+ emittedThoughts.set(id, next);
640
+ return [thoughtAction(id, next, false)];
641
+ }
642
+ case 'assistant.reasoning': {
643
+ const id = String(data.reasoningId || '').trim();
644
+ if (!id) return [];
645
+ const text = capThought(String(data.content || openThoughts.get(id) || ''));
646
+ openThoughts.delete(id);
647
+ emittedThoughts.delete(id);
648
+ // Hosted models emit this ephemerally with EMPTY content (the
649
+ // reasoning is encrypted; only `reasoningTokens` survives), so an
650
+ // empty thought must close nothing rather than publish a blank bubble.
651
+ if (!text.trim()) return [];
652
+ return [thoughtAction(id, text, true)];
653
+ }
654
+
655
+ // ---- turn lifecycle ---------------------------------------------------
656
+ case 'assistant.turn_start': {
657
+ interactionId = String(data.interactionId || interactionId || '').trim();
658
+ return [];
659
+ }
660
+ case 'assistant.turn_end':
661
+ // Deliberately inert: one per model call, not one per user request.
662
+ return [];
663
+ case 'assistant.usage': {
664
+ usage.add(data);
665
+ return [];
666
+ }
667
+ case 'session.usage_info': {
668
+ const tokenLimit = Number(data.tokenLimit);
669
+ const currentTokens = Number(data.currentTokens);
670
+ if (!Number.isFinite(tokenLimit) || !Number.isFinite(currentTokens)) return [];
671
+ contextUsage = {
672
+ tokenLimit,
673
+ currentTokens,
674
+ systemTokens: Number(data.systemTokens) || 0,
675
+ conversationTokens: Number(data.conversationTokens) || 0,
676
+ toolDefinitionsTokens: Number(data.toolDefinitionsTokens) || 0,
677
+ };
678
+ return [];
679
+ }
680
+
681
+ // ---- tools ------------------------------------------------------------
682
+ case 'tool.execution_start': {
683
+ const toolCallId = String(data.toolCallId || '').trim();
684
+ const toolName = String(data.toolName || '').trim() || 'tool';
685
+ if (toolCallId) toolNames.set(toolCallId, toolName);
686
+ return [activityAction(formatToolActivityText(toolName, data.arguments))];
687
+ }
688
+ case 'tool.execution_complete': {
689
+ // There is no `tool.*_failed` event type — failure is `success: false`
690
+ // with `error` in place of `result`.
691
+ if (data.success !== false) return [];
692
+ const toolCallId = String(data.toolCallId || '').trim();
693
+ const toolName = toolNames.get(toolCallId) || 'tool';
694
+ const reason = String(data.error?.message || data.error?.code || 'unknown error').trim();
695
+ return [activityAction(truncate(`Tool failed (${toolName}): ${reason}`))];
696
+ }
697
+
698
+ // ---- permission / questions ------------------------------------------
699
+ case 'permission.requested': {
700
+ const requestId = String(data.requestId || '').trim();
701
+ const request = data.permissionRequest || data.promptRequest || {};
702
+ const summary = String(request.fullCommandText || request.intention || request.kind || '').trim();
703
+ if (requestId) permissionRequests.set(requestId, summary);
704
+ return [];
705
+ }
706
+ case 'permission.completed': {
707
+ const requestId = String(data.requestId || '').trim();
708
+ const summary = permissionRequests.get(requestId) || '';
709
+ permissionRequests.delete(requestId);
710
+ const kind = String(data.result?.kind || 'unknown').trim();
711
+ return [activityAction(truncate(summary
712
+ ? `Permission ${kind}: ${summary}`
713
+ : `Permission ${kind}`))];
714
+ }
715
+ case 'user_input.requested': {
716
+ // The question is forwarded to the relay as a question card; this row
717
+ // records it in the transcript so the conversation still reads as a
718
+ // conversation once the card has been answered and dismissed.
719
+ const question = String(data.question || '').trim();
720
+ return [activityAction(truncate(
721
+ `Copilot asked: ${question || '(no question text)'}`,
722
+ ))];
723
+ }
724
+
725
+ // ---- failure / abort --------------------------------------------------
726
+ case 'abort': {
727
+ aborted = true;
728
+ return [];
729
+ }
730
+ case 'model.call_failure': {
731
+ // Carries the quota snapshots the usage card wants; the terminal
732
+ // classification itself rides `session.error`.
733
+ usage.setQuotaSnapshots(data.quotaSnapshots);
734
+ return [];
735
+ }
736
+ case 'session.error': {
737
+ return terminalAction({
738
+ isError: true,
739
+ subtype: String(data.errorType || data.errorCode || 'session_error').trim() || 'session_error',
740
+ errorMessage: String(data.message || '').trim() || null,
741
+ errorData: data,
742
+ });
743
+ }
744
+ // ---- explicit closing message ----------------------------------------
745
+ case 'session.task_complete': {
746
+ // Root-agent only by construction: an agentId-tagged task_complete
747
+ // (a subagent finishing its own autopilot loop) was already consumed
748
+ // by the subagent block above and never reaches this switch.
749
+ const summary = String(data.summary || '').trim();
750
+ if (!summary) return [];
751
+ taskCompleteSummary = summary;
752
+ // Stream it so the reply is visible before the terminal result lands —
753
+ // in the empty-assistant-text case nothing has streamed yet.
754
+ const text = finalText();
755
+ if (!shouldEmitStreamUpdate(text, lastEmittedStreamText)) return [];
756
+ lastEmittedStreamText = text;
757
+ return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
758
+ }
759
+ // ---- the one true terminator -----------------------------------------
760
+ case 'session.idle': {
761
+ if (data.aborted === true) aborted = true;
762
+ return terminalAction({ subtype: aborted ? 'aborted' : 'completed' });
763
+ }
764
+
765
+ default:
766
+ // Everything else is deliberately ignored, silently — the event set
767
+ // grows between CLI releases (the live probe found an undocumented
768
+ // `subagent.configured` that is in no published schema), so an unknown
769
+ // type is normal and must never warn or throw. The high-volume
770
+ // offenders: `assistant.streaming_delta` is a byte counter (not
771
+ // content), `pending_messages.modified` and
772
+ // `session.background_tasks_changed` always carry an empty `data` (23
773
+ // of the latter fire during a single bash call), and the whole
774
+ // `model.*` family duplicates information already taken from the
775
+ // `assistant.*` events. `session.shutdown` is NOT treated as a
776
+ // failure — the resume fixture shows it arriving from a graceful
777
+ // disconnect immediately before a perfectly healthy turn. A runtime
778
+ // that dies under a live turn is detected from the client instead
779
+ // (`observeRuntimeExit`).
780
+ return [];
781
+ }
782
+ }
783
+
784
+ return {
785
+ normalize,
786
+ finalStreamText: finalText,
787
+ segmentText,
788
+ promptCount: () => segments.length,
789
+ /**
790
+ * Runs still marked `running`. The turn runner force-closes these when a
791
+ * turn dies without reaching its terminator (abort, runtime exit, thrown
792
+ * exception) — those paths never build a `result`, so the stray-closing in
793
+ * `terminalAction` cannot run and the bubbles would spin forever.
794
+ */
795
+ activeSubagentRuns: () => [...subagentRuns.entries()]
796
+ .filter(([, run]) => run.status === 'running')
797
+ .map(([subagentRunId, run]) => ({ subagentRunId, displayName: run.displayName })),
798
+ get model() { return sessionModel; },
799
+ };
800
+ }