@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,307 @@
1
+ /**
2
+ * Renders the plan-usage ("Check Usage") modal body from the multi-provider
3
+ * payload `/api/usage` returns.
4
+ *
5
+ * Plan-first by design: subscription credits, limits and resets lead, and the
6
+ * token/cost diagnostics sit in collapsed sections underneath. Kept DOM-free so
7
+ * it can be unit tested directly, mirroring `context-usage-view.mjs` — whose
8
+ * bar/table vocabulary this deliberately reuses so the two modals look related.
9
+ */
10
+
11
+ import { escapeHtml } from './context-usage-view.mjs';
12
+
13
+ // Null-prototype so an unrecognized `status`/`source` can only ever miss —
14
+ // a plain literal would resolve keys like `constructor` off the prototype and
15
+ // stringify a function into a badge.
16
+ const STATUS_BADGES = Object.freeze(Object.assign(Object.create(null), {
17
+ ok: null,
18
+ partial: 'Partial',
19
+ unavailable: 'Unavailable',
20
+ 'not-configured': 'Not configured',
21
+ error: 'Error',
22
+ }));
23
+
24
+ const SOURCE_LABELS = Object.freeze(Object.assign(Object.create(null), {
25
+ live: 'Live',
26
+ cache: 'Cached',
27
+ worker: 'From last turn',
28
+ manual: 'Estimated',
29
+ }));
30
+
31
+ /** Short tab captions; the card keeps the long label ("GitHub Copilot"). */
32
+ const TAB_LABELS = Object.freeze(Object.assign(Object.create(null), {
33
+ github: 'Copilot',
34
+ claude: 'Claude',
35
+ cursor: 'Cursor',
36
+ grok: 'Grok',
37
+ }));
38
+
39
+ // The composer speaks 'github'/'openai'/… while the usage report keys Copilot as
40
+ // 'github'; fold the spellings that mean the same provider onto one id so a
41
+ // session's provider can be matched against a card.
42
+ const PROVIDER_ALIASES = Object.freeze(Object.assign(Object.create(null), {
43
+ copilot: 'github',
44
+ 'github-copilot': 'github',
45
+ 'copilot-github': 'github',
46
+ }));
47
+
48
+ /** @returns {string} the canonical usage-card id for a provider spelling. */
49
+ export function normalizeUsageProvider(value) {
50
+ const key = String(value ?? '').trim().toLowerCase();
51
+ if (!key) return '';
52
+ return PROVIDER_ALIASES[key] || key;
53
+ }
54
+
55
+ /**
56
+ * Which tab opens selected: the requested provider when it has a card (so a
57
+ * Claude session lands on Claude), otherwise the first card — 'openai' sessions
58
+ * have no card of their own and must not open onto an empty modal.
59
+ */
60
+ export function resolveActiveUsageProvider(providers, activeProvider = '') {
61
+ const ids = (Array.isArray(providers) ? providers : [])
62
+ .filter(Boolean)
63
+ .map((card) => normalizeUsageProvider(card.provider));
64
+ const wanted = normalizeUsageProvider(activeProvider);
65
+ if (wanted && ids.includes(wanted)) return wanted;
66
+ return ids[0] || '';
67
+ }
68
+
69
+ function toNullableNumber(value) {
70
+ if (value === null || value === undefined || value === '') return null;
71
+ const n = Number(value);
72
+ return Number.isFinite(n) ? n : null;
73
+ }
74
+
75
+ /** Green → amber → red, matching the composer's context-usage indicator ramp. */
76
+ export function utilizationColor(utilization) {
77
+ const value = toNullableNumber(utilization);
78
+ if (value === null) return '#6e7681';
79
+ if (value >= 90) return '#f85149';
80
+ if (value >= 75) return '#e3b341';
81
+ if (value >= 50) return '#d29922';
82
+ return '#3fb950';
83
+ }
84
+
85
+ export function formatAmount(value, unit) {
86
+ const n = toNullableNumber(value);
87
+ if (n === null) return null;
88
+ if (unit === 'usd') {
89
+ const rounded = Math.round(n * 100) / 100;
90
+ return `${rounded < 0 ? '-' : ''}$${Math.abs(rounded).toFixed(2)}`;
91
+ }
92
+ if (unit === 'percent') return `${Math.round(n * 10) / 10}%`;
93
+ const abs = Math.abs(n);
94
+ if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
95
+ if (abs >= 10_000) return `${(n / 1000).toFixed(1)}k`;
96
+ return String(Math.round(n * 100) / 100);
97
+ }
98
+
99
+ export function formatResetCountdown(resetAt, now = new Date()) {
100
+ if (!resetAt) return null;
101
+ const target = new Date(resetAt);
102
+ if (Number.isNaN(target.getTime())) return null;
103
+ const reference = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
104
+ const diffMs = target.getTime() - reference.getTime();
105
+ if (diffMs <= 0) return 'resets now';
106
+ const minutes = Math.floor(diffMs / 60000);
107
+ if (minutes < 60) return `resets in ${Math.max(1, minutes)} min`;
108
+ const hours = Math.floor(minutes / 60);
109
+ if (hours < 24) {
110
+ const remainderMinutes = minutes % 60;
111
+ return remainderMinutes ? `resets in ${hours} h ${remainderMinutes} min` : `resets in ${hours} h`;
112
+ }
113
+ const days = Math.floor(hours / 24);
114
+ if (days <= 14) {
115
+ const remainderHours = hours % 24;
116
+ return remainderHours ? `resets in ${days} d ${remainderHours} h` : `resets in ${days} d`;
117
+ }
118
+ return `resets ${target.toISOString().slice(0, 10)}`;
119
+ }
120
+
121
+ /**
122
+ * The headline under a meter label: what is left, out of what, and how it was
123
+ * derived. Providers that only report a percentage get just the percentage.
124
+ */
125
+ export function meterSummaryText(meter) {
126
+ if (!meter || typeof meter !== 'object') return '';
127
+ if (meter.unlimited) return 'Unlimited';
128
+ const unit = meter.unit || 'credits';
129
+ const used = formatAmount(meter.used, unit);
130
+ const allowance = formatAmount(meter.allowance, unit);
131
+ const remaining = formatAmount(meter.remaining, unit);
132
+ const utilization = toNullableNumber(meter.utilization);
133
+
134
+ if (allowance !== null && used !== null) {
135
+ const parts = [`${used} of ${allowance} used`];
136
+ if (remaining !== null) {
137
+ const remainingNumber = toNullableNumber(meter.remaining);
138
+ // Overage reads as a magnitude ("9989 over"), not a signed value.
139
+ parts.push(remainingNumber < 0
140
+ ? `${formatAmount(Math.abs(remainingNumber), unit)} over`
141
+ : `${remaining} left`);
142
+ }
143
+ return parts.join(' · ');
144
+ }
145
+ if (remaining !== null && allowance !== null) return `${remaining} of ${allowance} left`;
146
+ if (used !== null) return `${used} used`;
147
+ if (utilization !== null) return `${Math.round(utilization * 10) / 10}% used`;
148
+ return '';
149
+ }
150
+
151
+ function renderMeter(meter, now) {
152
+ const utilization = toNullableNumber(meter.utilization);
153
+ const width = meter.unlimited ? 100 : Math.max(0, Math.min(100, utilization ?? 0));
154
+ const color = meter.unlimited ? '#3fb950' : utilizationColor(utilization);
155
+ const summary = meterSummaryText(meter);
156
+ const countdown = formatResetCountdown(meter.resetAt, now);
157
+ const percentLabel = meter.unlimited
158
+ ? '∞'
159
+ : (utilization === null ? '—' : `${Math.round(utilization)}%`);
160
+ const flags = [
161
+ meter.estimated ? '<span class="plan-usage-flag">estimated</span>' : '',
162
+ ].filter(Boolean).join('');
163
+ const meta = [summary, countdown].filter(Boolean).join(' · ');
164
+
165
+ return `
166
+ <div class="plan-usage-meter plan-usage-meter-${escapeHtml(meter.emphasis || 'primary')}" data-meter-id="${escapeHtml(meter.id)}">
167
+ <div class="plan-usage-meter-head">
168
+ <span class="plan-usage-meter-label">${escapeHtml(meter.label)}${flags}</span>
169
+ <span class="plan-usage-meter-pct">${escapeHtml(percentLabel)}</span>
170
+ </div>
171
+ <div class="plan-usage-bar" role="img" aria-label="${escapeHtml(`${meter.label}: ${percentLabel} used`)}">
172
+ <span class="plan-usage-bar-fill${utilization === null && !meter.unlimited ? ' plan-usage-bar-unknown' : ''}" style="width:${width}%;background:${color}"></span>
173
+ </div>
174
+ ${meta ? `<div class="plan-usage-meter-meta">${escapeHtml(meta)}</div>` : ''}
175
+ ${meter.note ? `<div class="plan-usage-meter-note">${escapeHtml(meter.note)}</div>` : ''}
176
+ </div>
177
+ `;
178
+ }
179
+
180
+ function renderDetailSection(section) {
181
+ const rows = section.rows.map((row) => `
182
+ <tr>
183
+ <td class="plan-usage-detail-label">${escapeHtml(row.label)}${row.hint ? `<span class="plan-usage-detail-hint">${escapeHtml(row.hint)}</span>` : ''}</td>
184
+ <td class="plan-usage-detail-value">${escapeHtml(row.value)}</td>
185
+ </tr>
186
+ `).join('');
187
+ return `
188
+ <details class="plan-usage-details" data-section-id="${escapeHtml(section.id)}">
189
+ <summary>${escapeHtml(section.label)}</summary>
190
+ ${section.note ? `<div class="plan-usage-detail-note">${escapeHtml(section.note)}</div>` : ''}
191
+ <table class="plan-usage-detail-table"><tbody>${rows}</tbody></table>
192
+ </details>
193
+ `;
194
+ }
195
+
196
+ /**
197
+ * The signed-in account under the card title. Three distinct states, because
198
+ * "we could not ask" must not be drawn as "you are logged out": no `account`
199
+ * key renders nothing at all, `{loggedIn:false}` says so, anything else shows
200
+ * whatever identity we have.
201
+ */
202
+ function renderAccountLine(account) {
203
+ if (!account || typeof account !== 'object') return '';
204
+ if (account.loggedIn === false) {
205
+ return '<span class="plan-usage-account plan-usage-account-signed-out">Not logged in</span>';
206
+ }
207
+ const parts = [account.email, account.plan]
208
+ .map((value) => String(value ?? '').trim())
209
+ .filter(Boolean);
210
+ if (!parts.length) {
211
+ return account.loggedIn === true
212
+ ? '<span class="plan-usage-account">Logged in</span>'
213
+ : '';
214
+ }
215
+ return `<span class="plan-usage-account">${parts.map(escapeHtml).join(' · ')}</span>`;
216
+ }
217
+
218
+ function renderCard(card, now, { active = true } = {}) {
219
+ const meters = Array.isArray(card.meters) ? card.meters : [];
220
+ const details = Array.isArray(card.details) ? card.details : [];
221
+ const links = Array.isArray(card.links) ? card.links : [];
222
+
223
+ const statusBadge = STATUS_BADGES[card.status];
224
+ const sourceBadge = SOURCE_LABELS[card.source];
225
+ const badges = [
226
+ card.planName ? `<span class="plan-usage-badge plan-usage-badge-plan">${escapeHtml(card.planName)}</span>` : '',
227
+ statusBadge ? `<span class="plan-usage-badge plan-usage-badge-status">${escapeHtml(statusBadge)}</span>` : '',
228
+ sourceBadge ? `<span class="plan-usage-badge">${escapeHtml(sourceBadge)}</span>` : '',
229
+ ].filter(Boolean).join('');
230
+
231
+ const primary = meters.filter((meter) => meter.emphasis !== 'secondary');
232
+ const secondary = meters.filter((meter) => meter.emphasis === 'secondary');
233
+
234
+ const metersHtml = meters.length
235
+ ? `${primary.map((meter) => renderMeter(meter, now)).join('')}${
236
+ secondary.length
237
+ ? `<div class="plan-usage-secondary">${secondary.map((meter) => renderMeter(meter, now)).join('')}</div>`
238
+ : ''
239
+ }`
240
+ : '';
241
+
242
+ const capturedLabel = card.capturedAt
243
+ ? `Updated ${escapeHtml(String(card.capturedAt).replace('T', ' ').slice(0, 16))} UTC`
244
+ : '';
245
+
246
+ const panelId = normalizeUsageProvider(card.provider);
247
+ return `
248
+ <section class="plan-usage-card plan-usage-card-${escapeHtml(card.provider)}" data-provider="${escapeHtml(card.provider)}" data-usage-panel="${escapeHtml(panelId)}" data-status="${escapeHtml(card.status)}" role="tabpanel" id="plan-usage-panel-${escapeHtml(panelId)}" aria-labelledby="plan-usage-tab-${escapeHtml(panelId)}" tabindex="0"${active ? '' : ' hidden'}>
249
+ <header class="plan-usage-card-head">
250
+ <span class="plan-usage-card-titles">
251
+ <span class="plan-usage-card-title">${escapeHtml(card.label)}</span>
252
+ ${renderAccountLine(card.account)}
253
+ </span>
254
+ <span class="plan-usage-badges">${badges}</span>
255
+ </header>
256
+ ${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
257
+ ${metersHtml}
258
+ ${details.map(renderDetailSection).join('')}
259
+ <div class="plan-usage-card-foot">
260
+ ${capturedLabel ? `<span class="plan-usage-captured">${capturedLabel}</span>` : ''}
261
+ ${links.map((link) => `<a class="plan-usage-link" href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.label)}</a>`).join('')}
262
+ </div>
263
+ </section>
264
+ `;
265
+ }
266
+
267
+ function renderTab(card, activeProvider) {
268
+ const id = normalizeUsageProvider(card.provider);
269
+ const active = id === activeProvider;
270
+ const label = TAB_LABELS[id] || card.label || id;
271
+ return `<button type="button" class="plan-usage-tab${active ? ' active' : ''}" role="tab" id="plan-usage-tab-${escapeHtml(id)}" aria-controls="plan-usage-panel-${escapeHtml(id)}" aria-selected="${active ? 'true' : 'false'}" tabindex="${active ? '0' : '-1'}" data-usage-tab="${escapeHtml(id)}">${escapeHtml(label)}</button>`;
272
+ }
273
+
274
+ /**
275
+ * @param {object|null} report the `/api/usage` payload
276
+ * @param {object} [options]
277
+ * @param {Date} [options.now] injectable clock so countdowns are testable
278
+ * @param {string} [options.activeProvider] the session's provider; selects its
279
+ * tab when that provider has a card
280
+ * @returns {string} modal body HTML, or '' when there is nothing to render
281
+ */
282
+ export function renderPlanUsageHtml(report, { now = new Date(), activeProvider = '' } = {}) {
283
+ const providers = Array.isArray(report?.providers) ? report.providers.filter(Boolean) : [];
284
+ if (!providers.length) return '';
285
+ const active = resolveActiveUsageProvider(providers, activeProvider);
286
+ const tabs = providers.map((card) => renderTab(card, active)).join('');
287
+ const cards = providers
288
+ .map((card) => renderCard(card, now, { active: normalizeUsageProvider(card.provider) === active }))
289
+ .join('');
290
+ return `<div class="plan-usage"><div class="plan-usage-tab-strip" role="tablist" aria-label="Usage providers">${tabs}</div>${cards}</div>`;
291
+ }
292
+
293
+ /** Subtitle for the modal header: the healthiest available "what matters now" line. */
294
+ export function planUsageSubtitle(report) {
295
+ const providers = Array.isArray(report?.providers) ? report.providers.filter(Boolean) : [];
296
+ if (!providers.length) return 'No usage data available';
297
+ const available = providers.filter((card) => Array.isArray(card.meters) && card.meters.length);
298
+ if (!available.length) return 'No plan limits reported yet';
299
+ const soonest = available
300
+ .flatMap((card) => card.meters.map((meter) => meter.resetAt))
301
+ .filter(Boolean)
302
+ .sort()[0];
303
+ const countdown = formatResetCountdown(soonest);
304
+ return countdown
305
+ ? `${available.length} provider${available.length === 1 ? '' : 's'} reporting · next reset ${countdown.replace(/^resets /, '')}`
306
+ : `${available.length} provider${available.length === 1 ? '' : 's'} reporting`;
307
+ }
@@ -0,0 +1,273 @@
1
+ // Preview cards: live dev servers published through the preview lane. Fed by
2
+ // the `previews` socket event (REPLACE semantics for the whole set, since the
3
+ // registry is relay-owned and global) and by the conversation payload's
4
+ // `previews` on reload.
5
+ //
6
+ // The same row builder serves both surfaces: the composer's background-task
7
+ // panel (this conversation's previews, beside the Bash task running the dev
8
+ // server) and the settings modal's "Live previews" list (every session's).
9
+ // Previews never expire, so the settings list is the one place that can always
10
+ // close a link left behind in a conversation you have moved on from.
11
+
12
+ import { apiFetch } from './api-client.js';
13
+
14
+ let previews = [];
15
+ const closesInFlight = new Set();
16
+ const subscribers = new Set();
17
+
18
+ export function subscribePreviews(callback) {
19
+ if (typeof callback !== 'function') return () => {};
20
+ subscribers.add(callback);
21
+ return () => subscribers.delete(callback);
22
+ }
23
+
24
+ function notify() {
25
+ for (const callback of subscribers) {
26
+ try { callback(previews); } catch {}
27
+ }
28
+ }
29
+
30
+ function normalizePreview(entry) {
31
+ if (!entry || typeof entry !== 'object') return null;
32
+ const token = String(entry.token || '').trim();
33
+ if (!token) return null;
34
+ return {
35
+ token,
36
+ conversationId: String(entry.conversationId || '').trim() || null,
37
+ label: String(entry.label || '').trim() || 'Preview',
38
+ targetHost: String(entry.targetHost || '').trim(),
39
+ targetPort: Number(entry.targetPort) || null,
40
+ url: String(entry.url || '').trim(),
41
+ basePath: String(entry.basePath || '').trim(),
42
+ createdAt: Number(entry.createdAt) || null,
43
+ // null = not probed yet; the badge stays absent rather than guessing.
44
+ online: entry.online === true ? true : (entry.online === false ? false : null),
45
+ };
46
+ }
47
+
48
+ export function setPreviews(nextPreviews) {
49
+ previews = (Array.isArray(nextPreviews) ? nextPreviews : [])
50
+ .map(normalizePreview)
51
+ .filter(Boolean);
52
+ notify();
53
+ }
54
+
55
+ // The conversation payload only carries that conversation's previews, so it
56
+ // merges into the global set rather than replacing it — otherwise opening a
57
+ // conversation would erase every other session's cards until the next socket
58
+ // event.
59
+ export function mergeConversationPreviews(conversationId, conversationPreviews) {
60
+ const id = String(conversationId || '').trim();
61
+ if (!id) return;
62
+ const incoming = (Array.isArray(conversationPreviews) ? conversationPreviews : [])
63
+ .map(normalizePreview)
64
+ .filter(Boolean);
65
+ previews = previews.filter((entry) => entry.conversationId !== id).concat(incoming);
66
+ notify();
67
+ }
68
+
69
+ export function getPreviews() {
70
+ return previews;
71
+ }
72
+
73
+ export function getPreviewsForConversation(conversationId) {
74
+ const id = String(conversationId || '').trim();
75
+ if (!id) return [];
76
+ return previews.filter((entry) => entry.conversationId === id);
77
+ }
78
+
79
+ export async function closePreview(token) {
80
+ const id = String(token || '').trim();
81
+ if (!id || closesInFlight.has(id)) return;
82
+ closesInFlight.add(id);
83
+ notify();
84
+ try {
85
+ // apiFetch resolves to null on a network error or a non-2xx status rather
86
+ // than throwing, so success has to be checked, not assumed: a card removed
87
+ // on a failed DELETE would hide a link that is still publicly live.
88
+ const response = await apiFetch(`/api/previews/${encodeURIComponent(id)}`, { method: 'DELETE' });
89
+ if (response?.ok === true) {
90
+ // Optimistic removal: the socket event will confirm, but the card should
91
+ // not linger while the round trip completes.
92
+ previews = previews.filter((entry) => entry.token !== id);
93
+ }
94
+ } catch {
95
+ // Left in place on failure so the user can retry rather than lose the link.
96
+ } finally {
97
+ closesInFlight.delete(id);
98
+ notify();
99
+ }
100
+ }
101
+
102
+ async function copyPreviewUrl(url, button) {
103
+ try {
104
+ await navigator.clipboard.writeText(url);
105
+ const previousText = button.textContent;
106
+ button.textContent = 'Copied';
107
+ setTimeout(() => { button.textContent = previousText; }, 1500);
108
+ } catch {
109
+ button.textContent = 'Copy failed';
110
+ }
111
+ }
112
+
113
+ function previewDetail(preview, { withConversation = false } = {}) {
114
+ const parts = [];
115
+ if (preview.targetPort) parts.push(`:${preview.targetPort}`);
116
+ if (preview.online === false) parts.push('offline — dev server not responding');
117
+ if (withConversation && preview.conversationId) {
118
+ parts.push(`session ${preview.conversationId.slice(0, 8)}`);
119
+ }
120
+ return parts.join(' · ');
121
+ }
122
+
123
+ /**
124
+ * One preview row. Built with createElement/textContent throughout: a label is
125
+ * agent-supplied text and must never be able to execute as markup.
126
+ */
127
+ export function buildPreviewRow(preview, { withConversation = false } = {}) {
128
+ const row = document.createElement('div');
129
+ row.className = 'preview-row';
130
+ row.dataset.token = preview.token;
131
+ if (preview.online === false) row.classList.add('preview-row-offline');
132
+
133
+ const icon = document.createElement('span');
134
+ icon.className = 'preview-icon';
135
+ icon.textContent = '🌐';
136
+ icon.title = 'Preview server';
137
+ row.appendChild(icon);
138
+
139
+ const main = document.createElement('span');
140
+ main.className = 'preview-main';
141
+ row.appendChild(main);
142
+
143
+ const label = document.createElement('span');
144
+ label.className = 'preview-label';
145
+ label.textContent = preview.label;
146
+ main.appendChild(label);
147
+
148
+ const detailText = previewDetail(preview, { withConversation });
149
+ if (detailText) {
150
+ const detail = document.createElement('span');
151
+ detail.className = 'preview-detail';
152
+ detail.textContent = detailText;
153
+ main.appendChild(detail);
154
+ }
155
+
156
+ // The link is public: anyone who has it reaches the dev server without
157
+ // logging in, so the card says so rather than leaving it to be discovered.
158
+ const warning = document.createElement('span');
159
+ warning.className = 'preview-warning';
160
+ warning.textContent = 'public link — anyone with the URL can reach this app';
161
+ main.appendChild(warning);
162
+
163
+ const side = document.createElement('span');
164
+ side.className = 'preview-side';
165
+ row.appendChild(side);
166
+
167
+ if (preview.online !== null) {
168
+ const badge = document.createElement('span');
169
+ badge.className = preview.online
170
+ ? 'preview-badge preview-badge-online'
171
+ : 'preview-badge preview-badge-offline';
172
+ badge.textContent = preview.online ? 'online' : 'offline';
173
+ side.appendChild(badge);
174
+ }
175
+
176
+ const actions = document.createElement('span');
177
+ actions.className = 'preview-actions';
178
+ side.appendChild(actions);
179
+
180
+ const open = document.createElement('a');
181
+ open.className = 'preview-open';
182
+ open.href = preview.url;
183
+ open.target = '_blank';
184
+ // noopener/noreferrer: the preview is a different origin running app code we
185
+ // do not control, and it has no business holding a handle on this window.
186
+ open.rel = 'noopener noreferrer';
187
+ open.textContent = 'Open';
188
+ actions.appendChild(open);
189
+
190
+ const copy = document.createElement('button');
191
+ copy.type = 'button';
192
+ copy.className = 'preview-copy';
193
+ copy.textContent = 'Copy link';
194
+ copy.addEventListener('click', (event) => {
195
+ event.preventDefault();
196
+ void copyPreviewUrl(preview.url, copy);
197
+ });
198
+ actions.appendChild(copy);
199
+
200
+ const closing = closesInFlight.has(preview.token);
201
+ const close = document.createElement('button');
202
+ close.type = 'button';
203
+ close.className = 'preview-close';
204
+ close.disabled = closing;
205
+ close.textContent = closing ? 'Closing…' : 'Close';
206
+ close.addEventListener('click', (event) => {
207
+ event.preventDefault();
208
+ void closePreview(preview.token);
209
+ });
210
+ actions.appendChild(close);
211
+
212
+ return row;
213
+ }
214
+
215
+ /**
216
+ * Transcript card for a preview published during a turn. Renders from the
217
+ * persisted snapshot (the registry may be long gone), overlaying live state
218
+ * from the current store: a token still registered gets a working row, a
219
+ * vanished one keeps the label but drops the dead link for a `closed` badge.
220
+ */
221
+ export function buildTranscriptPreviewCard(snapshot) {
222
+ const preview = normalizePreview(snapshot);
223
+ if (!preview) return null;
224
+ const live = previews.some((entry) => entry.token === preview.token);
225
+
226
+ const card = document.createElement('div');
227
+ card.className = live ? 'msg-preview-card' : 'msg-preview-card msg-preview-card-closed';
228
+
229
+ const icon = document.createElement('span');
230
+ icon.className = 'preview-icon';
231
+ icon.textContent = '🌐';
232
+ card.appendChild(icon);
233
+
234
+ const label = document.createElement('span');
235
+ label.className = 'preview-label';
236
+ label.textContent = preview.label;
237
+ card.appendChild(label);
238
+
239
+ if (live) {
240
+ const open = document.createElement('a');
241
+ open.className = 'preview-open';
242
+ open.href = preview.url;
243
+ open.target = '_blank';
244
+ open.rel = 'noopener noreferrer';
245
+ open.textContent = 'Open';
246
+ card.appendChild(open);
247
+
248
+ const copy = document.createElement('button');
249
+ copy.type = 'button';
250
+ copy.className = 'preview-copy';
251
+ copy.textContent = 'Copy link';
252
+ copy.addEventListener('click', (event) => {
253
+ event.preventDefault();
254
+ void copyPreviewUrl(preview.url, copy);
255
+ });
256
+ card.appendChild(copy);
257
+ } else {
258
+ const badge = document.createElement('span');
259
+ badge.className = 'preview-badge preview-badge-offline';
260
+ badge.textContent = 'closed';
261
+ card.appendChild(badge);
262
+ }
263
+ return card;
264
+ }
265
+
266
+ export function renderPreviewRowsInto(container, rows, options = {}) {
267
+ if (!container) return 0;
268
+ container.textContent = '';
269
+ for (const preview of rows) {
270
+ container.appendChild(buildPreviewRow(preview, options));
271
+ }
272
+ return rows.length;
273
+ }
@@ -0,0 +1,103 @@
1
+ // The /preview composer command: publish a port or directory, list, or close —
2
+ // entirely client-side against the relay API, so it works identically on every
3
+ // provider and never costs an agent turn. Mirrors the /compact intercept in
4
+ // conversation-view.js: parsed before anything is queued, unknown syntax falls
5
+ // through to a help line, never to the model.
6
+
7
+ import { apiFetch } from './api-client.js';
8
+
9
+ export const PREVIEW_COMMAND_HELP =
10
+ 'Usage: /preview <port> [label] · /preview <dir> [label] · /preview list · /preview close [token-prefix]';
11
+
12
+ /**
13
+ * Returns null when the text is not a /preview command at all (the composer
14
+ * sends it as a normal message); otherwise a parsed command, with unparseable
15
+ * input mapped to {kind:'help'} so it never reaches the model by accident.
16
+ */
17
+ export function parsePreviewCommand(text) {
18
+ const trimmed = String(text || '').trim();
19
+ if (!/^\/preview(\s|$)/i.test(trimmed)) return null;
20
+ const rest = trimmed.replace(/^\/preview\s*/i, '').trim();
21
+ if (!rest) return { kind: 'help' };
22
+
23
+ const [first, ...restParts] = rest.split(/\s+/);
24
+ const remainder = restParts.join(' ').trim();
25
+
26
+ if (/^list$/i.test(first)) return { kind: 'list' };
27
+ if (/^close$/i.test(first)) {
28
+ const prefix = remainder.toLowerCase();
29
+ if (prefix && !/^[0-9a-f]{4,32}$/.test(prefix)) return { kind: 'help' };
30
+ return { kind: 'close', tokenPrefix: prefix || null };
31
+ }
32
+ if (/^\d+$/.test(first)) {
33
+ const port = Number(first);
34
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { kind: 'help' };
35
+ return { kind: 'create-port', port, label: remainder || null };
36
+ }
37
+ // Anything path-shaped is a static preview. Windows drive letters ("C:\x")
38
+ // and dotted relatives all land here; the server does the real validation.
39
+ return { kind: 'create-dir', dir: first, label: remainder || null };
40
+ }
41
+
42
+ async function closeByPrefix(tokenPrefix, conversationId) {
43
+ const listed = await apiFetch(`/api/previews${tokenPrefix ? '' : `?conversationId=${encodeURIComponent(conversationId || '')}`}`);
44
+ const previews = listed?.previews || [];
45
+ const matches = tokenPrefix
46
+ ? previews.filter((entry) => String(entry.token || '').startsWith(tokenPrefix))
47
+ : previews;
48
+ if (matches.length === 0) {
49
+ return { ok: false, notice: tokenPrefix ? `No preview matches "${tokenPrefix}".` : 'No live previews in this conversation.' };
50
+ }
51
+ if (matches.length > 1) {
52
+ const names = matches.map((entry) => `${entry.token.slice(0, 8)} (${entry.label})`).join(', ');
53
+ return { ok: false, notice: `Ambiguous — matches: ${names}. Use a longer prefix.` };
54
+ }
55
+ const closed = await apiFetch(`/api/previews/${encodeURIComponent(matches[0].token)}`, { method: 'DELETE' });
56
+ if (closed?.ok !== true) return { ok: false, notice: 'Close failed — the preview may already be gone.' };
57
+ return { ok: true, notice: `Preview closed: ${matches[0].label}` };
58
+ }
59
+
60
+ /**
61
+ * Executes a parsed command. Returns `{ ok, notice }`; the caller renders the
62
+ * notice. Creation results also surface as panel cards via the `previews`
63
+ * socket event, so the notice only needs to carry the URL once.
64
+ */
65
+ export async function runPreviewCommand(parsed, { conversationId = '' } = {}) {
66
+ if (!parsed || parsed.kind === 'help') return { ok: true, notice: PREVIEW_COMMAND_HELP };
67
+
68
+ try {
69
+ if (parsed.kind === 'list') {
70
+ const listed = await apiFetch('/api/previews');
71
+ if (!listed) return { ok: false, notice: 'Could not reach the relay.' };
72
+ if (listed.enabled !== true) return { ok: false, notice: 'The preview lane is disabled on this relay.' };
73
+ const previews = listed.previews || [];
74
+ if (!previews.length) return { ok: true, notice: 'No live previews.' };
75
+ const lines = previews.map((entry) => {
76
+ const target = entry.mode === 'static' ? entry.rootDir : `:${entry.targetPort}`;
77
+ const state = entry.online === false ? ' — offline' : '';
78
+ return `${entry.token.slice(0, 8)} · ${entry.label} (${target})${state} → ${entry.url}`;
79
+ });
80
+ return { ok: true, notice: lines.join('\n') };
81
+ }
82
+
83
+ if (parsed.kind === 'close') {
84
+ return await closeByPrefix(parsed.tokenPrefix, conversationId);
85
+ }
86
+
87
+ const body = {
88
+ conversationId: conversationId || undefined,
89
+ label: parsed.label || undefined,
90
+ ...(parsed.kind === 'create-port' ? { port: parsed.port } : { dir: parsed.dir }),
91
+ };
92
+ const created = await apiFetch('/api/previews', { method: 'POST', body: JSON.stringify(body) });
93
+ if (created?.ok !== true) {
94
+ // apiFetch swallows the response body on non-2xx, so the specific server
95
+ // error (bad port, jail refusal, lane disabled) is not recoverable here;
96
+ // point at the panel where the states are visible instead.
97
+ return { ok: false, notice: 'Preview failed — check the port/path, or whether the preview lane is enabled (Settings → Live previews).' };
98
+ }
99
+ return { ok: true, notice: `Preview published: ${created.url}` };
100
+ } catch {
101
+ return { ok: false, notice: 'Preview command failed — could not reach the relay.' };
102
+ }
103
+ }