@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,59 @@
1
+ // Mirrors shared/auto-compact-window.mjs, which the browser cannot import
2
+ // (only server/public is served). shared/auto-compact-window.test.mjs asserts
3
+ // the two stop lists stay identical.
4
+ //
5
+ // The value is a TOKEN COUNT, not a percent: it is the amount of context the
6
+ // Claude CLI lets a conversation reach before compacting. null = "Auto", the
7
+ // CLI's model-tuned default.
8
+
9
+ export const AUTO_COMPACT_WINDOW_STOPS = Object.freeze([
10
+ null,
11
+ 100000,
12
+ 150000,
13
+ 200000,
14
+ 300000,
15
+ 500000,
16
+ 1000000,
17
+ ]);
18
+
19
+ export function parseAutoCompactWindow(value) {
20
+ if (value === null || value === undefined) return null;
21
+ if (typeof value === 'string') {
22
+ const text = value.trim().toLowerCase();
23
+ if (!text || text === 'auto' || text === 'null') return null;
24
+ }
25
+ const numeric = Number(value);
26
+ if (!Number.isFinite(numeric) || numeric <= 0) return null;
27
+ let best = null;
28
+ let bestDistance = Infinity;
29
+ for (const stop of AUTO_COMPACT_WINDOW_STOPS) {
30
+ if (stop === null) continue;
31
+ const distance = Math.abs(numeric - stop);
32
+ if (distance < bestDistance) {
33
+ best = stop;
34
+ bestDistance = distance;
35
+ }
36
+ }
37
+ return best;
38
+ }
39
+
40
+ export function formatAutoCompactWindowLabel(value) {
41
+ const window = parseAutoCompactWindow(value);
42
+ if (window === null) return 'Auto';
43
+ if (window >= 1000000) {
44
+ const millions = window / 1000000;
45
+ return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
46
+ }
47
+ return `${Math.round(window / 1000)}k`;
48
+ }
49
+
50
+ export function autoCompactWindowToIndex(value) {
51
+ const index = AUTO_COMPACT_WINDOW_STOPS.indexOf(parseAutoCompactWindow(value));
52
+ return index >= 0 ? index : 0;
53
+ }
54
+
55
+ export function autoCompactWindowFromIndex(index) {
56
+ const i = Math.round(Number(index));
57
+ if (!Number.isFinite(i) || i < 0 || i >= AUTO_COMPACT_WINDOW_STOPS.length) return null;
58
+ return AUTO_COMPACT_WINDOW_STOPS[i];
59
+ }
@@ -0,0 +1,494 @@
1
+ // Composer background-tasks panel: the live set of tasks (backgrounded Bash,
2
+ // subagents, monitors, workflows) still running inside a conversation's
3
+ // persistent Claude CLI process. Fed by the `background_tasks` socket event
4
+ // (REPLACE semantics per conversation) and the conversation payload's
5
+ // `backgroundTasks` on reload; hidden whenever the set is empty.
6
+
7
+ import { apiFetch } from './api-client.js';
8
+ import {
9
+ getPreviewsForConversation,
10
+ renderPreviewRowsInto,
11
+ subscribePreviews,
12
+ } from './preview-cards.mjs';
13
+
14
+ const tasksByConversation = new Map();
15
+ const stopsInFlight = new Set();
16
+ // Manual chevron choices for workflow progress trees, keyed by taskId so a
17
+ // user's fold survives the ~2s background_tasks replace re-renders.
18
+ const workflowFoldState = new Map();
19
+ let currentConversationId = null;
20
+ let elapsedTimer = null;
21
+
22
+ const TASK_TYPE_ICONS = {
23
+ local_bash: '⌨️',
24
+ local_agent: '🤖',
25
+ agent: '🤖',
26
+ subagent: '🤖',
27
+ local_workflow: '🧩',
28
+ monitor: '👁️',
29
+ };
30
+
31
+ const TASK_TYPE_LABELS = {
32
+ local_bash: 'Bash',
33
+ local_agent: 'Subagent',
34
+ agent: 'Subagent',
35
+ subagent: 'Subagent',
36
+ local_workflow: 'Workflow',
37
+ monitor: 'Monitor',
38
+ };
39
+
40
+ // Workflow digest agent states → compact tree icons.
41
+ const WORKFLOW_AGENT_STATE_ICONS = {
42
+ queued: '○',
43
+ running: '◐',
44
+ done: '✓',
45
+ failed: '✗',
46
+ killed: '✗',
47
+ };
48
+
49
+ // Digest statuses that mean the workflow is still doing work: the tree
50
+ // defaults to expanded while one of these is live (the "what is it doing"
51
+ // view), collapsed once the run settles.
52
+ const WORKFLOW_ACTIVE_STATUSES = new Set(['queued', 'pending', 'starting', 'running', 'in_progress']);
53
+
54
+ // Persisted-run statuses that still read "in flight": when the CLI never
55
+ // delivers the terminal task_notification, the buffered digest keeps its last
56
+ // live status. The card header says "Finished" — rendering these raw would
57
+ // contradict it, so the label owns up to not knowing the outcome instead.
58
+ const WORKFLOW_RUN_UNCONFIRMED_STATUSES = new Set(['running', 'pending', 'queued']);
59
+
60
+ function taskIcon(taskType) {
61
+ return TASK_TYPE_ICONS[String(taskType || '').trim()] || '⚙️';
62
+ }
63
+
64
+ // Badge text: the specific agent type when the worker knows it ("Explore",
65
+ // "code-reviewer"), the generic kind otherwise.
66
+ function taskKindLabel(task) {
67
+ const label = TASK_TYPE_LABELS[String(task?.taskType || '').trim()] || 'Task';
68
+ const subagentType = String(task?.subagentType || '').trim();
69
+ return label === 'Subagent' && subagentType ? subagentType : label;
70
+ }
71
+
72
+ function formatTaskModel(task) {
73
+ const model = String(task?.model || '').trim();
74
+ if (!model) return '';
75
+ const short = model.replace(/^claude-/, '');
76
+ return task?.modelInherited === true ? `${short} (inherited)` : short;
77
+ }
78
+
79
+ // Compact token counts: "812 tok", "62.2k tok", "1.2M tok". Exported for the
80
+ // transcript's finished-workflow card, which shares the panel's formatting.
81
+ export function formatTaskTokens(totalTokens) {
82
+ const tokens = Number(totalTokens);
83
+ if (!Number.isFinite(tokens) || tokens <= 0) return '';
84
+ if (tokens < 1000) return `${tokens} tok`;
85
+ if (tokens < 1_000_000) {
86
+ const thousands = tokens / 1000;
87
+ return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1)}k tok`;
88
+ }
89
+ const millions = tokens / 1_000_000;
90
+ return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1)}M tok`;
91
+ }
92
+
93
+ function escHtml(value) {
94
+ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
95
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
96
+ }[ch]));
97
+ }
98
+
99
+ function formatElapsed(startedAt) {
100
+ const started = Number(startedAt);
101
+ if (!Number.isFinite(started) || started <= 0) return '';
102
+ const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
103
+ if (seconds < 60) return `${seconds}s`;
104
+ const minutes = Math.floor(seconds / 60);
105
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
106
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
107
+ }
108
+
109
+ // Compact "4m20s" duration for workflow agents (distinct from formatElapsed,
110
+ // which renders a live "4m 20s" wall clock from a start timestamp).
111
+ function formatWorkflowAgentDuration(durationMs) {
112
+ const ms = Number(durationMs);
113
+ if (!Number.isFinite(ms) || ms <= 0) return '';
114
+ const seconds = Math.max(1, Math.round(ms / 1000));
115
+ if (seconds < 60) return `${seconds}s`;
116
+ const minutes = Math.floor(seconds / 60);
117
+ if (minutes < 60) return `${minutes}m${seconds % 60}s`;
118
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
119
+ }
120
+
121
+ function workflowTreeAgents(task) {
122
+ const agents = task?.workflowProgress?.agents;
123
+ if (!Array.isArray(agents)) return [];
124
+ return agents.filter((agent) => agent && typeof agent === 'object');
125
+ }
126
+
127
+ function isWorkflowTreeExpanded(task) {
128
+ const taskId = String(task?.taskId || '');
129
+ if (workflowFoldState.has(taskId)) return workflowFoldState.get(taskId) === true;
130
+ const status = String(task?.workflowProgress?.status || '').trim().toLowerCase();
131
+ return WORKFLOW_ACTIVE_STATUSES.has(status);
132
+ }
133
+
134
+ // Chevron toggle. Stores the user's explicit choice so it wins over the
135
+ // running/settled default on every subsequent re-render.
136
+ export function toggleWorkflowTreeFold(taskId) {
137
+ const id = String(taskId || '').trim();
138
+ if (!id) return;
139
+ const tasks = currentConversationId ? (tasksByConversation.get(currentConversationId) || []) : [];
140
+ const task = tasks.find((entry) => String(entry.taskId) === id) || { taskId: id };
141
+ workflowFoldState.set(id, !isWorkflowTreeExpanded(task));
142
+ renderBackgroundTasksPanel();
143
+ }
144
+
145
+ function agentBelongsToPhase(agent, phase) {
146
+ const phaseIndex = Number(phase.index);
147
+ if (Number.isFinite(phaseIndex) && Number(agent.phaseIndex) === phaseIndex) return true;
148
+ const title = String(phase.title ?? '').trim();
149
+ return !!title && String(agent.phaseTitle ?? '').trim() === title;
150
+ }
151
+
152
+ // One agent row, two lines: the state icon beside the 2-line-clamped label,
153
+ // then a muted metrics line "model · 62.2k tok · 27 tools · 4m20s" with
154
+ // absent fields simply omitted — the metrics never compete with the label
155
+ // for width, so the token count stays readable on a phone. A running agent's
156
+ // current tool rides the metrics line as its last, muted segment (never the
157
+ // clamped label line, where a long label would clamp the live activity away).
158
+ function buildWorkflowAgentRow(agent) {
159
+ const state = String(agent.state ?? '').trim().toLowerCase();
160
+ const row = document.createElement('div');
161
+ row.className = 'bg-task-tree-agent';
162
+
163
+ const stateEl = document.createElement('span');
164
+ stateEl.className = state === 'running'
165
+ ? 'bg-task-agent-state bg-task-agent-state-running'
166
+ : 'bg-task-agent-state';
167
+ stateEl.textContent = WORKFLOW_AGENT_STATE_ICONS[state] || '·';
168
+ row.appendChild(stateEl);
169
+
170
+ const mainEl = document.createElement('span');
171
+ mainEl.className = 'bg-task-agent-main';
172
+ row.appendChild(mainEl);
173
+
174
+ const labelEl = document.createElement('span');
175
+ labelEl.className = 'bg-task-agent-label';
176
+ labelEl.textContent = String(agent.label ?? '').trim();
177
+ mainEl.appendChild(labelEl);
178
+
179
+ const lastToolName = String(agent.lastToolName ?? '').trim();
180
+ const activityText = state === 'running' && lastToolName ? `— using ${lastToolName}` : '';
181
+
182
+ const meta = [];
183
+ const model = String(agent.model ?? '').trim().replace(/^claude-/, '');
184
+ if (model) meta.push(model);
185
+ const tokens = formatTaskTokens(agent.tokens);
186
+ if (tokens) meta.push(tokens);
187
+ const toolCalls = Number(agent.toolCalls);
188
+ if (Number.isFinite(toolCalls) && toolCalls > 0) {
189
+ meta.push(`${toolCalls} tool${toolCalls === 1 ? '' : 's'}`);
190
+ }
191
+ const duration = formatWorkflowAgentDuration(agent.durationMs);
192
+ if (duration) meta.push(duration);
193
+ const attempt = Number(agent.attempt);
194
+ if (Number.isFinite(attempt) && attempt > 1) meta.push(`attempt ${attempt}`);
195
+ if (meta.length || activityText) {
196
+ const metaEl = document.createElement('span');
197
+ metaEl.className = 'bg-task-agent-meta';
198
+ metaEl.textContent = meta.length && activityText ? `${meta.join(' · ')} ` : meta.join(' · ');
199
+ if (activityText) {
200
+ const activityEl = document.createElement('span');
201
+ activityEl.className = 'bg-task-agent-activity';
202
+ activityEl.textContent = activityText;
203
+ metaEl.appendChild(activityEl);
204
+ }
205
+ mainEl.appendChild(metaEl);
206
+ }
207
+ return row;
208
+ }
209
+
210
+ // Fills a workflow row's tree container: narrator log lines first, then agents
211
+ // grouped under their phase headers (ungrouped agents last, no header), then
212
+ // the "+N more agents" overflow line. Built with createElement/textContent so
213
+ // worker-supplied strings can never execute as markup.
214
+ export function renderWorkflowTreeInto(container, task) {
215
+ if (!container) return;
216
+ const digest = task?.workflowProgress;
217
+ const agents = workflowTreeAgents(task);
218
+ if (!digest || !agents.length) return;
219
+ container.textContent = '';
220
+
221
+ for (const line of (Array.isArray(digest.logs) ? digest.logs : []).slice(-5)) {
222
+ const text = String(line ?? '').trim();
223
+ if (!text) continue;
224
+ const logEl = document.createElement('div');
225
+ logEl.className = 'bg-task-tree-log';
226
+ logEl.textContent = text;
227
+ container.appendChild(logEl);
228
+ }
229
+
230
+ const phases = (Array.isArray(digest.phases) ? digest.phases : [])
231
+ .filter((phase) => phase && typeof phase === 'object');
232
+ const grouped = new Set();
233
+ for (const phase of phases) {
234
+ const members = agents.filter((agent) => !grouped.has(agent) && agentBelongsToPhase(agent, phase));
235
+ if (!members.length) continue;
236
+ const headerEl = document.createElement('div');
237
+ headerEl.className = 'bg-task-tree-phase';
238
+ headerEl.textContent = String(phase.title ?? '');
239
+ container.appendChild(headerEl);
240
+ for (const agent of members) {
241
+ grouped.add(agent);
242
+ container.appendChild(buildWorkflowAgentRow(agent));
243
+ }
244
+ }
245
+ for (const agent of agents) {
246
+ if (!grouped.has(agent)) container.appendChild(buildWorkflowAgentRow(agent));
247
+ }
248
+
249
+ const omitted = Number(digest.agentsOmitted);
250
+ if (Number.isFinite(omitted) && omitted > 0) {
251
+ const omittedEl = document.createElement('div');
252
+ omittedEl.className = 'bg-task-tree-omitted';
253
+ omittedEl.textContent = `+${omitted} more agent${omitted === 1 ? '' : 's'}`;
254
+ container.appendChild(omittedEl);
255
+ }
256
+ }
257
+
258
+ // Whole-run duration for the finished-workflow card header ("28m" rather than
259
+ // the per-agent "4m20s" — the card summarizes, the tree details).
260
+ function formatWorkflowRunDuration(durationMs) {
261
+ const ms = Number(durationMs);
262
+ if (!Number.isFinite(ms) || ms <= 0) return '';
263
+ const totalMinutes = Math.round(ms / 60_000);
264
+ if (totalMinutes < 1) return `${Math.max(1, Math.round(ms / 1000))}s`;
265
+ if (totalMinutes < 60) return `${totalMinutes}m`;
266
+ return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
267
+ }
268
+
269
+ /**
270
+ * Header line for a persisted workflow digest:
271
+ * `🧩 Finished background task — review-shared-modules · 20 agents · 1.2M tok
272
+ * · 28m`, with absent fields omitted and a non-completed outcome surfaced as
273
+ * e.g. `🧩 Finished background task (failed) — …`. A digest persisted with a
274
+ * still-running-ish status renders `(unconfirmed)` rather than the
275
+ * self-contradicting `Finished … (running)`.
276
+ */
277
+ export function workflowRunCardTitle(run) {
278
+ const rawStatus = String(run?.status || '').trim().toLowerCase();
279
+ const status = WORKFLOW_RUN_UNCONFIRMED_STATUSES.has(rawStatus) ? 'unconfirmed' : rawStatus;
280
+ const statusNote = status && status !== 'completed' ? ` (${status})` : '';
281
+ const parts = [];
282
+ const workflowName = String(run?.workflowName || '').trim();
283
+ if (workflowName) parts.push(workflowName);
284
+ const agentCount = Number(run?.agentCount);
285
+ if (Number.isFinite(agentCount) && agentCount > 0) {
286
+ parts.push(`${agentCount} agent${agentCount === 1 ? '' : 's'}`);
287
+ }
288
+ const tokens = formatTaskTokens(run?.totalTokens);
289
+ if (tokens) parts.push(tokens);
290
+ const duration = formatWorkflowRunDuration(run?.durationMs);
291
+ if (duration) parts.push(duration);
292
+ return `🧩 Finished background task${statusNote}${parts.length ? ` — ${parts.join(' · ')}` : ''}`;
293
+ }
294
+
295
+ /**
296
+ * The transcript's "Finished background task" card for one persisted digest:
297
+ * a native `<details>` (collapsed by default — clicking the summary is the
298
+ * fold toggle, no listener needed) whose body is the SAME tree the live panel
299
+ * renders. Built entirely with createElement/textContent, so digest strings
300
+ * can never execute as markup. Returns null for a non-object run.
301
+ */
302
+ export function buildWorkflowRunCard(run) {
303
+ if (!run || typeof run !== 'object') return null;
304
+ const details = document.createElement('details');
305
+ details.className = 'msg-activity msg-workflow-run';
306
+ const summary = document.createElement('summary');
307
+ summary.textContent = workflowRunCardTitle(run);
308
+ details.appendChild(summary);
309
+ const tree = document.createElement('div');
310
+ tree.className = 'bg-task-tree msg-workflow-run-tree';
311
+ renderWorkflowTreeInto(tree, { workflowProgress: run });
312
+ details.appendChild(tree);
313
+ return details;
314
+ }
315
+
316
+ export function setConversationBackgroundTasks(conversationId, tasks) {
317
+ const id = String(conversationId || '').trim();
318
+ if (!id) return;
319
+ const normalized = (Array.isArray(tasks) ? tasks : []).filter((task) => task?.taskId);
320
+ // Drop fold choices for tasks that left this conversation's set so the map
321
+ // cannot grow across long sessions.
322
+ const surviving = new Set(normalized.map((task) => String(task.taskId)));
323
+ for (const previous of tasksByConversation.get(id) || []) {
324
+ if (!surviving.has(String(previous.taskId))) workflowFoldState.delete(String(previous.taskId));
325
+ }
326
+ if (normalized.length) tasksByConversation.set(id, normalized);
327
+ else tasksByConversation.delete(id);
328
+ if (id === currentConversationId) renderBackgroundTasksPanel();
329
+ }
330
+
331
+ export function getConversationBackgroundTasks(conversationId) {
332
+ return tasksByConversation.get(String(conversationId || '').trim()) || [];
333
+ }
334
+
335
+ export function setBackgroundTasksConversation(conversationId) {
336
+ currentConversationId = String(conversationId || '').trim() || null;
337
+ renderBackgroundTasksPanel();
338
+ }
339
+
340
+ async function stopBackgroundTask(conversationId, taskId) {
341
+ const key = `${conversationId}:${taskId}`;
342
+ if (stopsInFlight.has(key)) return;
343
+ stopsInFlight.add(key);
344
+ renderBackgroundTasksPanel();
345
+ try {
346
+ await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/background-task/${encodeURIComponent(taskId)}/stop`, {
347
+ method: 'POST',
348
+ body: JSON.stringify({}),
349
+ });
350
+ // The worker's next background_tasks_changed replaces the set; keep the
351
+ // button in its stopping state until that lands (or 15s pass).
352
+ setTimeout(() => {
353
+ stopsInFlight.delete(key);
354
+ renderBackgroundTasksPanel();
355
+ }, 15_000);
356
+ } catch (error) {
357
+ stopsInFlight.delete(key);
358
+ renderBackgroundTasksPanel();
359
+ console.warn('background task stop failed', error?.message || error);
360
+ }
361
+ }
362
+
363
+ function renderTaskRow(conversationId, task) {
364
+ const key = `${conversationId}:${task.taskId}`;
365
+ const stopping = stopsInFlight.has(key);
366
+ const elapsed = formatElapsed(task.startedAt);
367
+ // Second line: progress first ("using Bash" prefixed so a bare tool name
368
+ // can't read as the task's kind), then model. Token usage renders as its
369
+ // own always-visible element on the row's right side instead of riding
370
+ // this clampable line, so a long summary can never crowd it out.
371
+ const summary = String(task.summary || '').trim();
372
+ const lastToolName = String(task.lastToolName || '').trim();
373
+ const detail = [
374
+ summary || (lastToolName ? `using ${lastToolName}` : ''),
375
+ formatTaskModel(task),
376
+ ].filter(Boolean).join(' · ');
377
+ const tokens = formatTaskTokens(task.totalTokens);
378
+ // Workflow rows with a digest get a fold chevron plus an (initially empty)
379
+ // tree container right below the row; renderBackgroundTasksPanel fills the
380
+ // container with DOM nodes after this markup lands, so digest text never
381
+ // passes through innerHTML.
382
+ const hasTree = workflowTreeAgents(task).length > 0;
383
+ const treeExpanded = hasTree && isWorkflowTreeExpanded(task);
384
+ const foldButton = hasTree
385
+ ? `<button type="button" class="bg-task-fold" data-task-id="${escHtml(task.taskId)}" aria-expanded="${treeExpanded ? 'true' : 'false'}" title="${treeExpanded ? 'Hide' : 'Show'} workflow progress">${treeExpanded ? '▾' : '▸'}</button>`
386
+ : '';
387
+ const treeHolder = hasTree
388
+ ? `<div class="bg-task-tree" data-task-id="${escHtml(task.taskId)}"${treeExpanded ? '' : ' hidden'}></div>`
389
+ : '';
390
+ return `
391
+ <div class="bg-task-row" data-task-id="${escHtml(task.taskId)}">
392
+ ${foldButton}<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
393
+ <span class="bg-task-main">
394
+ <span class="bg-task-desc">${escHtml(task.description || task.taskId)}</span>
395
+ ${detail ? `<span class="bg-task-detail">${escHtml(detail)}</span>` : ''}
396
+ </span>
397
+ <span class="bg-task-side">
398
+ <span class="bg-task-badge">${escHtml(taskKindLabel(task))}</span>
399
+ <span class="bg-task-side-status">
400
+ ${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
401
+ <span class="bg-task-elapsed">${escHtml(elapsed)}</span>
402
+ <button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>
403
+ </span>
404
+ </span>
405
+ </div>${treeHolder}`;
406
+ }
407
+
408
+ // "2 background tasks running", "1 preview", or both — the panel is shared, so
409
+ // the summary has to name whatever is actually in it.
410
+ function panelSummaryText(taskCount, previewCount) {
411
+ const parts = [];
412
+ if (taskCount) parts.push(`${taskCount} background task${taskCount === 1 ? '' : 's'} running`);
413
+ if (previewCount) parts.push(`${previewCount} preview${previewCount === 1 ? '' : 's'}`);
414
+ return parts.join(' · ');
415
+ }
416
+
417
+ export function renderBackgroundTasksPanel() {
418
+ const panel = document.getElementById('background-tasks-panel');
419
+ if (!panel) return;
420
+ const conversationId = currentConversationId;
421
+ const tasks = conversationId ? (tasksByConversation.get(conversationId) || []) : [];
422
+ const previews = conversationId ? getPreviewsForConversation(conversationId) : [];
423
+ // Previews outlive the task that started the dev server, so the panel stays
424
+ // up for them even once every background task has finished.
425
+ if (!tasks.length && !previews.length) {
426
+ panel.hidden = true;
427
+ panel.removeAttribute('open');
428
+ stopElapsedTimer();
429
+ return;
430
+ }
431
+ const wasOpen = panel.hasAttribute('open');
432
+ panel.hidden = false;
433
+ const summary = panel.querySelector('#background-tasks-summary');
434
+ if (summary) {
435
+ const spinner = tasks.length ? '<span class="bg-task-spinner"></span> ' : '';
436
+ summary.innerHTML = `${spinner}${escHtml(panelSummaryText(tasks.length, previews.length))}`;
437
+ }
438
+ const previewList = panel.querySelector('#background-previews-list');
439
+ if (previewList) {
440
+ // Previews sit above the task list: the link is the thing you came to the
441
+ // panel for, and it should not drift down as tasks come and go.
442
+ renderPreviewRowsInto(previewList, previews);
443
+ }
444
+ const list = panel.querySelector('#background-tasks-list');
445
+ if (list) {
446
+ list.innerHTML = tasks.map((task) => renderTaskRow(conversationId, task)).join('');
447
+ for (const button of list.querySelectorAll('.bg-task-stop')) {
448
+ button.addEventListener('click', (event) => {
449
+ event.preventDefault();
450
+ void stopBackgroundTask(conversationId, button.dataset.taskId);
451
+ });
452
+ }
453
+ // Fill expanded workflow trees (collapsed holders stay empty — the toggle
454
+ // re-renders, so they get content the moment they open).
455
+ for (const holder of list.querySelectorAll('.bg-task-tree')) {
456
+ if (holder.hasAttribute('hidden')) continue;
457
+ const holderTaskId = holder.getAttribute('data-task-id');
458
+ const task = tasks.find((entry) => String(entry.taskId) === holderTaskId);
459
+ if (task) renderWorkflowTreeInto(holder, task);
460
+ }
461
+ for (const button of list.querySelectorAll('.bg-task-fold')) {
462
+ button.addEventListener('click', (event) => {
463
+ event.preventDefault();
464
+ toggleWorkflowTreeFold(button.dataset.taskId);
465
+ });
466
+ }
467
+ }
468
+ if (wasOpen) panel.setAttribute('open', '');
469
+ if (tasks.length) startElapsedTimer();
470
+ else stopElapsedTimer();
471
+ }
472
+
473
+ // A preview created, closed, or flipping online/offline re-renders the panel;
474
+ // the registry is relay-owned, so the store cannot notify through the task path.
475
+ subscribePreviews(() => renderBackgroundTasksPanel());
476
+
477
+ function startElapsedTimer() {
478
+ if (elapsedTimer) return;
479
+ elapsedTimer = setInterval(() => {
480
+ const panel = document.getElementById('background-tasks-panel');
481
+ if (!panel || panel.hidden || !panel.hasAttribute('open')) return;
482
+ const tasks = currentConversationId ? (tasksByConversation.get(currentConversationId) || []) : [];
483
+ for (const task of tasks) {
484
+ const row = panel.querySelector(`.bg-task-row[data-task-id="${CSS.escape(task.taskId)}"] .bg-task-elapsed`);
485
+ if (row) row.textContent = formatElapsed(task.startedAt);
486
+ }
487
+ }, 1000);
488
+ }
489
+
490
+ function stopElapsedTimer() {
491
+ if (!elapsedTimer) return;
492
+ clearInterval(elapsedTimer);
493
+ elapsedTimer = null;
494
+ }