headlesscode 1.0.2

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 (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,2803 @@
1
+ /**
2
+ * Cost/token dashboard — the single static HTML page served at `GET /`.
3
+ *
4
+ * A plain template string (no build step, no framework) with a small
5
+ * vanilla-JS poll loop hitting `/api/summary` every few seconds, plus a
6
+ * live per-session event feed (Phase 1) and pause/resume control (Phase 3).
7
+ *
8
+ * Session detail: clicking a session row opens a detail panel that polls
9
+ * `/api/session/:id/events?since=<offset>` every ~1.5s (faster than the
10
+ * summary poll — this is meant to feel live), appending new events to the
11
+ * DOM rather than re-rendering the whole feed, and auto-scrolling to the
12
+ * latest event unless the user has manually scrolled up. The panel also
13
+ * carries pause/resume buttons (POST /api/session/:id/pause|resume) and a
14
+ * checkpoints panel (GET /api/checkpoints list + GET /api/checkpoints/diff,
15
+ * POST /api/checkpoints/restore).
16
+ *
17
+ * Checkpoints panel: lists the session's shadow-git checkpoints; clicking one
18
+ * shows its unified-diff-style text block vs the working tree; Restore
19
+ * reverts REAL workspace files and therefore requires a two-step confirm
20
+ * (the button arms first — a second click fires the POST). After a restore
21
+ * the list + diff refresh so the view never shows stale data.
22
+ *
23
+ * NOTE (Phase 3 scope change): this page is no longer purely read-only — the
24
+ * pause/resume buttons issue state-changing POSTs, and restore is fully
25
+ * destructive. Local-only + no-auth by default; the restore POST goes
26
+ * through the same optional bearer-token gate as the other control POSTs
27
+ * when HEADLESSCODE_DASHBOARD_TOKEN is set (see src/dashboard/server.ts).
28
+ */
29
+
30
+ export function renderPage(repo?: string): string {
31
+ return `<!doctype html>
32
+ <html lang="en">
33
+ <head>
34
+ <meta charset="utf-8">
35
+ <meta name="viewport" content="width=device-width, initial-scale=1">
36
+ <title>headlesscode dashboard</title>
37
+ <style>
38
+ :root { color-scheme: light dark; }
39
+ * { box-sizing: border-box; }
40
+ body {
41
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
42
+ margin: 0; padding: 24px; max-width: 1100px; margin-inline: auto;
43
+ background: Canvas; color: CanvasText;
44
+ }
45
+ h1 { font-size: 1.4rem; margin-bottom: 4px; }
46
+ .sub { color: GrayText; font-size: 0.85rem; margin-bottom: 24px; }
47
+ h2 { font-size: 1.05rem; margin-top: 32px; margin-bottom: 8px; }
48
+ .stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
49
+ .stat {
50
+ border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
51
+ border-radius: 8px; padding: 10px 16px; min-width: 120px;
52
+ }
53
+ .stat .label { font-size: 0.75rem; color: GrayText; text-transform: uppercase; letter-spacing: 0.04em; }
54
+ .stat .value { font-size: 1.3rem; font-weight: 600; margin-top: 2px; }
55
+ table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
56
+ th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent); }
57
+ th { color: GrayText; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
58
+ tr:hover td { background: color-mix(in srgb, CanvasText 5%, transparent); }
59
+ .status-success { color: #1a7f37; }
60
+ .status-error { color: #c0392b; }
61
+ .status-budget { color: #b8860b; }
62
+ .status-blocked { color: #b8860b; }
63
+ /* A running (live) session gets a pill badge so it stands out from completed ones. */
64
+ .status-running {
65
+ color: #b8860b;
66
+ display: inline-block;
67
+ padding: 1px 8px;
68
+ border-radius: 10px;
69
+ border: 1px solid color-mix(in srgb, #b8860b 50%, transparent);
70
+ background: color-mix(in srgb, #b8860b 10%, transparent);
71
+ }
72
+ .status-paused {
73
+ color: #1a7f37;
74
+ display: inline-block;
75
+ padding: 1px 8px;
76
+ border-radius: 10px;
77
+ border: 1px solid color-mix(in srgb, #1a7f37 50%, transparent);
78
+ background: color-mix(in srgb, #1a7f37 10%, transparent);
79
+ }
80
+ .status-done { color: #1a7f37; }
81
+ .status-failed { color: #c0392b; }
82
+ .empty { color: GrayText; font-style: italic; padding: 12px 0; }
83
+ .blocked-card {
84
+ border: 1px solid color-mix(in srgb, #b8860b 50%, transparent);
85
+ border-radius: 8px; padding: 12px 16px; margin-bottom: 10px;
86
+ background: color-mix(in srgb, #b8860b 8%, transparent);
87
+ }
88
+ .blocked-card .name { font-weight: 600; }
89
+ .blocked-card .question { margin-top: 4px; }
90
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; }
91
+ #error { color: #c0392b; margin-top: 12px; display: none; }
92
+
93
+ /* Session detail panel (Phase 1 live event feed). */
94
+ #detail { display: none; margin-top: 20px; border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); border-radius: 8px; }
95
+ #detail .detail-head {
96
+ display: flex; align-items: center; gap: 12px; padding: 10px 16px;
97
+ border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
98
+ }
99
+ #detail .detail-head .session-id { font-weight: 600; }
100
+ #detail .detail-head .close { margin-left: auto; cursor: pointer; border: none; background: none; font-size: 1.1rem; color: GrayText; }
101
+ #detail .detail-head .state-pill { font-size: 0.75rem; padding: 1px 8px; border-radius: 10px; border: 1px solid currentColor; }
102
+ #detail .detail-head .state-pill.running { color: #b8860b; }
103
+ #detail .detail-head .state-pill.paused { color: #1a7f37; }
104
+ #detail .detail-head .state-pill.done { color: GrayText; }
105
+ #detail .detail-controls { display: flex; gap: 8px; }
106
+ #detail .detail-controls button {
107
+ font-size: 0.8rem; padding: 3px 12px; border-radius: 6px; cursor: pointer;
108
+ border: 1px solid color-mix(in srgb, CanvasText 30%, transparent); background: color-mix(in srgb, CanvasText 8%, transparent);
109
+ }
110
+ #detail .detail-controls button:disabled { opacity: 0.45; cursor: not-allowed; }
111
+ #detail .detail-controls button.pause { color: #b8860b; }
112
+ #detail .detail-controls button.resume { color: #1a7f37; }
113
+ #feed {
114
+ max-height: 420px; overflow-y: auto; padding: 8px 16px; font-size: 0.8rem;
115
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
116
+ }
117
+ .ev { display: flex; gap: 10px; padding: 3px 0; border-bottom: 1px solid color-mix(in srgb, CanvasText 6%, transparent); }
118
+ .ev .ev-ts { color: GrayText; flex-shrink: 0; }
119
+ .ev .ev-body { word-break: break-word; }
120
+ .ev .ev-label { font-weight: 600; }
121
+ .ev.tool_call .ev-label { color: #8250df; }
122
+ .ev.tool_result .ev-label { color: #1a7f37; }
123
+ .ev.tool_result.is-error .ev-label { color: #c0392b; }
124
+ .ev.checkpoint_saved .ev-label { color: #b8860b; }
125
+ .ev.decision_blocked .ev-label { color: #c0392b; }
126
+ .ev.decision_answered .ev-label { color: #1a7f37; }
127
+ .ev.paused .ev-label { color: #b8860b; }
128
+ .ev.resumed .ev-label { color: #1a7f37; }
129
+ .ev.session_start .ev-label { color: #1a7f37; }
130
+ .ev.session_end .ev-label { color: GrayText; }
131
+ .ev-iter { color: GrayText; }
132
+ .ev .trunc-note { color: GrayText; font-style: italic; }
133
+ .feed-empty { color: GrayText; font-style: italic; padding: 12px 0; }
134
+
135
+ /* Detail view tabs: flat event log (original) vs. chat thread (this view).
136
+ Both render from the SAME /api/session/:id/events feed — the toggle only
137
+ switches how the already-arriving events are displayed. */
138
+ #detail .detail-tabs { display: flex; gap: 4px; padding: 0 16px; border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent); }
139
+ #detail .detail-tabs button {
140
+ font-size: 0.8rem; padding: 6px 14px; cursor: pointer;
141
+ border: none; background: none; color: GrayText; border-bottom: 2px solid transparent;
142
+ }
143
+ #detail .detail-tabs button.active { color: CanvasText; border-bottom-color: #8250df; font-weight: 600; }
144
+
145
+ /* Chat-thread view (phase: dashboard-chat-ui). Rendered client-side from the
146
+ same event feed — the model's text + its tool calls/results per turn, as
147
+ chat bubbles instead of flat rows. */
148
+ #chat {
149
+ display: none; max-height: 420px; overflow-y: auto; padding: 16px 16px 8px;
150
+ font-size: 0.85rem; line-height: 1.45;
151
+ }
152
+ #detail.view-chat #feed { display: none; }
153
+ #detail.view-chat #chat { display: block; }
154
+ #chat .chat-task {
155
+ border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
156
+ border-radius: 10px; padding: 8px 12px; margin-bottom: 14px;
157
+ background: color-mix(in srgb, CanvasText 5%, transparent);
158
+ white-space: pre-wrap; word-break: break-word;
159
+ }
160
+ #chat .chat-task .chat-task-label { font-size: 0.72rem; color: GrayText; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
161
+ #chat .turn { margin-bottom: 16px; }
162
+ #chat .turn-head {
163
+ display: flex; align-items: center; gap: 8px; margin: 0 0 6px;
164
+ font-size: 0.72rem; color: GrayText; text-transform: uppercase; letter-spacing: 0.04em;
165
+ }
166
+ #chat .turn-head .turn-iter { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
167
+ #chat .bubble {
168
+ border: 1px solid color-mix(in srgb, #8250df 35%, transparent);
169
+ border-radius: 10px; padding: 10px 14px; margin-bottom: 8px;
170
+ background: color-mix(in srgb, #8250df 6%, transparent);
171
+ word-break: break-word; white-space: pre-wrap;
172
+ }
173
+ #chat .bubble .bubble-kicker { font-size: 0.72rem; color: #8250df; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
174
+ #chat .bubble .bubble-kicker.error { color: #c0392b; }
175
+ #chat .bubble .bubble-kicker.ok { color: #1a7f37; }
176
+ #chat .bubble.stream-chunk { border-style: dashed; border-color: color-mix(in srgb, #8250df 25%, transparent); background: color-mix(in srgb, #8250df 3%, transparent); }
177
+ #chat .tool-call {
178
+ display: flex; align-items: baseline; gap: 8px; margin: 8px 0 0;
179
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem;
180
+ }
181
+ #chat .tool-call .tool-name { color: #8250df; font-weight: 600; }
182
+ #chat .tool-call .tool-arg { color: GrayText; word-break: break-all; }
183
+ #chat .tool-result {
184
+ margin: 2px 0 6px 22px; padding: 6px 10px; border-left: 3px solid #1a7f37;
185
+ background: color-mix(in srgb, #1a7f37 6%, transparent);
186
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem;
187
+ white-space: pre-wrap; word-break: break-word; color: color-mix(in srgb, CanvasText 85%, transparent);
188
+ }
189
+ #chat .tool-result.error { border-left-color: #c0392b; background: color-mix(in srgb, #c0392b 7%, transparent); }
190
+ #chat .tool-result .result-label { font-weight: 600; margin-bottom: 2px; }
191
+ #chat .tool-result.error .result-label { color: #c0392b; }
192
+ #chat .tool-result.ok .result-label { color: #1a7f37; }
193
+ #chat .trunc-note { color: GrayText; font-style: italic; font-size: 0.72rem; }
194
+ #chat .sys-marker {
195
+ display: flex; align-items: center; gap: 6px; margin: 10px 0;
196
+ font-size: 0.72rem; color: GrayText;
197
+ }
198
+ #chat .sys-marker .marker-label { font-weight: 600; }
199
+ #chat .sys-marker.checkpoint_saved .marker-label { color: #b8860b; }
200
+ #chat .sys-marker.paused .marker-label { color: #b8860b; }
201
+ #chat .sys-marker.resumed .marker-label { color: #1a7f37; }
202
+ #chat .sys-marker.decision_answered .marker-label { color: #1a7f37; }
203
+ #chat .sys-marker.decision_blocked .marker-label { color: #c0392b; }
204
+ #chat .sys-marker.llm_error .marker-label { color: #c0392b; }
205
+ #chat .sys-marker.session_end .marker-label { color: GrayText; }
206
+ #chat .chat-empty { color: GrayText; font-style: italic; padding: 12px 0; }
207
+ #chat .chat-err { color: #c0392b; padding: 12px 0; }
208
+
209
+ /* Inline decision-answer box (browser control plane): rendered in the feed
210
+ right after a decision_blocked event with no matching decision_answered
211
+ yet, so the human answers right where they see the question. */
212
+ .answer-box {
213
+ border: 1px solid color-mix(in srgb, #b8860b 50%, transparent);
214
+ border-radius: 8px; padding: 8px 12px; margin: 6px 0;
215
+ background: color-mix(in srgb, #b8860b 8%, transparent);
216
+ }
217
+ .answer-box .answer-suggestions { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
218
+ .answer-box .answer-suggestions button {
219
+ font-size: 0.75rem; padding: 2px 10px; border-radius: 12px; cursor: pointer;
220
+ border: 1px solid color-mix(in srgb, #b8860b 60%, transparent);
221
+ background: color-mix(in srgb, #b8860b 12%, transparent); color: inherit;
222
+ }
223
+ .answer-box .answer-input-row { display: flex; gap: 6px; align-items: center; }
224
+ .answer-box input[type="text"] { flex: 1; font-size: 0.8rem; padding: 3px 8px; }
225
+ .answer-box button { font-size: 0.75rem; padding: 3px 12px; border-radius: 6px; cursor: pointer; }
226
+ .answer-box .answer-status { font-size: 0.75rem; color: GrayText; margin-left: 6px; }
227
+
228
+ /* Checkpoints panel (browser checkpoint list/diff/restore): sits between
229
+ the detail head and the event feed. Plain rows + a unified-diff <pre> —
230
+ no framework, matching this page's ethos. */
231
+ #checkpointsPanel {
232
+ border-top: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
233
+ border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
234
+ padding: 8px 16px;
235
+ }
236
+ .checkpoints-head { display: flex; align-items: baseline; gap: 8px; font-weight: 600; font-size: 0.85rem; }
237
+ .checkpoints-head .ckpt-status { font-weight: 400; color: GrayText; font-size: 0.75rem; }
238
+ .ckpt-list { margin-top: 4px; }
239
+ .ckpt-row {
240
+ display: flex; gap: 10px; align-items: baseline; padding: 3px 0;
241
+ border-bottom: 1px solid color-mix(in srgb, CanvasText 6%, transparent);
242
+ font-size: 0.8rem; cursor: pointer;
243
+ }
244
+ .ckpt-row:hover { background: color-mix(in srgb, CanvasText 5%, transparent); }
245
+ .ckpt-row.selected { background: color-mix(in srgb, #8250df 12%, transparent); }
246
+ .ckpt-row .ckpt-msg { flex: 1; word-break: break-word; }
247
+ .ckpt-row .ckpt-date { color: GrayText; flex-shrink: 0; }
248
+ .ckpt-row .ckpt-hash { color: GrayText; flex-shrink: 0; }
249
+ .ckpt-diff { margin-top: 8px; }
250
+ .ckpt-diff-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
251
+ .ckpt-diff-head button {
252
+ font-size: 0.75rem; padding: 3px 12px; border-radius: 6px; cursor: pointer;
253
+ border: 1px solid color-mix(in srgb, CanvasText 30%, transparent); background: color-mix(in srgb, CanvasText 8%, transparent);
254
+ }
255
+ .ckpt-diff-head button:disabled { opacity: 0.45; cursor: not-allowed; }
256
+ .ckpt-diff-head button.restore-armed {
257
+ background: #c0392b; color: #fff; border-color: #c0392b; font-weight: 600;
258
+ }
259
+ .ckpt-diff-head .ckpt-restore-msg { font-size: 0.75rem; }
260
+ #ckptDiffBody {
261
+ max-height: 300px; overflow-y: auto; margin-top: 6px; padding: 8px 12px;
262
+ background: color-mix(in srgb, CanvasText 4%, transparent);
263
+ border: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
264
+ border-radius: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
265
+ font-size: 0.75rem; white-space: pre-wrap; word-break: break-word;
266
+ }
267
+ /* File browser (dashboard-file-browser-and-permissions-ui). */
268
+ #fileBrowser { margin-top: 8px; }
269
+ #fileBrowser .fb-toolbar { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap; }
270
+ #fileBrowser .fb-toolbar input[type="text"] { flex: 1; min-width: 200px; padding: 4px 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; }
271
+ #fileBrowser .fb-tree {
272
+ border: 1px solid color-mix(in srgb, CanvasText 15%, transparent);
273
+ border-radius: 8px; max-height: 380px; overflow: auto; padding: 6px 0;
274
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem;
275
+ }
276
+ #fileBrowser .fb-entry { display: flex; align-items: center; gap: 6px; padding: 2px 10px; cursor: pointer; white-space: nowrap; }
277
+ #fileBrowser .fb-entry:hover { background: color-mix(in srgb, CanvasText 6%, transparent); }
278
+ #fileBrowser .fb-entry .fb-name { overflow: hidden; text-overflow: ellipsis; }
279
+ #fileBrowser .fb-entry .fb-size { margin-left: auto; color: GrayText; font-size: 0.7rem; padding-left: 12px; }
280
+ #fileBrowser .fb-entry.dir > .fb-name { font-weight: 600; }
281
+ #fileBrowser .fb-children { padding-left: 16px; }
282
+ #fileBrowser .fb-children.collapsed { display: none; }
283
+ #fileBrowser .fb-viewer {
284
+ margin-top: 8px; border: 1px solid color-mix(in srgb, CanvasText 15%, transparent);
285
+ border-radius: 8px; overflow: hidden;
286
+ }
287
+ #fileBrowser .fb-viewer-head {
288
+ display: flex; align-items: center; gap: 8px; padding: 6px 12px;
289
+ border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
290
+ font-size: 0.75rem; color: GrayText;
291
+ }
292
+ #fileBrowser .fb-viewer-head .fb-path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; color: CanvasText; overflow: hidden; text-overflow: ellipsis; }
293
+ #fileBrowser .fb-viewer-head .fb-close { margin-left: auto; cursor: pointer; border: none; background: none; font-size: 1rem; color: GrayText; }
294
+ #fileBrowser pre.fb-content {
295
+ margin: 0; padding: 12px; max-height: 420px; overflow: auto;
296
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem; line-height: 1.45;
297
+ white-space: pre-wrap; word-break: break-word;
298
+ }
299
+ #fileBrowser .fb-note { color: GrayText; font-style: italic; padding: 8px 12px; }
300
+
301
+ /* Permissions settings (dashboard-file-browser-and-permissions-ui). */
302
+ #permissionsSettings .perm-list { display: flex; flex-direction: column; gap: 8px; }
303
+ #permissionsSettings .perm-field label { font-size: 0.75rem; color: GrayText; text-transform: uppercase; letter-spacing: 0.03em; }
304
+ #permissionsSettings .perm-field textarea {
305
+ width: 100%; min-height: 56px; padding: 6px 8px; margin-top: 3px;
306
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem;
307
+ resize: vertical;
308
+ }
309
+ #permissionsSettings .perm-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
310
+ #permissionsSettings .perm-row button { font-size: 0.8rem; padding: 3px 12px; border-radius: 6px; cursor: pointer; }
311
+ #permissionsSettings .perm-actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; flex-wrap: wrap; }
312
+ #permissionsSettings .perm-status { font-size: 0.8rem; }
313
+
314
+ /* Timeline view (work-progress-visualization). Third detail-view mode next to
315
+ the flat event log and the chat thread. Pure HTML/CSS positioning — no
316
+ charting library: iteration cells and marker dots are absolutely placed on
317
+ a % time axis, matching this page's no-dependency ethos. */
318
+ #timeline { display: none; padding: 12px 16px; font-size: 0.8rem; }
319
+ #detail.view-timeline #feed { display: none; }
320
+ #detail.view-timeline #chat { display: none; }
321
+ #detail.view-timeline #timeline { display: block; }
322
+ .tl-empty { color: GrayText; font-style: italic; padding: 12px 0; }
323
+ .tl-summary { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
324
+ .tl-chip {
325
+ border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
326
+ border-radius: 8px; padding: 4px 10px; display: flex; gap: 6px; align-items: baseline;
327
+ background: color-mix(in srgb, CanvasText 4%, transparent);
328
+ }
329
+ .tl-chip .tl-chip-label { color: GrayText; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; }
330
+ .tl-chip .tl-chip-value { font-weight: 600; }
331
+ .tl-chip.tl-cat-read .tl-chip-value { color: #4f9cf9; }
332
+ .tl-chip.tl-cat-search .tl-chip-value { color: #38bdf8; }
333
+ .tl-chip.tl-cat-write .tl-chip-value { color: #f59e0b; }
334
+ .tl-chip.tl-cat-exec .tl-chip-value { color: #10b981; }
335
+ .tl-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; color: GrayText; font-size: 0.72rem; }
336
+ .tl-legend-item { display: inline-flex; align-items: center; gap: 4px; }
337
+ .tl-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
338
+ .tl-cat-read { background: #4f9cf9; }
339
+ .tl-cat-search { background: #38bdf8; }
340
+ .tl-cat-write { background: #f59e0b; }
341
+ .tl-cat-exec { background: #10b981; }
342
+ .tl-cat-other { background: #9ca3af; }
343
+ .tl-cat-none { background: transparent; border: 1px dashed color-mix(in srgb, CanvasText 30%, transparent); }
344
+ .tl-strip {
345
+ position: relative; height: 30px; margin: 4px 0 2px;
346
+ border: 1px solid color-mix(in srgb, CanvasText 12%, transparent); border-radius: 4px;
347
+ background: color-mix(in srgb, CanvasText 3%, transparent);
348
+ }
349
+ .tl-grid { position: relative; height: 100%; }
350
+ .tl-cell { position: absolute; top: 2px; bottom: 2px; border-radius: 2px; cursor: pointer; }
351
+ .tl-cell:hover { outline: 1px solid CanvasText; }
352
+ .tl-marker-row { position: relative; height: 16px; margin: 2px 0 6px; }
353
+ .tl-marker { position: absolute; top: 0; transform: translateX(-50%); font-size: 0.8rem; cursor: default; }
354
+ .tl-mk-checkpoint { color: #b8860b; }
355
+ .tl-mk-condensed { color: #8250df; }
356
+ .tl-mk-paused { color: #b8860b; }
357
+ .tl-mk-resumed { color: #1a7f37; }
358
+ .tl-mk-decision { color: #c0392b; }
359
+ .tl-mk-start { color: #1a7f37; }
360
+ .tl-mk-end { color: GrayText; }
361
+ .tl-mk-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; background: currentColor; }
362
+ .tl-marker-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
363
+ .tl-marker-chip {
364
+ font-size: 0.72rem; padding: 1px 8px; border-radius: 10px;
365
+ border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
366
+ color: GrayText;
367
+ }
368
+ .tl-marker-chip.tl-mk-condensed { border-color: color-mix(in srgb, #8250df 50%, transparent); color: #8250df; }
369
+ .tl-marker-chip.tl-mk-decision { border-color: color-mix(in srgb, #c0392b 50%, transparent); color: #c0392b; }
370
+ .tl-marker-chip.tl-mk-paused { border-color: color-mix(in srgb, #b8860b 50%, transparent); color: #b8860b; }
371
+ .tl-marker-chip.tl-mk-resumed { border-color: color-mix(in srgb, #1a7f37 50%, transparent); color: #1a7f37; }
372
+ .tl-iter-list { margin-top: 4px; }
373
+ .tl-iter-row { border-bottom: 1px solid color-mix(in srgb, CanvasText 6%, transparent); }
374
+ .tl-iter-head { display: flex; align-items: baseline; gap: 10px; padding: 4px 2px; cursor: pointer; flex-wrap: wrap; }
375
+ .tl-iter-head:hover { background: color-mix(in srgb, CanvasText 4%, transparent); }
376
+ .tl-iter-num { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
377
+ .tl-cat-badge { font-size: 0.7rem; padding: 0 6px; border-radius: 8px; color: #fff; }
378
+ .tl-cat-badge.tl-cat-read { background: #4f9cf9; }
379
+ .tl-cat-badge.tl-cat-search { background: #38bdf8; }
380
+ .tl-cat-badge.tl-cat-write { background: #f59e0b; }
381
+ .tl-cat-badge.tl-cat-exec { background: #10b981; }
382
+ .tl-cat-badge.tl-cat-other { background: #9ca3af; }
383
+ .tl-cat-badge.tl-cat-none { background: transparent; color: GrayText; border: 1px dashed color-mix(in srgb, CanvasText 40%, transparent); }
384
+ .tl-iter-tools { color: GrayText; word-break: break-all; }
385
+ .tl-iter-err { color: #c0392b; font-weight: 600; }
386
+ .tl-iter-tokens { color: GrayText; margin-left: auto; }
387
+ .tl-iter-body { padding: 2px 2px 6px 14px; color: GrayText; }
388
+
389
+ /* Cost history (issue #29): historical per-round cost/token/duration charts,
390
+ rendered as inline SVG (no charting library). Same rules also style the
391
+ self-improvement progress section (issue #145) — shared look, separate
392
+ data source. */
393
+ #costHistory .ch-chips, #selfImprovement .ch-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
394
+ #costHistory .ch-chip, #selfImprovement .ch-chip {
395
+ border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
396
+ border-radius: 8px; padding: 4px 10px; display: flex; gap: 6px; align-items: baseline;
397
+ background: color-mix(in srgb, CanvasText 4%, transparent);
398
+ }
399
+ #costHistory .ch-chip .ch-chip-label, #selfImprovement .ch-chip .ch-chip-label { color: GrayText; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; }
400
+ #costHistory .ch-chip .ch-chip-value, #selfImprovement .ch-chip .ch-chip-value { font-weight: 600; }
401
+ #costHistory .ch-chip.ch-wasted .ch-chip-value, #selfImprovement .ch-chip.ch-wasted .ch-chip-value { color: #c0392b; }
402
+ #costHistory .ch-block, #selfImprovement .ch-block { margin-bottom: 16px; }
403
+ #costHistory .ch-block-title, #selfImprovement .ch-block-title { font-size: 0.82rem; font-weight: 600; margin-bottom: 4px; }
404
+ #costHistory .ch-block-sub, #selfImprovement .ch-block-sub { font-size: 0.72rem; color: GrayText; margin-bottom: 6px; }
405
+ #costHistory .ch-table-wrap { overflow-x: auto; }
406
+ </style>
407
+ </head>
408
+ <body>
409
+ <h1>headlesscode dashboard</h1>
410
+ <div class="sub">Local monitor + control. Summary auto-refreshes every 4s. <span id="generatedAt"></span></div>
411
+
412
+ <div id="launch">
413
+ <h2>Start a session</h2>
414
+ <div class="sub">Launch a top-level session as a detached background process (same CLI
415
+ <code>scripts/run-worker.sh</code> uses). The event feed below opens immediately so you can
416
+ watch it start thinking. Requires <code>HEADLESSCODE_OPENROUTER_API_KEY</code> to be exported in the
417
+ dashboard process's environment (<code>set -a; source .env; set +a</code> before starting the
418
+ dashboard) — launched sessions inherit it, they don't re-source <code>.env</code>.</div>
419
+ <textarea id="launchTask" rows="3" placeholder="Describe the work, e.g. 'Split plans/realtime-monitoring.md into issues and orchestrate a round against them…'" style="width:100%"></textarea>
420
+ <div style="display:flex; gap:8px; margin-top:8px; align-items:center; flex-wrap:wrap;">
421
+ <label for="launchMode" class="mono" style="color:GrayText">mode</label>
422
+ <input type="text" id="launchMode" list="modeList" value="multi-agent-orchestrator-headless" style="min-width:260px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:0.8rem">
423
+ <datalist id="modeList"></datalist>
424
+ <button id="btnStart">▶ Start</button>
425
+ <span id="launchMsg"></span>
426
+ </div>
427
+ </div>
428
+
429
+ <div class="stats" id="totals"></div>
430
+
431
+ <h2>Mode → model settings</h2>
432
+ <div class="sub">Per-mode model assignment (<code>.headlesscode/mode-models.json</code>).
433
+ Each role resolves its own model; <code>_default</code> is the fallback when a mode isn't listed.
434
+ An explicit <code>--model</code> flag always overrides this table.</div>
435
+ <div id="modeModels"></div>
436
+
437
+ <h2>Workspace files</h2>
438
+ <div class="sub">Read-only file browser. Click a directory to expand/collapse; click a file to
439
+ preview its text content (files over 30k chars or binary files are not previewable).
440
+ No file editing from the browser — deliberately read-only.</div>
441
+ <div id="fileBrowser">
442
+ <div class="fb-toolbar">
443
+ <input type="text" id="fbRepo" placeholder="workspace path (repo)" value="${repo ? String(repo).replace(/"/g, "&quot;") : ""}" />
444
+ <button id="fbLoad">Load</button>
445
+ <span id="fbMsg"></span>
446
+ </div>
447
+ <div class="empty">enter a workspace path and press Load</div>
448
+ </div>
449
+
450
+ <h2>Permissions settings</h2>
451
+ <div class="sub">Command allow/deny + protected-file permissions
452
+ (<code>.headlesscode/permissions.json</code>). When no file exists yet the page shows the
453
+ resolved built-in defaults, clearly labeled. Save writes the file; the change takes effect
454
+ on the next session (fresh <code>resolvePermissions</code> call).</div>
455
+ <div id="permissionsSettings"><div class="empty">loading…</div></div>
456
+
457
+ <h2>Blocked (awaiting a decision)</h2>
458
+ <div id="blocked"><div class="empty">none</div></div>
459
+
460
+ <h2>Round (orchestrator groups)</h2>
461
+ <div id="round"><div class="empty">no .orchestrator-state.json found for --repo</div></div>
462
+
463
+ <h2>Sessions</h2>
464
+ <div id="sessions"><div class="empty">no sessions found</div></div>
465
+
466
+ <h2>Cost history</h2>
467
+ <div class="sub">Historical per-round cost/token/duration from the central store's
468
+ <code>cost-history.jsonl</code> / <code>session-cost-history.jsonl</code>, served by
469
+ <code>/api/cost-history</code>. Uses the workspace path from the file browser above
470
+ (or <code>--repo</code>); reload with the file browser's Load button.</div>
471
+ <div id="costHistory"><div class="empty">loading…</div></div>
472
+
473
+ <h2>Self-improvement progress</h2>
474
+ <div class="sub">Recursive self-improvement loop metrics (issue #145) — computed from this
475
+ repo's own session records, <code>git log</code>, and <code>gh issue list</code>, served by
476
+ <code>/api/self-improvement</code>. Local-machine only; no external dashboard. Hourly buckets,
477
+ oldest → newest.</div>
478
+ <div id="selfImprovement"><div class="empty">loading…</div></div>
479
+
480
+ <div id="detail">
481
+ <div class="detail-head">
482
+ <span class="session-id mono" id="detailSessionId"></span>
483
+ <span class="state-pill" id="detailState"></span>
484
+ <span class="mono" id="detailSource"></span>
485
+ <span class="detail-controls">
486
+ <button class="pause" id="btnPause">⏸ Pause</button>
487
+ <button class="resume" id="btnResume">▶ Resume</button>
488
+ </span>
489
+ <button class="close" id="detailClose" title="close">✕</button>
490
+ </div>
491
+ <div class="detail-tabs">
492
+ <button id="tabLog" class="active">Event log</button>
493
+ <button id="tabChat">Chat thread</button>
494
+ <button id="tabTimeline">Timeline</button>
495
+ </div>
496
+ <div id="checkpointsPanel">
497
+ <div class="checkpoints-head">📸 Checkpoints <span class="ckpt-status" id="ckptStatus"></span></div>
498
+ <div class="ckpt-list" id="ckptList"></div>
499
+ <div class="ckpt-diff" id="ckptDiff" style="display:none">
500
+ <div class="ckpt-diff-head">
501
+ <span class="mono" id="ckptDiffTitle"></span>
502
+ <button id="btnRestore" title="reverts real workspace files to this checkpoint">Restore</button>
503
+ <span class="ckpt-restore-msg" id="ckptRestoreMsg"></span>
504
+ </div>
505
+ <pre id="ckptDiffBody"></pre>
506
+ </div>
507
+
508
+ </div>
509
+ <div id="feed"><div class="feed-empty">select a session to watch its live event feed</div></div>
510
+ <div id="chat"><div class="chat-empty">select a session to watch its live event feed</div></div>
511
+ <div id="timeline"><div class="tl-empty">select a session to watch its live event feed</div></div>
512
+ </div>
513
+
514
+ <div id="error"></div>
515
+
516
+ <script>
517
+ function fmtCost(n) { return "$" + (n ?? 0).toFixed(6); }
518
+ function fmtInt(n) { return (n ?? 0).toLocaleString(); }
519
+ function esc(s) {
520
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
521
+ }
522
+
523
+ function fmtPct(n) { return (n ?? 0).toFixed(1) + "%"; }
524
+
525
+ // ── Live event feed (Phase 1) ────────────────────────────────────────────────
526
+ // One small map from event type to a short label + icon, so the feed reads
527
+ // at a glance (tool call -> "🔧 tool: args", checkpoint -> "📸 checkpoint").
528
+ const EVENT_LABELS = {
529
+ session_start: "🚀 session start",
530
+ iteration_start: "🔄 iteration",
531
+ llm_response: "🤖 llm response",
532
+ llm_stream_chunk: "⋯ stream chunk",
533
+ llm_error: "💥 llm error",
534
+ tool_call: "🔧 tool call",
535
+ tool_result: "✅ tool result",
536
+ checkpoint_saved: "📸 checkpoint",
537
+ condensed: "🧠 condensed",
538
+ decision_blocked: "⛔ blocked (question)",
539
+ decision_answered: "💬 question answered",
540
+ paused: "⏸ paused",
541
+ resumed: "▶ resumed",
542
+ session_end: "🏁 session end",
543
+ };
544
+
545
+ // Current detail view state: null when closed.
546
+ let detail = null; // { sessionId, source, nextOffset, lastEventCount, userScrolledUp }
547
+
548
+ function openDetail(sessionId, source, status, workspace) {
549
+ closeDetail();
550
+ detail = { sessionId, source, nextOffset: 0, lastEventCount: 0, userScrolledUp: false, status, workspace, allEvents: [], view: "chat" };
551
+
552
+ document.getElementById("detail").style.display = "block";
553
+ document.getElementById("detailSessionId").textContent = sessionId;
554
+ document.getElementById("detailSource").textContent = "· " + (source || "");
555
+ updateDetailControls();
556
+ const feed = document.getElementById("feed");
557
+ feed.innerHTML = '<div class="feed-empty">loading events…</div>';
558
+ document.getElementById("chat").innerHTML = '<div class="chat-empty">loading events…</div>';
559
+ document.getElementById("timeline").innerHTML = '<div class="tl-empty">loading events…</div>';
560
+ // Reset scroll-tracking state for the new session.
561
+ detail.userScrolledUp = false;
562
+ feed.scrollTop = 0;
563
+ setView(detail.view);
564
+ pollEvents();
565
+ // The checkpoints panel loads alongside the event feed.
566
+ loadCheckpoints();
567
+ }
568
+
569
+ function closeDetail() {
570
+ if (detail) {
571
+ detail = null;
572
+ }
573
+ // Reset checkpoint state so the panel shows nothing stale next open.
574
+ ckptEntries = [];
575
+ ckptSelectedHash = null;
576
+ ckptRestoreArmed = false;
577
+ document.getElementById("detail").style.display = "none";
578
+ }
579
+
580
+ function updateDetailControls() {
581
+ const paused = detail && (detail.status === "paused" || detail.paused);
582
+ const running = detail && (detail.status === "running" || detail.running);
583
+ const done = detail && (detail.status === "done" || detail.status === "success" || detail.status === "error" || detail.status === "failed" || detail.status === "budget");
584
+ const btnPause = document.getElementById("btnPause");
585
+ const btnResume = document.getElementById("btnResume");
586
+ btnPause.disabled = !running || !!paused;
587
+ btnResume.disabled = !paused;
588
+ const pill = document.getElementById("detailState");
589
+ if (paused) {
590
+ pill.textContent = "paused";
591
+ pill.className = "state-pill paused";
592
+ } else if (running) {
593
+ pill.textContent = "running";
594
+ pill.className = "state-pill running";
595
+ } else if (done) {
596
+ pill.textContent = "done";
597
+ pill.className = "state-pill done";
598
+ } else {
599
+ pill.textContent = detail ? detail.status || "unknown" : "";
600
+ pill.className = "state-pill done";
601
+ }
602
+ }
603
+
604
+ function renderEvent(e) {
605
+ const label = EVENT_LABELS[e.type] || e.type;
606
+ const iter = typeof e.iteration === "number" ? '<span class="ev-iter">[' + esc(e.iteration) + "]</span> " : "";
607
+ let body = "";
608
+ switch (e.type) {
609
+ case "tool_call":
610
+ body = esc(e.tool) + (e.args ? ": " + esc(e.args) : "") + (e.argsTruncated ? ' <span class="trunc-note">(truncated)</span>' : "");
611
+ break;
612
+ case "tool_result":
613
+ body = esc(e.tool) + (e.isError ? " (error)" : "") + ": " + esc(String(e.result || "").slice(0, 400)) + (e.resultTruncated ? ' <span class="trunc-note">(truncated)</span>' : "");
614
+ break;
615
+ case "llm_response":
616
+ body = (e.hadToolCalls ? "tool_calls " : "text ") +
617
+ (e.textPreview ? "· " + esc(String(e.textPreview).slice(0, 300)) + (e.textTruncated ? ' <span class="trunc-note">(truncated)</span>' : "") : "") +
618
+ (typeof e.inputTokens === "number" ? " · in=" + esc(e.inputTokens) + " out=" + esc(e.outputTokens ?? 0) + " cached=" + esc(e.cachedTokens ?? 0) : "");
619
+ break;
620
+ case "llm_stream_chunk":
621
+ body = (e.kind || "") + (e.chunk ? " · " + esc(String(e.chunk).slice(0, 200)) + (e.chunkTruncated ? ' <span class="trunc-note">(truncated)</span>' : "") : "");
622
+ break;
623
+ case "checkpoint_saved":
624
+ body = e.iteration === 0 ? "baseline" : "after iteration " + esc(e.iteration);
625
+ break;
626
+ case "condensed":
627
+ body = "messages " + esc(e.messagesBefore) + "→" + esc(e.messagesAfter) +
628
+ (typeof e.inputTokens === "number" ? " · in=" + esc(e.inputTokens) + " out=" + esc(e.outputTokens ?? 0) + " cached=" + esc(e.cachedTokens ?? 0) : "");
629
+ break;
630
+ case "decision_blocked":
631
+ body = esc(e.question || "") + (e.suggestions && e.suggestions.length ? " · suggestions: " + esc(e.suggestions.join(" | ")) : "");
632
+ break;
633
+ case "decision_answered":
634
+ body = e.timedOut ? "timed out — worker moved on autonomously" : "answer: " + esc(e.answer || "");
635
+ break;
636
+ case "paused":
637
+ case "resumed":
638
+ body = e.reason ? esc(e.reason) : "";
639
+ break;
640
+ case "session_start":
641
+ body = esc(e.mode || "") + " · " + esc(e.model || "") + (e.workspaceRoot ? " · " + esc(e.workspaceRoot) : "");
642
+ break;
643
+ case "session_end":
644
+ body = "status=" + esc(e.status) + " · iterations=" + esc(e.iterations) + " · cost=" + fmtCost(e.costUsd) +
645
+ " · in=" + esc(e.inputTokens) + " out=" + esc(e.outputTokens);
646
+ break;
647
+ default:
648
+ body = esc(JSON.stringify(e).slice(0, 400));
649
+ }
650
+ const div = document.createElement("div");
651
+ div.className = "ev " + esc(e.type) + (e.isError ? " is-error" : "");
652
+ div.dataset.ts = e.ts || "";
653
+ // NOTE: the ev-body class is built with a variable to avoid a backslash-quote
654
+ // escape INSIDE this page's outer template literal — \" in a template literal
655
+ // is unescaped at runtime to a bare quote, which would break the served JS
656
+ // (pre-existing bug fixed here; the flat log must keep working).
657
+ const evBodyClass = "ev-body";
658
+ div.innerHTML = '<span class="ev-ts">' + esc(e.ts) + "</span><span class=" + evBodyClass + ">" + iter + '<span class="ev-label">' + esc(label) + "</span> " + body + "</span>";
659
+ return div;
660
+ }
661
+
662
+ function feedAutoScrolls() {
663
+ const feed = document.getElementById("feed");
664
+ // Auto-scroll only when the user is already at the bottom (within ~40px) —
665
+ // never yank the view back down while they're reading history above.
666
+ return feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
667
+ }
668
+
669
+ async function pollEvents() {
670
+ if (!detail) return;
671
+ try {
672
+ const params = new URLSearchParams();
673
+ if (detail.nextOffset > 0) params.set("since", String(detail.nextOffset));
674
+ const res = await fetch("/api/session/" + encodeURIComponent(detail.sessionId) + "/events?" + params.toString());
675
+ if (!res.ok) throw new Error("HTTP " + res.status);
676
+ const data = await res.json();
677
+ const feed = document.getElementById("feed");
678
+ const newEvents = data.events || [];
679
+ // Accumulate the full feed: the chat view groups events into turns, so it
680
+ // needs everything seen so far, not just the incremental slice. The flat
681
+ // event-log view still appends incrementally as before.
682
+ detail.allEvents = (detail.allEvents || []).concat(newEvents);
683
+ if (detail.lastEventCount === 0 && newEvents.length === 0) {
684
+ feed.innerHTML = '<div class="feed-empty">no events yet — waiting for the worker…</div>';
685
+ renderChat();
686
+ } else {
687
+ const shouldScroll = feedAutoScrolls();
688
+ if (feed.querySelector(".feed-empty")) feed.innerHTML = "";
689
+ for (const e of newEvents) {
690
+ feed.appendChild(renderEvent(e));
691
+ // Browser control plane: if this is a decision_blocked and no
692
+ // matching decision_answered has arrived yet, render the inline
693
+ // answer box right here in the feed.
694
+ if (e.type === "decision_blocked" && !answeredAlready(feed, e)) {
695
+ renderAnswerBox(feed, e);
696
+ }
697
+ }
698
+ detail.lastEventCount = (detail.lastEventCount || 0) + newEvents.length;
699
+ if (shouldScroll) feed.scrollTop = feed.scrollHeight;
700
+ renderChat();
701
+ }
702
+ detail.nextOffset = data.nextOffset;
703
+ // Derive running/paused state from the feed (the summary row may lag).
704
+ const types = newEvents.map((e) => e.type);
705
+ if (types.includes("paused")) detail.paused = true;
706
+ if (types.includes("resumed")) detail.paused = false;
707
+ if (types.includes("session_end")) { detail.status = "done"; detail.paused = false; }
708
+ // Prune answer boxes for decision_blocked events that now HAVE a matching
709
+ // decision_answered (the answer arrived — the box is moot).
710
+ pruneAnsweredBoxes();
711
+ // The timeline re-renders from allEvents on every poll, like the chat view.
712
+ renderTimeline();
713
+ updateDetailControls();
714
+ } catch (err) {
715
+ const feed = document.getElementById("feed");
716
+ if (feed) {
717
+ const empty = feed.querySelector(".feed-empty");
718
+ if (empty) empty.textContent = "failed to load events: " + err;
719
+ }
720
+ }
721
+ }
722
+
723
+ async function controlSession(action) {
724
+ if (!detail) return;
725
+ const res = await fetch("/api/session/" + encodeURIComponent(detail.sessionId) + "/" + action, { method: "POST" });
726
+ if (!res.ok) {
727
+ alert("Failed to " + action + ": HTTP " + res.status);
728
+ return;
729
+ }
730
+ // Optimistic UI: pause immediately reflects the intent; the next event poll
731
+ // confirms via the paused/resumed events.
732
+ if (action === "pause") detail.paused = true;
733
+ if (action === "resume") detail.paused = false;
734
+ updateDetailControls();
735
+ }
736
+
737
+ // ── Chat-thread view (dashboard-chat-ui) ────────────────────────────────────
738
+ // Groups the SAME event feed data into conversation turns (see the grouping
739
+ // rules in src/dashboard/chat-thread.ts) and renders them as chat bubbles:
740
+ // the user's task, then per turn the assistant's text + its tool calls and
741
+ // their results inline, with system-level events as small markers. The flat
742
+ // event-log view (renderEvent above) remains available via the tab toggle.
743
+ // This is client-side-only rendering of data that's already arriving via the
744
+ // existing poll — no new backend source.
745
+
746
+ // Short arg summary for a tool call, matching the server-side summarizeToolArg
747
+ // style (the feed already carries this as "args"; cap here for safety).
748
+ function chatSummaryArg(e) {
749
+ if (!e || e.type !== "tool_call") return "";
750
+ const raw = String(e.args || "");
751
+ const trimmed = raw.trim();
752
+ if (trimmed.length <= 80) return trimmed;
753
+ return trimmed.slice(0, 80) + "…";
754
+ }
755
+
756
+ function setView(view) {
757
+ if (!detail) return;
758
+ detail.view = view;
759
+ const detailEl = document.getElementById("detail");
760
+ detailEl.classList.toggle("view-chat", view === "chat");
761
+ detailEl.classList.toggle("view-timeline", view === "timeline");
762
+ document.getElementById("tabLog").classList.toggle("active", view === "log");
763
+ document.getElementById("tabChat").classList.toggle("active", view === "chat");
764
+ document.getElementById("tabTimeline").classList.toggle("active", view === "timeline");
765
+ }
766
+
767
+ // Render one chat "turn" block: the model's text bubble (plus a reasoning
768
+ // bubble if streaming-and-reasoning has landed and the feed carries one),
769
+ // then its tool calls and results inline.
770
+ function renderChatTurn(turn) {
771
+ const wrap = document.createElement("div");
772
+ wrap.className = "turn";
773
+
774
+ const head = document.createElement("div");
775
+ head.className = "turn-head";
776
+ const ts = turn.turnStart.ts || "";
777
+ const headText = (turn.turnStart.type === "decision_blocked")
778
+ ? "⛔ question"
779
+ : "iteration " + (typeof turn.iteration === "number" ? turn.iteration : "?");
780
+ head.innerHTML = '<span class="turn-iter">' + esc(headText) + "</span><span>" + esc(ts) + "</span>";
781
+ wrap.appendChild(head);
782
+
783
+ for (const e of turn.events) {
784
+ if (e.type === "llm_response") {
785
+ const bubble = document.createElement("div");
786
+ bubble.className = "bubble";
787
+ const reasoning = typeof e.reasoning === "string" && e.reasoning ? e.reasoning : "";
788
+ const text = typeof e.textPreview === "string" ? e.textPreview : "";
789
+ const toks = typeof e.inputTokens === "number"
790
+ ? '<div class="trunc-note">in=' + esc(e.inputTokens) + " · out=" + esc(e.outputTokens ?? 0) + " · cached=" + esc(e.cachedTokens ?? 0) + "</div>"
791
+ : "";
792
+ if (reasoning) {
793
+ bubble.innerHTML =
794
+ '<div class="bubble-kicker">thinking</div><div>' + esc(reasoning) + "</div>" +
795
+ (text ? '<div style="margin-top:8px">' + esc(text) + "</div>" : "") + toks;
796
+ } else if (text) {
797
+ bubble.innerHTML = '<div>' + esc(text) + "</div>" + toks;
798
+ } else {
799
+ bubble.innerHTML = '<div class="trunc-note">(tool-call turn — no text)</div>' + toks;
800
+ }
801
+ if (e.textTruncated) {
802
+ bubble.appendChild(elTruncNote());
803
+ }
804
+ wrap.appendChild(bubble);
805
+ } else if (e.type === "llm_stream_chunk") {
806
+ // Streaming chunk: render as a live-typing bubble. The turn's final
807
+ // llm_response carries the assembled text, so these are purely the
808
+ // "live" effect between iteration_start and llm_response.
809
+ const stream = document.createElement("div");
810
+ stream.className = "bubble stream-chunk";
811
+ const label = e.kind === "reasoning" ? "thinking…" : e.kind === "tool" ? "tool…" : "typing…";
812
+ stream.innerHTML =
813
+ '<div class="bubble-kicker">' + esc(label) + "</div>" +
814
+ "<div>" + esc(String(e.chunk || "")) + "</div>" +
815
+ (e.chunkTruncated ? '<div class="trunc-note">(truncated)</div>' : "");
816
+ wrap.appendChild(stream);
817
+ } else if (e.type === "decision_answered") {
818
+ // The answer to a blocked question: inline in the turn that asked it.
819
+ const marker = document.createElement("div");
820
+ marker.className = "sys-marker decision_answered";
821
+ marker.innerHTML = '<span class="marker-label">💬 answer</span><span>' + esc(e.timedOut ? "timed out — worker moved on autonomously" : (e.answer || "")) + "</span>";
822
+ wrap.appendChild(marker);
823
+ } else if (e.type === "tool_call") {
824
+ const call = document.createElement("div");
825
+ call.className = "tool-call";
826
+ const arg = chatSummaryArg(e);
827
+ call.innerHTML = '<span class="tool-name">' + esc(e.tool) + "</span>" + (arg ? '<span class="tool-arg">' + esc(arg) + "</span>" : "") + (e.argsTruncated ? ' <span class="trunc-note">(truncated)</span>' : "");
828
+ wrap.appendChild(call);
829
+ } else if (e.type === "tool_result") {
830
+ const res = document.createElement("div");
831
+ res.className = "tool-result" + (e.isError ? " error" : " ok");
832
+ const label = (e.isError ? "⚠ error" : "✓ result") + (e.tool ? " · " + esc(e.tool) : "");
833
+ const content = String(e.result || "");
834
+ res.innerHTML = '<div class="result-label">' + label + "</div>" + (content ? "<div>" + esc(content) + "</div>" : "") + (e.resultTruncated ? '<div class="trunc-note">(truncated)</div>' : "");
835
+ wrap.appendChild(res);
836
+ } else {
837
+ // Unknown event inside a turn: fall back to a system marker.
838
+ wrap.appendChild(renderChatSystem(e));
839
+ }
840
+ }
841
+ return wrap;
842
+ }
843
+
844
+ function elTruncNote() {
845
+ const d = document.createElement("div");
846
+ d.className = "trunc-note";
847
+ d.textContent = "(truncated)";
848
+ return d;
849
+ }
850
+
851
+ // Render a system-level event as a small inline marker, not a chat bubble.
852
+ function renderChatSystem(e) {
853
+ const marker = document.createElement("div");
854
+ marker.className = "sys-marker " + esc(e.type);
855
+ const labels = {
856
+ session_start: "🚀 session start",
857
+ checkpoint_saved: "📸 checkpoint",
858
+ condensed: "🧠 condensed",
859
+ paused: "⏸ paused",
860
+ resumed: "▶ resumed",
861
+ llm_error: "💥 llm error",
862
+ decision_blocked: "⛔ blocked (question)",
863
+ session_end: "🏁 session end",
864
+ };
865
+ const label = labels[e.type] || e.type;
866
+ let body = "";
867
+ switch (e.type) {
868
+ case "checkpoint_saved":
869
+ body = e.iteration === 0 ? "baseline" : "after iteration " + esc(e.iteration);
870
+ break;
871
+ case "condensed":
872
+ body = "messages " + esc(e.messagesBefore) + "→" + esc(e.messagesAfter) +
873
+ (typeof e.inputTokens === "number" ? " · in=" + esc(e.inputTokens) + " out=" + esc(e.outputTokens ?? 0) + " cached=" + esc(e.cachedTokens ?? 0) : "");
874
+ break;
875
+ case "paused":
876
+ case "resumed":
877
+ body = e.reason ? esc(e.reason) : "";
878
+ break;
879
+ case "llm_error":
880
+ body = esc(String(e.message || e.error || ""));
881
+ break;
882
+ case "session_start":
883
+ body = esc(e.mode || "") + " · " + esc(e.model || "");
884
+ break;
885
+ case "session_end":
886
+ body = "status=" + esc(e.status) + " · iterations=" + esc(e.iterations) + " · cost=" + fmtCost(e.costUsd);
887
+ break;
888
+ case "decision_blocked":
889
+ body = esc(e.question || "") + (e.suggestions && e.suggestions.length ? " · suggestions: " + esc(e.suggestions.join(" | ")) : "");
890
+ break;
891
+ default:
892
+ body = esc(JSON.stringify(e).slice(0, 200));
893
+ }
894
+ marker.innerHTML = '<span class="marker-label">' + esc(label) + "</span>" + (body ? "<span>" + body + "</span>" : "");
895
+ return marker;
896
+ }
897
+
898
+ // Re-render the whole chat thread from the accumulated event feed. Called on
899
+ // every poll (the feed arrives incrementally, but grouping is order-dependent
900
+ // — a turn only becomes complete once its final tool_result lands, so
901
+ // re-grouping from scratch is the simplest correct approach).
902
+ function renderChat() {
903
+ if (!detail) return;
904
+ const chat = document.getElementById("chat");
905
+ const events = detail.allEvents || [];
906
+ if (events.length === 0) {
907
+ chat.innerHTML = '<div class="chat-empty">no events yet — waiting for the worker…</div>';
908
+ return;
909
+ }
910
+ const shouldScroll = chat.scrollHeight - chat.scrollTop - chat.clientHeight < 40;
911
+ chat.innerHTML = "";
912
+
913
+ // Task bubble (the user's message) when the feed has a session_start.
914
+ const start = events.find((e) => e.type === "session_start");
915
+ if (start && typeof start.task === "string" && start.task) {
916
+ const task = document.createElement("div");
917
+ task.className = "chat-task";
918
+ task.innerHTML = '<div class="chat-task-label">task</div><div>' + esc(start.task) + "</div>" + (start.taskTruncated ? '<div class="trunc-note">(truncated)</div>' : "");
919
+ chat.appendChild(task);
920
+ }
921
+
922
+ // Group + render. Mirrors src/dashboard/chat-thread.ts's grouping rules
923
+ // (kept as pure TS there + unit-tested; this inline copy is the page's
924
+ // no-build-step vanilla-JS rendition of the same rules).
925
+ const blocks = groupEvents(events);
926
+ for (const block of blocks) {
927
+ if (block.kind === "turn") {
928
+ chat.appendChild(renderChatTurn(block));
929
+ } else {
930
+ chat.appendChild(renderChatSystem(block.event));
931
+ }
932
+ }
933
+ if (shouldScroll) chat.scrollTop = chat.scrollHeight;
934
+ }
935
+
936
+ // Group a flat event array into chat blocks — inline JS twin of the tested
937
+ // pure function in src/dashboard/chat-thread.ts (this page has no build step,
938
+ // so the grouping is duplicated rather than imported).
939
+ function groupEvents(events) {
940
+ const blocks = [];
941
+ let task = "";
942
+ let current = null;
943
+ const closeTurn = () => {
944
+ if (current) {
945
+ blocks.push({ kind: "turn", iteration: current.iteration, turnStart: current.turnStart, events: current.events });
946
+ current = null;
947
+ }
948
+ };
949
+ for (const e of events) {
950
+ switch (e.type) {
951
+ case "session_start":
952
+ task = typeof e.task === "string" ? e.task : "";
953
+ blocks.push({ kind: "system", event: e });
954
+ break;
955
+ case "iteration_start":
956
+ closeTurn();
957
+ current = { iteration: e.iteration, turnStart: e, events: [] };
958
+ break;
959
+ case "decision_blocked":
960
+ closeTurn();
961
+ current = { iteration: e.iteration, turnStart: e, events: [] };
962
+ break;
963
+ case "tool_result": {
964
+ if (current && current.events.length > 0) {
965
+ const last = current.events[current.events.length - 1];
966
+ if (last.type === "tool_call") {
967
+ current.events.push(e);
968
+ break;
969
+ }
970
+ }
971
+ blocks.push({ kind: "system", event: e });
972
+ break;
973
+ }
974
+ case "session_end":
975
+ case "llm_error":
976
+ case "checkpoint_saved":
977
+ case "paused":
978
+ case "resumed":
979
+ closeTurn();
980
+ blocks.push({ kind: "system", event: e });
981
+ break;
982
+ case "llm_response":
983
+ case "tool_call":
984
+ case "decision_answered":
985
+ case "llm_stream_chunk":
986
+ if (current) {
987
+ current.events.push(e);
988
+ } else {
989
+ blocks.push({ kind: "system", event: e });
990
+ }
991
+ break;
992
+ default:
993
+ closeTurn();
994
+ blocks.push({ kind: "system", event: e });
995
+ break;
996
+ }
997
+ }
998
+ closeTurn();
999
+ return blocks;
1000
+ }
1001
+
1002
+ // ── Timeline view (work-progress-visualization) ─────────────────────────────
1003
+ // Reduces the SAME event feed into a per-iteration "shape" model: one cell per
1004
+ // iteration colored by its dominant tool type, a running read/write/exec/
1005
+ // search tool-mix summary, and structural markers (checkpoints, condensation,
1006
+ // pause/resume, decisions) positioned on a real time axis. Inline vanilla-JS
1007
+ // twin of src/dashboard/timeline.ts (no build step — duplicated, not imported;
1008
+ // keep the two in sync). Pure HTML/CSS positioning — no charting library.
1009
+ const TL_READ = new Set(["read_file", "list_files", "outline", "go_to_definition", "find_references", "import_graph"]);
1010
+ const TL_WRITE = new Set(["write_to_file", "apply_diff", "search_replace", "edit_file", "set_indentation"]);
1011
+ const TL_EXEC = new Set(["execute_command"]);
1012
+ const TL_SEARCH = new Set(["codebase_search"]);
1013
+
1014
+ function tlCategory(tool) {
1015
+ if (TL_READ.has(tool)) return "read";
1016
+ if (TL_WRITE.has(tool)) return "write";
1017
+ if (TL_EXEC.has(tool)) return "exec";
1018
+ if (TL_SEARCH.has(tool)) return "search";
1019
+ return "other";
1020
+ }
1021
+
1022
+ // Tie-break priority for equal call counts: the write that appears once
1023
+ // alongside a read that appears once is still the iteration's point.
1024
+ const TL_TIE = ["write", "exec", "search", "read", "other"];
1025
+
1026
+ function tlToTs(t) {
1027
+ if (typeof t === "number") return t;
1028
+ const ms = Date.parse(String(t));
1029
+ return Number.isFinite(ms) ? ms : 0;
1030
+ }
1031
+
1032
+ // Inline twin of timeline.ts's buildTimeline: { iterations, markers, totals }.
1033
+ function buildTimeline(events) {
1034
+ const m = {
1035
+ iterations: [],
1036
+ markers: [],
1037
+ totals: { read: 0, write: 0, exec: 0, search: 0, other: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
1038
+ task: "", model: "", mode: "",
1039
+ };
1040
+ const categoryCounts = new Map();
1041
+ const toolCounts = new Map();
1042
+ const iterMeta = new Map();
1043
+ const ensure = (iteration) => {
1044
+ if (!iterMeta.has(iteration)) {
1045
+ iterMeta.set(iteration, { firstTs: Infinity, lastTs: -Infinity, inputTokens: 0, outputTokens: 0, cachedTokens: 0, hasError: false });
1046
+ }
1047
+ };
1048
+ for (const e of events) {
1049
+ const ts = tlToTs(e.ts);
1050
+ switch (e.type) {
1051
+ case "session_start":
1052
+ m.task = typeof e.task === "string" ? e.task : "";
1053
+ m.model = typeof e.model === "string" ? e.model : "";
1054
+ m.mode = typeof e.mode === "string" ? e.mode : "";
1055
+ m.markers.push({ kind: "start", ts: ts });
1056
+ break;
1057
+ case "session_end":
1058
+ m.markers.push({ kind: "end", ts: ts, status: e.status });
1059
+ break;
1060
+ case "iteration_start":
1061
+ if (typeof e.iteration !== "number") break;
1062
+ ensure(e.iteration);
1063
+ iterMeta.get(e.iteration).firstTs = Math.min(iterMeta.get(e.iteration).firstTs, ts);
1064
+ iterMeta.get(e.iteration).lastTs = Math.max(iterMeta.get(e.iteration).lastTs, ts);
1065
+ break;
1066
+ case "llm_response":
1067
+ if (typeof e.iteration !== "number") break;
1068
+ ensure(e.iteration);
1069
+ {
1070
+ const meta = iterMeta.get(e.iteration);
1071
+ meta.firstTs = Math.min(meta.firstTs, ts);
1072
+ meta.lastTs = Math.max(meta.lastTs, ts);
1073
+ const input = typeof e.inputTokens === "number" ? e.inputTokens : 0;
1074
+ const output = typeof e.outputTokens === "number" ? e.outputTokens : 0;
1075
+ const cached = typeof e.cachedTokens === "number" ? e.cachedTokens : 0;
1076
+ meta.inputTokens += input;
1077
+ meta.outputTokens += output;
1078
+ meta.cachedTokens += cached;
1079
+ m.totals.inputTokens += input;
1080
+ m.totals.outputTokens += output;
1081
+ m.totals.cachedTokens += cached;
1082
+ }
1083
+ break;
1084
+ case "tool_call":
1085
+ if (typeof e.iteration !== "number") break;
1086
+ ensure(e.iteration);
1087
+ {
1088
+ const meta = iterMeta.get(e.iteration);
1089
+ meta.firstTs = Math.min(meta.firstTs, ts);
1090
+ meta.lastTs = Math.max(meta.lastTs, ts);
1091
+ const tool = typeof e.tool === "string" ? e.tool : "unknown";
1092
+ const cat = tlCategory(tool);
1093
+ if (!categoryCounts.has(e.iteration)) categoryCounts.set(e.iteration, {});
1094
+ categoryCounts.get(e.iteration)[cat] = (categoryCounts.get(e.iteration)[cat] || 0) + 1;
1095
+ if (!toolCounts.has(e.iteration)) toolCounts.set(e.iteration, {});
1096
+ toolCounts.get(e.iteration)[tool] = (toolCounts.get(e.iteration)[tool] || 0) + 1;
1097
+ m.totals[cat] += 1;
1098
+ m.totals.toolCalls += 1;
1099
+ }
1100
+ break;
1101
+ case "tool_result":
1102
+ if (typeof e.iteration === "number" && iterMeta.has(e.iteration) && e.isError === true) {
1103
+ iterMeta.get(e.iteration).hasError = true;
1104
+ }
1105
+ break;
1106
+ case "checkpoint_saved":
1107
+ m.markers.push({ kind: "checkpoint", ts: ts, iteration: e.iteration });
1108
+ break;
1109
+ case "condensed":
1110
+ m.markers.push({ kind: "condensed", ts: ts, iteration: e.iteration, messagesBefore: e.messagesBefore, messagesAfter: e.messagesAfter, inputTokens: e.inputTokens, outputTokens: e.outputTokens, cachedTokens: e.cachedTokens });
1111
+ break;
1112
+ case "paused":
1113
+ m.markers.push({ kind: "paused", ts: ts, iteration: e.iteration, reason: e.reason });
1114
+ break;
1115
+ case "resumed":
1116
+ m.markers.push({ kind: "resumed", ts: ts, iteration: e.iteration, reason: e.reason });
1117
+ break;
1118
+ case "decision_blocked":
1119
+ m.markers.push({ kind: "decision", ts: ts, question: e.question });
1120
+ break;
1121
+ case "decision_answered":
1122
+ m.markers.push({ kind: "decision", ts: ts, answer: e.answer, timedOut: e.timedOut });
1123
+ break;
1124
+ }
1125
+ }
1126
+ for (const [iteration, meta] of iterMeta) {
1127
+ const cc = categoryCounts.get(iteration) || {};
1128
+ const tc = toolCounts.get(iteration) || {};
1129
+ let dominant = "none";
1130
+ if (Object.keys(cc).length > 0) {
1131
+ dominant = Object.keys(cc).sort((a, b) => (cc[b] - cc[a]) || (TL_TIE.indexOf(a) - TL_TIE.indexOf(b)))[0];
1132
+ }
1133
+ const calls = Object.keys(tc)
1134
+ .map((name) => ({ name: name, count: tc[name] }))
1135
+ .sort((a, b) => (b.count - a.count) || a.name.localeCompare(b.name));
1136
+ m.iterations.push({
1137
+ iteration: iteration,
1138
+ category: dominant,
1139
+ toolCalls: calls,
1140
+ toolCount: calls.reduce((s, c) => s + c.count, 0),
1141
+ firstTs: meta.firstTs === Infinity ? 0 : meta.firstTs,
1142
+ lastTs: meta.lastTs === -Infinity ? 0 : meta.lastTs,
1143
+ inputTokens: meta.inputTokens,
1144
+ outputTokens: meta.outputTokens,
1145
+ cachedTokens: meta.cachedTokens,
1146
+ hasError: meta.hasError,
1147
+ });
1148
+ }
1149
+ return m;
1150
+ }
1151
+
1152
+ const TL_LABEL = { read: "read", write: "write", exec: "exec", search: "search", other: "other", none: "idle" };
1153
+
1154
+ function tlBounds(m) {
1155
+ let min = Infinity, max = -Infinity;
1156
+ for (const it of m.iterations) {
1157
+ min = Math.min(min, it.firstTs);
1158
+ max = Math.max(max, it.lastTs);
1159
+ }
1160
+ for (const mk of m.markers) {
1161
+ min = Math.min(min, mk.ts);
1162
+ max = Math.max(max, mk.ts);
1163
+ }
1164
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) {
1165
+ min = 0;
1166
+ max = 1;
1167
+ }
1168
+ return { min: min, max: max, span: max - min };
1169
+ }
1170
+
1171
+ function tlMarkerGlyph(mk) {
1172
+ switch (mk.kind) {
1173
+ case "start": return "🚀";
1174
+ case "checkpoint": return "📸";
1175
+ case "condensed": return "⟲";
1176
+ case "paused": return "⏸";
1177
+ case "resumed": return "▶";
1178
+ case "decision": return "⛔";
1179
+ case "end": return "🏁";
1180
+ default: return "•";
1181
+ }
1182
+ }
1183
+
1184
+ function tlMarkerTitle(mk) {
1185
+ switch (mk.kind) {
1186
+ case "start": return "session start";
1187
+ case "checkpoint":
1188
+ return "checkpoint" + (typeof mk.iteration === "number" ? (mk.iteration === 0 ? " (baseline)" : " (iter " + mk.iteration + ")") : "");
1189
+ case "condensed":
1190
+ return "condensed " + esc(mk.messagesBefore) + "→" + esc(mk.messagesAfter) + " messages" + (mk.inputTokens ? " · in=" + fmtInt(mk.inputTokens) + " out=" + fmtInt(mk.outputTokens) : "");
1191
+ case "paused": return "paused" + (mk.reason ? ": " + esc(mk.reason) : "");
1192
+ case "resumed": return "resumed" + (mk.reason ? ": " + esc(mk.reason) : "");
1193
+ case "decision":
1194
+ return mk.question
1195
+ ? "blocked: " + esc(String(mk.question).slice(0, 60))
1196
+ : (mk.timedOut ? "question timed out" : "answered: " + esc(String(mk.answer || "").slice(0, 60)));
1197
+ case "end": return "session end" + (mk.status ? " (" + esc(mk.status) + ")" : "");
1198
+ default: return mk.kind;
1199
+ }
1200
+ }
1201
+
1202
+ // Tool-mix summary chips: the read/write ratio as real numbers, not eyeballed.
1203
+ function tlSummary(m) {
1204
+ const t = m.totals;
1205
+ const wrap = document.createElement("div");
1206
+ wrap.className = "tl-summary";
1207
+ const chips = [
1208
+ ["iterations", String(m.iterations.length), ""],
1209
+ ["tool calls", fmtInt(t.toolCalls), ""],
1210
+ ["reads", fmtInt(t.read), "read"],
1211
+ ["searches", fmtInt(t.search), "search"],
1212
+ ["writes", fmtInt(t.write), "write"],
1213
+ ["commands", fmtInt(t.exec), "exec"],
1214
+ ["tokens in", fmtInt(t.inputTokens), ""],
1215
+ ["tokens out", fmtInt(t.outputTokens), ""],
1216
+ ["cached", fmtInt(t.cachedTokens), ""],
1217
+ ];
1218
+ for (const c of chips) {
1219
+ const chip = document.createElement("div");
1220
+ chip.className = "tl-chip" + (c[2] ? " tl-cat-" + c[2] : "");
1221
+ chip.innerHTML = '<span class="tl-chip-label">' + esc(c[0]) + '</span><span class="tl-chip-value">' + esc(c[1]) + "</span>";
1222
+ wrap.appendChild(chip);
1223
+ }
1224
+ return wrap;
1225
+ }
1226
+
1227
+ function tlLegend() {
1228
+ const wrap = document.createElement("div");
1229
+ wrap.className = "tl-legend";
1230
+ for (const [cat, label] of [["read", "read"], ["search", "search"], ["write", "write"], ["exec", "commands"], ["other", "other"], ["none", "idle"]]) {
1231
+ const s = document.createElement("span");
1232
+ s.className = "tl-legend-item";
1233
+ s.innerHTML = '<span class="tl-swatch tl-cat-' + esc(cat) + '"></span>' + esc(label);
1234
+ wrap.appendChild(s);
1235
+ }
1236
+ for (const [kind, label] of [["checkpoint", "📸 checkpoint"], ["condensed", "⟲ condensed"], ["paused", "⏸ pause"], ["resumed", "▶ resume"], ["decision", "⛔ decision"]]) {
1237
+ const s = document.createElement("span");
1238
+ s.className = "tl-legend-item";
1239
+ s.innerHTML = '<span class="tl-swatch tl-mk-swatch tl-mk-' + esc(kind) + '"></span>' + esc(label);
1240
+ wrap.appendChild(s);
1241
+ }
1242
+ return wrap;
1243
+ }
1244
+
1245
+ // The horizontal timeline: one cell per iteration, positioned by REAL time
1246
+ // (session-relative % of the whole span), colored by dominant tool type.
1247
+ function tlStrip(m) {
1248
+ const b = tlBounds(m);
1249
+ const strip = document.createElement("div");
1250
+ strip.className = "tl-strip";
1251
+ const grid = document.createElement("div");
1252
+ grid.className = "tl-grid";
1253
+ strip.appendChild(grid);
1254
+ for (const it of m.iterations) {
1255
+ const left = ((it.firstTs - b.min) / b.span) * 100;
1256
+ const width = Math.min(Math.max(1.2, ((it.lastTs - it.firstTs) / b.span) * 100), 100 - left);
1257
+ const cell = document.createElement("div");
1258
+ cell.className = "tl-cell tl-cat-" + esc(it.category);
1259
+ cell.style.left = left.toFixed(3) + "%";
1260
+ cell.style.width = width.toFixed(3) + "%";
1261
+ cell.title = "iteration " + it.iteration + " — " + TL_LABEL[it.category] +
1262
+ (it.toolCount ? " · " + it.toolCount + " tool call" + (it.toolCount === 1 ? "" : "s") + " (" + it.toolCalls.map((c) => c.name + "×" + c.count).join(", ") + ")" : "") +
1263
+ (it.inputTokens ? " · in=" + fmtInt(it.inputTokens) + " out=" + fmtInt(it.outputTokens) + " cached=" + fmtInt(it.cachedTokens) : "");
1264
+ cell.dataset.iter = String(it.iteration);
1265
+ cell.addEventListener("click", () => toggleIterationRow(it.iteration, true));
1266
+ grid.appendChild(cell);
1267
+ }
1268
+ return strip;
1269
+ }
1270
+
1271
+ // Structural markers on the same time axis (dots) + readable chips for the
1272
+ // interesting ones (checkpoints stay as dots — their density IS the signal).
1273
+ function tlMarkers(m) {
1274
+ const wrap = document.createElement("div");
1275
+ const b = tlBounds(m);
1276
+ const dots = document.createElement("div");
1277
+ dots.className = "tl-marker-row";
1278
+ for (const mk of m.markers) {
1279
+ const left = ((mk.ts - b.min) / b.span) * 100;
1280
+ const dot = document.createElement("div");
1281
+ dot.className = "tl-marker tl-mk-" + esc(mk.kind);
1282
+ dot.style.left = left.toFixed(3) + "%";
1283
+ dot.textContent = tlMarkerGlyph(mk);
1284
+ dot.title = tlMarkerTitle(mk);
1285
+ dots.appendChild(dot);
1286
+ }
1287
+ wrap.appendChild(dots);
1288
+
1289
+ const chips = document.createElement("div");
1290
+ chips.className = "tl-marker-chips";
1291
+ for (const mk of m.markers) {
1292
+ if (mk.kind === "checkpoint") continue;
1293
+ const chip = document.createElement("span");
1294
+ chip.className = "tl-marker-chip tl-mk-" + esc(mk.kind);
1295
+ chip.textContent = tlMarkerGlyph(mk) + " " + tlMarkerTitle(mk);
1296
+ chips.appendChild(chip);
1297
+ }
1298
+ wrap.appendChild(chips);
1299
+ return wrap;
1300
+ }
1301
+
1302
+ // Per-iteration detail rows below the strip: click a row (or a strip cell) to
1303
+ // expand the exact tool calls + token usage of that iteration.
1304
+ function toggleIterationRow(iteration, scrollTo) {
1305
+ const list = document.querySelector("#timeline .tl-iter-list");
1306
+ if (!list) return;
1307
+ const row = list.querySelector('.tl-iter-row[data-iter="' + iteration + '"]');
1308
+ if (!row) return;
1309
+ const body = row.querySelector(".tl-iter-body");
1310
+ const head = row.querySelector(".tl-iter-head");
1311
+ if (body) {
1312
+ const open = body.style.display === "block";
1313
+ body.style.display = open ? "none" : "block";
1314
+ if (head) head.classList.toggle("open", !open);
1315
+ if (!open && scrollTo) row.scrollIntoView({ block: "nearest" });
1316
+ }
1317
+ }
1318
+
1319
+ function tlIterations(m) {
1320
+ const list = document.createElement("div");
1321
+ list.className = "tl-iter-list";
1322
+ for (const it of m.iterations) {
1323
+ const row = document.createElement("div");
1324
+ row.className = "tl-iter-row";
1325
+ row.dataset.iter = String(it.iteration);
1326
+ const head = document.createElement("div");
1327
+ head.className = "tl-iter-head";
1328
+ const toolSummary = it.toolCalls.map((c) => c.name + "×" + c.count).join(", ") || "no tool calls";
1329
+ const tokens = it.inputTokens
1330
+ ? "in=" + fmtInt(it.inputTokens) + " · out=" + fmtInt(it.outputTokens) + " · cached=" + fmtInt(it.cachedTokens)
1331
+ : "";
1332
+ head.innerHTML =
1333
+ '<span class="tl-iter-num">iter ' + esc(it.iteration) + "</span>" +
1334
+ '<span class="tl-cat-badge tl-cat-' + esc(it.category) + '">' + esc(TL_LABEL[it.category]) + "</span>" +
1335
+ '<span class="tl-iter-tools">' + esc(toolSummary) + "</span>" +
1336
+ (it.hasError ? '<span class="tl-iter-err">⚠ error</span>' : "") +
1337
+ (tokens ? '<span class="tl-iter-tokens mono">' + esc(tokens) + "</span>" : "");
1338
+ head.addEventListener("click", () => toggleIterationRow(it.iteration, false));
1339
+ row.appendChild(head);
1340
+ const body = document.createElement("div");
1341
+ body.className = "tl-iter-body";
1342
+ body.style.display = "none";
1343
+ body.innerHTML =
1344
+ '<div class="mono">tools: ' + esc(it.toolCalls.map((c) => c.name + " × " + c.count).join(" · ") || "none") + "</div>" +
1345
+ (tokens ? '<div class="mono">tokens: ' + esc(tokens) + "</div>" : "");
1346
+ row.appendChild(body);
1347
+ list.appendChild(row);
1348
+ }
1349
+ return list;
1350
+ }
1351
+
1352
+ // Re-render the whole timeline from the accumulated feed on every poll (same
1353
+ // approach as renderChat — incremental append doesn't fit a % time axis).
1354
+ function renderTimeline() {
1355
+ if (!detail) return;
1356
+ const el = document.getElementById("timeline");
1357
+ const events = detail.allEvents || [];
1358
+ if (events.length === 0) {
1359
+ el.innerHTML = '<div class="tl-empty">no events yet — waiting for the worker…</div>';
1360
+ return;
1361
+ }
1362
+ const m = buildTimeline(events);
1363
+ el.innerHTML = "";
1364
+ el.appendChild(tlSummary(m));
1365
+ el.appendChild(tlLegend());
1366
+ el.appendChild(tlStrip(m));
1367
+ el.appendChild(tlMarkers(m));
1368
+ el.appendChild(tlIterations(m));
1369
+ }
1370
+
1371
+ // ── Checkpoints (browser checkpoint list/diff/restore) ──────────────────────
1372
+ // The panel in the session detail view lists the session's shadow-git
1373
+ // checkpoints (message, date, hash) via GET /api/checkpoints. Clicking a row
1374
+ // loads its diff (GET /api/checkpoints/diff) and renders it as a plain
1375
+ // unified-diff-style text block. Restore reverts REAL files, so it needs a
1376
+ // two-step confirm: the button first arms ("really restore?"), and only a
1377
+ // second click on the armed button fires the POST. After a successful restore
1378
+ // the list + diff refresh so the view never shows stale data.
1379
+ let ckptEntries = []; // { hash, message, date } for the current detail session
1380
+ let ckptSelectedHash = null;
1381
+ let ckptRestoreArmed = false;
1382
+
1383
+ async function loadCheckpoints() {
1384
+ const listEl = document.getElementById("ckptList");
1385
+ const statusEl = document.getElementById("ckptStatus");
1386
+ const diffEl = document.getElementById("ckptDiff");
1387
+ if (!detail) return;
1388
+ // The detail view is keyed by session id; the checkpoints routes are keyed
1389
+ // by the same session id (HeadlessSession uses sessionId as the checkpoint
1390
+ // taskId). The workspace the shadow repo tracks is the session's recorded
1391
+ // workspaceRoot (a worker session may live in a worktree, not --repo).
1392
+ const params = new URLSearchParams({ session: detail.sessionId });
1393
+ if (detail.workspace) params.set("repo", detail.workspace);
1394
+ try {
1395
+ const res = await fetch("/api/checkpoints?" + params.toString());
1396
+ if (!res.ok) throw new Error("HTTP " + res.status);
1397
+ const data = await res.json();
1398
+ ckptEntries = data.entries || [];
1399
+ ckptSelectedHash = null;
1400
+ ckptRestoreArmed = false;
1401
+ diffEl.style.display = "none";
1402
+ document.getElementById("ckptDiffBody").textContent = "";
1403
+ document.getElementById("ckptRestoreMsg").textContent = "";
1404
+ if (ckptEntries.length === 0) {
1405
+ statusEl.textContent = "none for this session";
1406
+ listEl.innerHTML = '<div class="feed-empty">no checkpoints</div>';
1407
+ return;
1408
+ }
1409
+ statusEl.textContent = ckptEntries.length + " checkpoint" + (ckptEntries.length > 1 ? "s" : "");
1410
+ listEl.innerHTML = "";
1411
+ for (const entry of ckptEntries) {
1412
+ const row = document.createElement("div");
1413
+ row.className = "ckpt-row";
1414
+ row.dataset.hash = entry.hash;
1415
+ row.innerHTML =
1416
+ '<span class="ckpt-msg">' + esc(entry.message) + "</span>" +
1417
+ '<span class="ckpt-date">' + esc(entry.date) + "</span>" +
1418
+ '<span class="ckpt-hash mono">' + esc(entry.hash.slice(0, 12)) + "</span>";
1419
+ row.addEventListener("click", () => selectCheckpoint(entry.hash, row));
1420
+ listEl.appendChild(row);
1421
+ }
1422
+ } catch (err) {
1423
+ statusEl.textContent = "";
1424
+ listEl.innerHTML = '<div class="feed-empty">failed to load checkpoints: ' + esc(String(err)) + "</div>";
1425
+ }
1426
+ }
1427
+
1428
+ function selectCheckpoint(hash, row) {
1429
+ ckptSelectedHash = hash;
1430
+ ckptRestoreArmed = false;
1431
+ const restoreBtn = document.getElementById("btnRestore");
1432
+ restoreBtn.classList.remove("restore-armed");
1433
+ restoreBtn.textContent = "Restore";
1434
+ restoreBtn.disabled = false;
1435
+ document.getElementById("ckptRestoreMsg").textContent = "";
1436
+ document.getElementById("ckptRestoreMsg").className = "ckpt-restore-msg";
1437
+ document.querySelectorAll(".ckpt-row").forEach((r) => r.classList.toggle("selected", r === row));
1438
+ loadDiff(hash);
1439
+ }
1440
+
1441
+ async function loadDiff(hash) {
1442
+ const diffEl = document.getElementById("ckptDiff");
1443
+ const titleEl = document.getElementById("ckptDiffTitle");
1444
+ const bodyEl = document.getElementById("ckptDiffBody");
1445
+ const entry = ckptEntries.find((e) => e.hash === hash);
1446
+ titleEl.textContent = (entry ? entry.message : hash.slice(0, 12)) + " vs working tree";
1447
+ bodyEl.textContent = "loading diff…";
1448
+ diffEl.style.display = "block";
1449
+ const params = new URLSearchParams({ session: detail.sessionId, from: hash });
1450
+ if (detail.workspace) params.set("repo", detail.workspace);
1451
+ try {
1452
+ const res = await fetch("/api/checkpoints/diff?" + params.toString());
1453
+ if (!res.ok) throw new Error("HTTP " + res.status);
1454
+ const data = await res.json();
1455
+ bodyEl.textContent = data.diff || "(no changes)";
1456
+ } catch (err) {
1457
+ bodyEl.textContent = "failed to load diff: " + err;
1458
+ }
1459
+ }
1460
+
1461
+ // Restore is DESTRUCTIVE (reverts real workspace files) — never a single
1462
+ // accidental click. First click arms the button (turns red + changes label);
1463
+ // second click actually POSTs.
1464
+ async function restoreSelected() {
1465
+ const restoreBtn = document.getElementById("btnRestore");
1466
+ const msgEl = document.getElementById("ckptRestoreMsg");
1467
+ if (!ckptSelectedHash) return;
1468
+ if (!ckptRestoreArmed) {
1469
+ ckptRestoreArmed = true;
1470
+ restoreBtn.classList.add("restore-armed");
1471
+ restoreBtn.textContent = "⚠ Really restore?";
1472
+ msgEl.textContent = "this reverts real files in the workspace to the selected checkpoint — uncommitted changes since then are lost";
1473
+ msgEl.className = "ckpt-restore-msg";
1474
+ return;
1475
+ }
1476
+ restoreBtn.disabled = true;
1477
+ msgEl.textContent = "restoring…";
1478
+ msgEl.className = "ckpt-restore-msg";
1479
+ try {
1480
+ const params = new URLSearchParams({ session: detail.sessionId });
1481
+ if (detail.workspace) params.set("repo", detail.workspace);
1482
+ const res = await fetch("/api/checkpoints/restore?" + params.toString(), {
1483
+ method: "POST",
1484
+ headers: { "content-type": "application/json" },
1485
+ body: JSON.stringify({ hash: ckptSelectedHash }),
1486
+ });
1487
+ const data = await res.json().catch(() => ({}));
1488
+ if (!res.ok) {
1489
+ msgEl.textContent = "restore failed: " + (data.error || ("HTTP " + res.status));
1490
+ msgEl.className = "ckpt-restore-msg status-error";
1491
+ restoreBtn.disabled = false;
1492
+ return;
1493
+ }
1494
+ msgEl.textContent = "restored ✓ to " + ckptSelectedHash.slice(0, 12) + " — files reverted";
1495
+ msgEl.className = "ckpt-restore-msg status-success";
1496
+ // Reflect the new state: re-list (restore rewrites the workspace; the
1497
+ // checkpoint set itself is unchanged but the working tree is now at the
1498
+ // restored commit) and refresh the selected diff (now vs the restored
1499
+ // tree, so "no changes" is expected).
1500
+ await loadCheckpoints();
1501
+ if (ckptEntries.some((e) => e.hash === ckptSelectedHash)) {
1502
+ loadDiff(ckptSelectedHash);
1503
+ }
1504
+ } catch (err) {
1505
+ msgEl.textContent = "restore failed: " + err;
1506
+ msgEl.className = "ckpt-restore-msg status-error";
1507
+ restoreBtn.disabled = false;
1508
+ }
1509
+
1510
+ }
1511
+
1512
+ function renderTotals(totals) {
1513
+ const el = document.getElementById("totals");
1514
+ const cacheHitRate = totals.inputTokens > 0 ? (100 * (totals.cachedTokens ?? 0)) / totals.inputTokens : 0;
1515
+ el.innerHTML = [
1516
+ ["Sessions", fmtInt(totals.sessionCount)],
1517
+ ["Total cost", fmtCost(totals.costUsd)],
1518
+ ["Input tokens", fmtInt(totals.inputTokens)],
1519
+ ["Cached tokens", fmtInt(totals.cachedTokens)],
1520
+ ["Cache hit rate", fmtPct(cacheHitRate)],
1521
+ ["Output tokens", fmtInt(totals.outputTokens)],
1522
+ ["Iterations", fmtInt(totals.iterations)],
1523
+ ].map(([label, value]) => \`<div class="stat"><div class="label">\${esc(label)}</div><div class="value">\${esc(value)}</div></div>\`).join("");
1524
+ }
1525
+
1526
+ function renderBlocked(blocked) {
1527
+ const el = document.getElementById("blocked");
1528
+ if (!blocked || blocked.length === 0) {
1529
+ el.innerHTML = '<div class="empty">none</div>';
1530
+ return;
1531
+ }
1532
+ el.innerHTML = blocked.map((b) => \`
1533
+ <div class="blocked-card">
1534
+ <div class="name">\${esc(b.name)}</div>
1535
+ <div class="question">\${esc(b.question)}</div>
1536
+ \${b.suggestions && b.suggestions.length ? '<div class="mono">suggestions: ' + esc(b.suggestions.join(" | ")) + '</div>' : ""}
1537
+ \${b.askedAt ? '<div class="mono">asked: ' + esc(b.askedAt) + '</div>' : ""}
1538
+ </div>
1539
+ \`).join("");
1540
+ }
1541
+
1542
+ function renderRound(round) {
1543
+ const el = document.getElementById("round");
1544
+ if (!round) {
1545
+ el.innerHTML = '<div class="empty">no .orchestrator-state.json found for --repo</div>';
1546
+ return;
1547
+ }
1548
+ const rows = (round.groups || []).map((g) => \`
1549
+ <tr>
1550
+ <td>\${esc(g.name)}</td>
1551
+ <td class="status-\${esc(g.status)}">\${esc(g.status)}</td>
1552
+ <td>\${g.usage ? fmtCost(g.usage.costUsd) : "—"}</td>
1553
+ <td>\${g.usage ? fmtInt(g.usage.inputTokens) : "—"}</td>
1554
+ <td>\${g.usage ? fmtInt(g.usage.cachedTokens) : "—"}</td>
1555
+ <td>\${g.usage ? fmtInt(g.usage.outputTokens) : "—"}</td>
1556
+ <td>\${g.usage ? fmtInt(g.usage.iterations) : "—"}</td>
1557
+ </tr>
1558
+ \`).join("");
1559
+ const preflight = round.preflight && round.preflight.line
1560
+ ? '<div class="sub mono" title="preflight probe before this round spawned">preflight: ' + esc(round.preflight.line) + '</div>'
1561
+ : "";
1562
+ el.innerHTML = \`
1563
+ <div class="sub">batch: \${esc(round.batch ?? "—")} · updated: \${esc(round.updated ?? "—")}
1564
+ · batch total: \${round.batchUsage ? fmtCost(round.batchUsage.costUsd) : "$0.000000"}
1565
+ · round total (all history): \${round.totalUsage ? fmtCost(round.totalUsage.costUsd) : "$0.000000"}</div>
1566
+ \${preflight}
1567
+ <table>
1568
+ <thead><tr><th>group</th><th>status</th><th>cost</th><th>in tok</th><th>cached tok</th><th>out tok</th><th>iter</th></tr></thead>
1569
+ <tbody>\${rows || '<tr><td colspan="7" class="empty">no groups</td></tr>'}</tbody>
1570
+ </table>
1571
+ \`;
1572
+ }
1573
+
1574
+ function renderSessions(sessions) {
1575
+ const el = document.getElementById("sessions");
1576
+ if (!sessions || sessions.length === 0) {
1577
+ el.innerHTML = '<div class="empty">no sessions found</div>';
1578
+ return;
1579
+ }
1580
+ // Every row is clickable: opens the live event detail panel for that
1581
+ // session (the detail panel polls /api/session/:id/events while open).
1582
+ const rows = sessions.map((s) => \`
1583
+ <tr class="session-row" data-session-id="\${esc(s.sessionId)}" data-source="\${esc(s.source)}" data-status="\${esc(s.status)}" data-workspace="\${esc(s.workspaceRoot)}" style="cursor:pointer">
1584
+ <td class="mono">\${esc(s.sessionId.slice(0, 8))}</td>
1585
+ <td>\${esc(s.mode)}</td>
1586
+ <td>\${esc(s.model)}</td>
1587
+ <td class="status-\${esc(s.status)}">\${esc(s.status)}</td>
1588
+ <td>\${fmtCost(s.costUsd)}</td>
1589
+ <td>\${fmtInt(s.inputTokens)}</td>
1590
+ <td>\${fmtInt(s.cachedTokens)}</td>
1591
+ <td>\${fmtInt(s.outputTokens)}</td>
1592
+ <td>\${fmtInt(s.iterations)}</td>
1593
+ <td class="mono">\${esc(s.startedAt)}</td>
1594
+ <td class="mono">\${esc(s.endedAt) || "—"}</td>
1595
+ <td class="mono">\${esc(s.source)}</td>
1596
+ </tr>
1597
+ \`).join("");
1598
+ el.innerHTML = \`
1599
+ <table>
1600
+ <thead><tr><th>id</th><th>mode</th><th>model</th><th>status</th><th>cost</th><th>in tok</th><th>cached tok</th><th>out tok</th><th>iter</th><th>started</th><th>ended</th><th>source</th></tr></thead>
1601
+ <tbody>\${rows}</tbody>
1602
+ </table>
1603
+ \`;
1604
+ // Click delegation: re-render replaces rows each poll, so bind once on the
1605
+ // container and read the clicked row's data attributes.
1606
+ el.querySelectorAll(".session-row").forEach((row) => {
1607
+ row.addEventListener("click", () => {
1608
+ openDetail(row.dataset.sessionId, row.dataset.source, row.dataset.status, row.dataset.workspace);
1609
+ });
1610
+ });
1611
+ }
1612
+
1613
+ // ── Mode → model settings (mode-model-assignment) ───────────────────────────
1614
+ // A small editable table (mode slug + model id, add/remove rows) with a save
1615
+ // button that POSTs the current table state to /api/settings/mode-models and
1616
+ // shows success/error inline. Plain vanilla JS — no framework, matching this
1617
+ // page's style.
1618
+ const modeModelsEl = document.getElementById("modeModels");
1619
+
1620
+ // Rows carry their slug in a data attribute so the table state can be
1621
+ // serialized back to the { mode: model, _default: model } file shape.
1622
+ function settingsTableRow(slug, model, isDefault) {
1623
+ const tr = document.createElement("tr");
1624
+ tr.dataset.slug = slug;
1625
+ tr.innerHTML =
1626
+ '<td class="mono">' + esc(slug) + "</td>" +
1627
+ '<td><input type="text" value="' + esc(model) + '" placeholder="provider/model" class="model-input" style="width:100%"></td>' +
1628
+ '<td><button class="row-remove" title="remove row">✕</button></td>';
1629
+ if (isDefault) {
1630
+ const label = document.createElement("span");
1631
+ label.className = "sub";
1632
+ label.textContent = " (fallback)";
1633
+ tr.querySelector("td").appendChild(label);
1634
+ }
1635
+ tr.querySelector(".row-remove").addEventListener("click", () => {
1636
+ tr.remove();
1637
+ });
1638
+ return tr;
1639
+ }
1640
+
1641
+ // Populate the "start a session" mode selector from the real merged modes
1642
+ // (.roomodes + global ~/.roo/custom_modes.yaml). Falls back to just the
1643
+ // default when the endpoint is unavailable (e.g. no --repo) — the input stays
1644
+ // a plain text field, so typing any slug always works.
1645
+ async function loadModes() {
1646
+ const list = document.getElementById("modeList");
1647
+ if (!list) return;
1648
+ try {
1649
+ const res = await fetch("/api/modes");
1650
+ if (!res.ok) throw new Error("HTTP " + res.status);
1651
+ const data = await res.json();
1652
+ list.innerHTML = "";
1653
+ const seen = new Set();
1654
+ for (const m of (data.modes || [])) {
1655
+ if (seen.has(m.slug)) continue;
1656
+ seen.add(m.slug);
1657
+ const opt = document.createElement("option");
1658
+ opt.value = m.slug;
1659
+ if (m.name && m.name !== m.slug) opt.label = m.name;
1660
+ list.appendChild(opt);
1661
+ }
1662
+ } catch (err) {
1663
+ // Non-fatal: the text input with the default value still works.
1664
+ list.innerHTML = "";
1665
+ }
1666
+ }
1667
+
1668
+ async function loadModeModels() {
1669
+ try {
1670
+ const res = await fetch("/api/settings/mode-models");
1671
+ if (!res.ok) throw new Error("HTTP " + res.status);
1672
+ const data = await res.json();
1673
+ renderModeModels(data || {});
1674
+ } catch (err) {
1675
+ modeModelsEl.innerHTML = '<div class="empty">failed to load mode-models settings: ' + esc(err) + "</div>";
1676
+ }
1677
+ }
1678
+
1679
+ function renderModeModels(file) {
1680
+ const entries = Object.entries(file || {});
1681
+ const table = document.createElement("table");
1682
+ const thead = document.createElement("thead");
1683
+ thead.innerHTML = "<tr><th>mode</th><th>model id</th><th></th></tr>";
1684
+ const tbody = document.createElement("tbody");
1685
+ let hasDefault = false;
1686
+ for (const [slug, model] of entries) {
1687
+ const isDefault = slug === "_default";
1688
+ if (isDefault) hasDefault = true;
1689
+ tbody.appendChild(settingsTableRow(slug, model, isDefault));
1690
+ }
1691
+ if (!hasDefault) {
1692
+ // Seed an empty _default row so the fallback is always editable.
1693
+ tbody.appendChild(settingsTableRow("_default", "", true));
1694
+ }
1695
+ table.appendChild(thead);
1696
+ table.appendChild(tbody);
1697
+
1698
+ const slugInput = document.createElement("input");
1699
+ slugInput.type = "text";
1700
+ slugInput.placeholder = "mode slug (e.g. code, deepseek-reviewer, qa-agent)";
1701
+ slugInput.style.width = "100%";
1702
+
1703
+ const modelInput = document.createElement("input");
1704
+ modelInput.type = "text";
1705
+ modelInput.placeholder = "provider/model";
1706
+ modelInput.style.width = "100%";
1707
+
1708
+ const addBtn = document.createElement("button");
1709
+ addBtn.textContent = "+ Add row";
1710
+ addBtn.addEventListener("click", () => {
1711
+ const slug = slugInput.value.trim();
1712
+ if (!slug || slug === "_default") {
1713
+ addMsg("mode slug must be a non-empty string (use the _default row for the fallback)", true);
1714
+ return;
1715
+ }
1716
+ if (table.querySelector('tbody tr[data-slug="' + slug + '"]')) {
1717
+ addMsg("row for mode '" + slug + "' already exists", true);
1718
+ return;
1719
+ }
1720
+ const tr = settingsTableRow(slug, modelInput.value.trim(), false);
1721
+ tbody.appendChild(tr);
1722
+ slugInput.value = "";
1723
+ modelInput.value = "";
1724
+ clearMsg();
1725
+ });
1726
+
1727
+ const saveBtn = document.createElement("button");
1728
+ saveBtn.textContent = "💾 Save";
1729
+ saveBtn.addEventListener("click", () => saveModeModels(table));
1730
+
1731
+ const addRow = document.createElement("div");
1732
+ addRow.className = "settings-add";
1733
+ addRow.style.cssText = "display:flex; gap:8px; margin-top:8px; align-items:center; flex-wrap:wrap;";
1734
+ addRow.appendChild(slugInput);
1735
+ addRow.appendChild(modelInput);
1736
+ addRow.appendChild(addBtn);
1737
+
1738
+ const actions = document.createElement("div");
1739
+ actions.style.cssText = "display:flex; gap:8px; margin-top:8px; align-items:center; flex-wrap:wrap;";
1740
+ actions.appendChild(saveBtn);
1741
+ const msg = document.createElement("span");
1742
+ msg.id = "settingsMsg";
1743
+ actions.appendChild(msg);
1744
+
1745
+ modeModelsEl.innerHTML = "";
1746
+ modeModelsEl.appendChild(table);
1747
+ modeModelsEl.appendChild(addRow);
1748
+ modeModelsEl.appendChild(actions);
1749
+ }
1750
+
1751
+ function currentSettingsTable(table) {
1752
+ const out = {};
1753
+ for (const tr of table.querySelectorAll("tbody tr")) {
1754
+ const slug = tr.dataset.slug;
1755
+ const model = tr.querySelector("input").value.trim();
1756
+ if (!slug) continue;
1757
+ if (model) out[slug] = model;
1758
+ }
1759
+ return out;
1760
+ }
1761
+
1762
+ function settingsMsg() {
1763
+ return document.getElementById("settingsMsg");
1764
+ }
1765
+
1766
+ function addMsg(text, isError) {
1767
+ const el = settingsMsg();
1768
+ if (!el) return;
1769
+ el.textContent = text;
1770
+ el.className = isError ? "status-error" : "status-success";
1771
+ }
1772
+
1773
+ function clearMsg() {
1774
+ const el = settingsMsg();
1775
+ if (el) el.textContent = "";
1776
+ }
1777
+
1778
+ async function saveModeModels(table) {
1779
+ const payload = currentSettingsTable(table);
1780
+ clearMsg();
1781
+ try {
1782
+ const res = await fetch("/api/settings/mode-models", {
1783
+ method: "POST",
1784
+ headers: { "content-type": "application/json" },
1785
+ body: JSON.stringify(payload),
1786
+ });
1787
+ const data = await res.json().catch(() => ({}));
1788
+ if (!res.ok) {
1789
+ addMsg("save failed: " + (data.error || ("HTTP " + res.status)), true);
1790
+ return;
1791
+ }
1792
+ addMsg("saved ✓ — " + Object.keys(payload).length + " entry(ies)", false);
1793
+ } catch (err) {
1794
+ addMsg("save failed: " + err, true);
1795
+ }
1796
+ }
1797
+
1798
+ // ── Workspace file browser (dashboard-file-browser-and-permissions-ui) ──────
1799
+ // A simple collapsible directory tree — plain vanilla JS, no library. Clicking
1800
+ // a directory toggles its children; clicking a file fetches its (capped,
1801
+ // binary-safe) content and shows it in a read-only text view. Read-only by
1802
+ // design: no file editing from the browser in this pass.
1803
+ const fbEl = document.getElementById("fileBrowser");
1804
+ const fbMsgEl = document.getElementById("fbMsg");
1805
+ const fbRepoInput = document.getElementById("fbRepo");
1806
+
1807
+ // Paths currently expanded, relative to the workspace root ("." = root).
1808
+ // The tree re-fetches each level on demand — no full recursive walk, so a
1809
+ // huge repo stays cheap.
1810
+ const fbExpanded = new Set();
1811
+
1812
+ function fbMsg(text, isError) {
1813
+ if (!fbMsgEl) return;
1814
+ fbMsgEl.textContent = text;
1815
+ fbMsgEl.className = isError ? "status-error" : "status-success";
1816
+ }
1817
+
1818
+ function fbRepo() {
1819
+ return (fbRepoInput.value || "").trim();
1820
+ }
1821
+
1822
+ async function fbFetchDir(relDir) {
1823
+ const params = new URLSearchParams();
1824
+ params.set("repo", fbRepo());
1825
+ if (relDir && relDir !== ".") params.set("dir", relDir);
1826
+ const res = await fetch("/api/files?" + params.toString());
1827
+ const data = await res.json().catch(() => ({}));
1828
+ if (!res.ok) {
1829
+ throw new Error(data.error || ("HTTP " + res.status));
1830
+ }
1831
+ return data.entries || [];
1832
+ }
1833
+
1834
+ function fmtSize(n) {
1835
+ if (typeof n !== "number") return "";
1836
+ if (n < 1024) return n + " B";
1837
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
1838
+ return (n / (1024 * 1024)).toFixed(1) + " MB";
1839
+ }
1840
+
1841
+ // Render one directory level as a <div class="fb-children"> under parentRel.
1842
+ // container is the element the level's children get appended to.
1843
+ async function renderFbLevel(container, parentRel) {
1844
+ container.innerHTML = '<div class="empty">loading…</div>';
1845
+ let entries;
1846
+ try {
1847
+ entries = await fbFetchDir(parentRel);
1848
+ } catch (err) {
1849
+ container.innerHTML = '<div class="empty">failed: ' + esc(err) + "</div>";
1850
+ return;
1851
+ }
1852
+ container.innerHTML = "";
1853
+ for (const entry of entries) {
1854
+ const rel = parentRel === "." || parentRel === "" ? entry.name : parentRel + "/" + entry.name;
1855
+ const row = document.createElement("div");
1856
+ row.className = "fb-entry " + (entry.type === "dir" ? "dir" : "file");
1857
+ const icon = entry.type === "dir" ? "📁" : "📄";
1858
+ row.innerHTML =
1859
+ '<span class="fb-icon">' + icon + "</span>" +
1860
+ '<span class="fb-name">' + esc(entry.name) + "</span>" +
1861
+ (entry.type === "file" ? '<span class="fb-size">' + esc(fmtSize(entry.size)) + "</span>" : "");
1862
+ if (entry.type === "dir") {
1863
+ const children = document.createElement("div");
1864
+ children.className = "fb-children collapsed";
1865
+ row.appendChild(children);
1866
+ row.addEventListener("click", (ev) => {
1867
+ ev.stopPropagation();
1868
+ if (fbExpanded.has(rel)) {
1869
+ fbExpanded.delete(rel);
1870
+ children.classList.add("collapsed");
1871
+ children.innerHTML = "";
1872
+ } else {
1873
+ fbExpanded.add(rel);
1874
+ children.classList.remove("collapsed");
1875
+ renderFbLevel(children, rel);
1876
+ }
1877
+ });
1878
+ } else {
1879
+ row.addEventListener("click", (ev) => {
1880
+ ev.stopPropagation();
1881
+ fbPreviewFile(rel);
1882
+ });
1883
+ }
1884
+ container.appendChild(row);
1885
+ }
1886
+ if (entries.length === 0) {
1887
+ container.innerHTML = '<div class="empty">(empty directory)</div>';
1888
+ }
1889
+ }
1890
+
1891
+ async function fbPreviewFile(rel) {
1892
+ const params = new URLSearchParams();
1893
+ params.set("repo", fbRepo());
1894
+ params.set("file", rel);
1895
+ try {
1896
+ const res = await fetch("/api/files/content?" + params.toString());
1897
+ const data = await res.json().catch(() => ({}));
1898
+ if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
1899
+ fbShowViewer(rel, data);
1900
+ } catch (err) {
1901
+ fbMsg("failed to load file: " + err, true);
1902
+ }
1903
+ }
1904
+
1905
+ function fbShowViewer(rel, data) {
1906
+ const existing = fbEl.querySelector(".fb-viewer");
1907
+ if (existing) existing.remove();
1908
+ const viewer = document.createElement("div");
1909
+ viewer.className = "fb-viewer";
1910
+ const head = document.createElement("div");
1911
+ head.className = "fb-viewer-head";
1912
+ head.innerHTML = '<span class="fb-path">' + esc(rel) + "</span>" +
1913
+ '<button class="fb-close" title="close">✕</button>';
1914
+ head.querySelector(".fb-close").addEventListener("click", () => viewer.remove());
1915
+ viewer.appendChild(head);
1916
+ const body = document.createElement("div");
1917
+ if (data.previewable) {
1918
+ const pre = document.createElement("pre");
1919
+ pre.className = "fb-content";
1920
+ pre.textContent = data.content;
1921
+ body.appendChild(pre);
1922
+ } else {
1923
+ const note = document.createElement("div");
1924
+ note.className = "fb-note";
1925
+ note.textContent = "not previewable — " + (data.reason || "unknown reason");
1926
+ body.appendChild(note);
1927
+ }
1928
+ viewer.appendChild(body);
1929
+ fbEl.appendChild(viewer);
1930
+ viewer.scrollIntoView({ block: "nearest" });
1931
+ }
1932
+
1933
+ async function fbLoad() {
1934
+ if (!fbRepo()) {
1935
+ fbMsg("enter a workspace path first", true);
1936
+ return;
1937
+ }
1938
+ fbExpanded.clear();
1939
+ fbMsg("");
1940
+ const tree = fbEl.querySelector(".fb-tree");
1941
+ if (tree) tree.remove();
1942
+ const holder = document.createElement("div");
1943
+ holder.className = "fb-tree";
1944
+ fbEl.appendChild(holder);
1945
+ await renderFbLevel(holder, ".");
1946
+ // The repo path also drives the cost-history + self-improvement +
1947
+ // permissions views — reload them together so a repo switch updates the
1948
+ // whole page, not just the tree.
1949
+ loadCostHistory();
1950
+ loadSelfImprovement();
1951
+ loadPermissions();
1952
+ }
1953
+
1954
+ // ── Permissions settings (dashboard-file-browser-and-permissions-ui) ────────
1955
+ // View + edit allowed/denied command lists, protected-file globs, and the
1956
+ // allowProtectedWrites escape hatch. Mirrors the mode-models settings page's
1957
+ // UX: load current state, edit in place, Save POSTs and shows the same inline
1958
+ // success/error status. When no permissions.json exists yet the page shows
1959
+ // the resolved built-in defaults, clearly labeled as defaults.
1960
+ const permEl = document.getElementById("permissionsSettings");
1961
+
1962
+ function permStatus() {
1963
+ return permEl.querySelector(".perm-status");
1964
+ }
1965
+
1966
+ function permMsg(text, isError) {
1967
+ const el = permStatus();
1968
+ if (!el) return;
1969
+ el.textContent = text;
1970
+ el.className = "perm-status " + (isError ? "status-error" : "status-success");
1971
+ }
1972
+
1973
+ // The three list fields are edited as newline-separated text (one entry per
1974
+ // line — the natural way to edit a list of command prefixes / globs in a
1975
+ // plain textarea, no framework).
1976
+ const PERM_LIST_FIELDS = [
1977
+ { key: "allowedCommands", label: "allowed commands", hint: "one command prefix per line — empty = default-ALLOW (deny list still applies)" },
1978
+ { key: "deniedCommands", label: "denied commands", hint: "one command prefix per line — deny always wins" },
1979
+ { key: "protectedFiles", label: "protected files", hint: "one glob per line, e.g. .env, *.pem, secrets/" },
1980
+ ];
1981
+
1982
+ function permListToText(list) {
1983
+ return (list || []).join("\\n");
1984
+ }
1985
+
1986
+ function permTextToList(text) {
1987
+ return text.split("\\n").map((s) => s.trim()).filter((s) => s.length > 0);
1988
+ }
1989
+
1990
+ function renderPermissions(data) {
1991
+ const file = data.file || null;
1992
+ const resolved = data.resolved || {};
1993
+
1994
+ permEl.innerHTML = "";
1995
+
1996
+ const banner = document.createElement("div");
1997
+ banner.className = "sub";
1998
+ if (data.isDefault) {
1999
+ banner.textContent = "No .headlesscode/permissions.json yet — showing the resolved built-in defaults. " +
2000
+ "Saving writes a real config file; until then these defaults apply to every session.";
2001
+ } else {
2002
+ banner.textContent = "Loaded from .headlesscode/permissions.json — this is the file every session reads.";
2003
+ }
2004
+ permEl.appendChild(banner);
2005
+
2006
+ const list = document.createElement("div");
2007
+ list.className = "perm-list";
2008
+
2009
+ // Seed the textareas from the FILE content when present, else from the
2010
+ // resolved defaults (so an empty workspace shows what would actually be
2011
+ // enforced — and saving those defaults is a no-op policy-wise).
2012
+ const seed = file || {
2013
+ allowedCommands: resolved.allowedCommands || [],
2014
+ deniedCommands: resolved.deniedCommands || [],
2015
+ protectedFiles: resolved.protectedFiles || [],
2016
+ };
2017
+
2018
+ for (const field of PERM_LIST_FIELDS) {
2019
+ const div = document.createElement("div");
2020
+ div.className = "perm-field";
2021
+ const label = document.createElement("label");
2022
+ label.textContent = field.label;
2023
+ div.appendChild(label);
2024
+ const ta = document.createElement("textarea");
2025
+ ta.dataset.key = field.key;
2026
+ ta.value = permListToText(seed[field.key]);
2027
+ ta.placeholder = field.hint;
2028
+ div.appendChild(ta);
2029
+ list.appendChild(div);
2030
+ }
2031
+ permEl.appendChild(list);
2032
+
2033
+ // allowProtectedWrites — the escape hatch, labeled as such.
2034
+ const row = document.createElement("div");
2035
+ row.className = "perm-row";
2036
+ const cb = document.createElement("input");
2037
+ cb.type = "checkbox";
2038
+ cb.id = "permAllowWrites";
2039
+ cb.checked = !!seed.allowProtectedWrites;
2040
+ const cbLabel = document.createElement("label");
2041
+ cbLabel.htmlFor = "permAllowWrites";
2042
+ cbLabel.innerHTML = '<span class="mono">allowProtectedWrites</span> — escape hatch: allow writes to protected files (.env, keys, PEMs). <b>Off by default; turning this on weakens protection for every future session.</b>';
2043
+ row.appendChild(cb);
2044
+ row.appendChild(cbLabel);
2045
+ permEl.appendChild(row);
2046
+
2047
+ const actions = document.createElement("div");
2048
+ actions.className = "perm-actions";
2049
+ const saveBtn = document.createElement("button");
2050
+ saveBtn.textContent = "💾 Save permissions";
2051
+ saveBtn.addEventListener("click", () => savePermissions());
2052
+ actions.appendChild(saveBtn);
2053
+ const msg = document.createElement("span");
2054
+ msg.className = "perm-status";
2055
+ actions.appendChild(msg);
2056
+ permEl.appendChild(actions);
2057
+ }
2058
+
2059
+ function currentPermissionsPayload() {
2060
+ const payload = {};
2061
+ for (const field of PERM_LIST_FIELDS) {
2062
+ const ta = permEl.querySelector('textarea[data-key="' + field.key + '"]');
2063
+ const list = ta ? permTextToList(ta.value) : [];
2064
+ if (list.length > 0) payload[field.key] = list;
2065
+ }
2066
+ const cb = document.getElementById("permAllowWrites");
2067
+ if (cb) payload.allowProtectedWrites = cb.checked;
2068
+ return payload;
2069
+ }
2070
+
2071
+ async function savePermissions() {
2072
+ permMsg("saving…", false);
2073
+ const payload = currentPermissionsPayload();
2074
+ try {
2075
+ const res = await fetch("/api/settings/permissions", {
2076
+ method: "POST",
2077
+ headers: { "content-type": "application/json" },
2078
+ body: JSON.stringify(payload),
2079
+ });
2080
+ const data = await res.json().catch(() => ({}));
2081
+ if (!res.ok) {
2082
+ permMsg("save failed: " + (data.error || ("HTTP " + res.status)), true);
2083
+ return;
2084
+ }
2085
+ permMsg("saved ✓ — permissions.json written. Takes effect on the next session.", false);
2086
+ // Refresh the GET so the page reflects the now-customized state.
2087
+ loadPermissions();
2088
+ } catch (err) {
2089
+ permMsg("save failed: " + err, true);
2090
+ }
2091
+ }
2092
+
2093
+ async function loadPermissions() {
2094
+ const params = new URLSearchParams();
2095
+ if (fbRepo()) params.set("repo", fbRepo());
2096
+ try {
2097
+ const res = await fetch("/api/settings/permissions?" + params.toString());
2098
+ const data = await res.json().catch(() => ({}));
2099
+ if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
2100
+ renderPermissions(data);
2101
+ } catch (err) {
2102
+ permEl.innerHTML = '<div class="empty">failed to load permissions settings: ' + esc(err) + "</div>";
2103
+ }
2104
+ }
2105
+
2106
+ // ── Start a session (browser control plane) ─────────────────────────────────
2107
+ // POSTs the typed prompt to /api/session/start, then navigates straight to
2108
+ // that session's live event view — the whole point is the user watches it
2109
+ // start thinking within a second or two of hitting submit, not that they
2110
+ // hunt for it in a list.
2111
+ async function startSession() {
2112
+ const task = document.getElementById("launchTask").value.trim();
2113
+ const mode = document.getElementById("launchMode").value.trim() || "multi-agent-orchestrator-headless";
2114
+ const msg = document.getElementById("launchMsg");
2115
+ const btn = document.getElementById("btnStart");
2116
+ if (!task) {
2117
+ msg.textContent = "type a prompt first";
2118
+ msg.className = "status-error";
2119
+ return;
2120
+ }
2121
+ msg.textContent = "launching…";
2122
+ msg.className = "";
2123
+ btn.disabled = true;
2124
+ try {
2125
+ const res = await fetch("/api/session/start", {
2126
+ method: "POST",
2127
+ headers: { "content-type": "application/json" },
2128
+ body: JSON.stringify({ task, mode }),
2129
+ });
2130
+ const data = await res.json().catch(() => ({}));
2131
+ if (!res.ok) {
2132
+ msg.textContent = "start failed: " + (data.error || ("HTTP " + res.status));
2133
+ msg.className = "status-error";
2134
+ return;
2135
+ }
2136
+ // Navigate immediately to the new session's live event view.
2137
+ openDetail(data.sessionId, ".", "running", data.workspace);
2138
+ msg.textContent = "launched ✓ session " + data.sessionId.slice(0, 8) + " — watching its event feed";
2139
+ msg.className = "status-success";
2140
+ } catch (err) {
2141
+ msg.textContent = "start failed: " + err;
2142
+ msg.className = "status-error";
2143
+ } finally {
2144
+ btn.disabled = false;
2145
+ }
2146
+ }
2147
+
2148
+ // ── Answer a blocked session (browser control plane) ────────────────────────
2149
+ // Rendered inline in the event feed at the decision_blocked event when no
2150
+ // matching decision_answered has arrived yet. Suggestion buttons are
2151
+ // quick-replies; the free-text input is the general case.
2152
+
2153
+ // An event feed has exactly one blocked question at a time (a session blocks
2154
+ // on one ask_followup_question, gets answered, then possibly blocks again).
2155
+ // Track per-session state so we only render one answer box per outstanding
2156
+ // question, and prune it once the answer arrives.
2157
+ let pendingDecisionTs = null;
2158
+
2159
+ // Returns true when this decision_blocked should NOT get an answer box —
2160
+ // i.e. a decision_answered for it already exists in the feed. Scans the DOM
2161
+ // (a session can block multiple times; only match an answer that came after
2162
+ // this particular question).
2163
+ function answeredAlready(feed, blockedEvent) {
2164
+ // If the detail view was reopened, pendingDecisionTs may still point at
2165
+ // this exact blocked event from a previous render — treat that as "box
2166
+ // still owed" unless a box already exists in the freshly re-rendered feed.
2167
+ if (pendingDecisionTs === blockedEvent.ts && feed.querySelector(".answer-box")) {
2168
+ return true;
2169
+ }
2170
+ // Otherwise, check whether the feed already shows a decision_answered that
2171
+ // came after this blocked event in DOM order.
2172
+ const evs = feed.querySelectorAll(".ev.decision_answered");
2173
+ for (const el of evs) {
2174
+ if (el.dataset.ts >= blockedEvent.ts) return true;
2175
+ }
2176
+ return false;
2177
+ }
2178
+
2179
+ function pruneAnsweredBoxes() {
2180
+ const feed = document.getElementById("feed");
2181
+ const evs = feed.querySelectorAll(".ev.decision_answered");
2182
+ if (evs.length === 0) {
2183
+ // No answer events at all — keep boxes as-is.
2184
+ return;
2185
+ }
2186
+ const lastAnswered = evs[evs.length - 1].dataset.ts;
2187
+ feed.querySelectorAll(".answer-box").forEach((box) => {
2188
+ const ts = box.dataset.blockedTs;
2189
+ if (ts && ts <= lastAnswered) {
2190
+ box.remove();
2191
+ }
2192
+ });
2193
+ // If the newest answer postdates our pending question, it's resolved.
2194
+ if (pendingDecisionTs !== null && pendingDecisionTs <= lastAnswered) {
2195
+ pendingDecisionTs = null;
2196
+ }
2197
+ }
2198
+
2199
+ function renderAnswerBox(feed, e) {
2200
+ pendingDecisionTs = e.ts;
2201
+ const box = document.createElement("div");
2202
+ box.className = "answer-box";
2203
+ box.dataset.blockedTs = e.ts;
2204
+ const suggestions = Array.isArray(e.suggestions) ? e.suggestions : [];
2205
+ const sRow = document.createElement("div");
2206
+ sRow.className = "answer-suggestions";
2207
+ for (const s of suggestions) {
2208
+ const b = document.createElement("button");
2209
+ b.textContent = s;
2210
+ b.addEventListener("click", () => submitAnswer(e, s, box));
2211
+ sRow.appendChild(b);
2212
+ }
2213
+ box.appendChild(sRow);
2214
+
2215
+ const row = document.createElement("div");
2216
+ row.className = "answer-input-row";
2217
+ const input = document.createElement("input");
2218
+ input.type = "text";
2219
+ input.placeholder = "type an answer…";
2220
+ row.appendChild(input);
2221
+ const btn = document.createElement("button");
2222
+ btn.textContent = "Answer";
2223
+ btn.addEventListener("click", () => {
2224
+ const text = input.value.trim();
2225
+ if (!text) return;
2226
+ submitAnswer(e, text, box);
2227
+ });
2228
+ row.appendChild(btn);
2229
+ const status = document.createElement("span");
2230
+ status.className = "answer-status";
2231
+ status.id = "answerStatus" + (e.ts || "");
2232
+ row.appendChild(status);
2233
+ box.appendChild(row);
2234
+ feed.appendChild(box);
2235
+ }
2236
+
2237
+ async function submitAnswer(e, text, box) {
2238
+ const status = box.querySelector(".answer-status");
2239
+ if (!status) return;
2240
+ status.textContent = "answering…";
2241
+ try {
2242
+ const res = await fetch("/api/session/" + encodeURIComponent(detail.sessionId) + "/answer", {
2243
+ method: "POST",
2244
+ headers: { "content-type": "application/json" },
2245
+ body: JSON.stringify({ answer: text }),
2246
+ });
2247
+ const data = await res.json().catch(() => ({}));
2248
+ if (!res.ok) {
2249
+ status.textContent = "failed: " + (data.error || ("HTTP " + res.status));
2250
+ return;
2251
+ }
2252
+ status.textContent = "answered ✓ — worker resumes on its next poll";
2253
+ status.className = "status-success";
2254
+ // Keep the box but disable it; the decision_answered event will confirm.
2255
+ box.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
2256
+ } catch (err) {
2257
+ status.textContent = "failed: " + err;
2258
+ }
2259
+ }
2260
+
2261
+ // ── Cost history (issue #29) ─────────────────────────────────────────────────
2262
+ // Historical per-round + per-session cost/token/duration from the central
2263
+ // store's cost-history.jsonl / session-cost-history.jsonl, served by
2264
+ // GET /api/cost-history?repo=<path> (see src/dashboard/server.ts). All charts
2265
+ // are hand-rolled inline SVG — no charting library, matching this page's
2266
+ // no-dependency ethos. The server returns newest-first; the charts read
2267
+ // oldest-first.
2268
+ const costHistoryEl = document.getElementById("costHistory");
2269
+ const selfImprovementEl = document.getElementById("selfImprovement");
2270
+
2271
+ function chRepo() {
2272
+ return fbRepo() || "";
2273
+ }
2274
+
2275
+ function chCost(n) {
2276
+ return "$" + (n ?? 0).toFixed(4);
2277
+ }
2278
+
2279
+ function chDuration(ms) {
2280
+ if (!(typeof ms === "number") || !Number.isFinite(ms) || ms < 0) return "?";
2281
+ const totalMinutes = Math.round(ms / 60000);
2282
+ const days = Math.floor(totalMinutes / 1440);
2283
+ const hours = Math.floor((totalMinutes % 1440) / 60);
2284
+ const minutes = totalMinutes % 60;
2285
+ if (days > 0) return days + "d " + hours + "h";
2286
+ if (hours > 0) return hours + "h " + minutes + "m";
2287
+ return minutes + "m";
2288
+ }
2289
+
2290
+ // Group label: the issues a round covered (#27,#29), falling back to the
2291
+ // group name for groups with no issue numbers.
2292
+ function chGroupLabel(g) {
2293
+ const nums = (g.issues && g.issues.length) ? g.issues.map(function (x) { return "#" + x; }).join(",") : "";
2294
+ return nums || g.groupName || "?";
2295
+ }
2296
+
2297
+ // Generic inline-SVG single-series bar chart. items: [{label, value, color,
2298
+ // title}]. opts.format(v) renders y-axis labels; opts.height overrides the
2299
+ // chart height. Returns an SVG string (the caller innerHTMLs it).
2300
+ function chBars(items, opts) {
2301
+ opts = opts || {};
2302
+ const width = 640;
2303
+ const height = opts.height || 170;
2304
+ const padL = 48, padR = 8, padT = 10, padB = 20;
2305
+ const plotW = width - padL - padR;
2306
+ const plotH = height - padT - padB;
2307
+ let max = 0;
2308
+ for (const it of items) {
2309
+ if (typeof it.value === "number" && it.value > max) max = it.value;
2310
+ }
2311
+ if (max <= 0) max = 1;
2312
+ const n = items.length;
2313
+ const slot = n > 0 ? plotW / n : plotW;
2314
+ const barW = Math.max(2, slot * 0.55);
2315
+ const steps = 4;
2316
+ let out = '<svg viewBox="0 0 ' + width + " " + height + '" width="100%" style="max-width:640px;height:auto" role="img">';
2317
+ for (let i = 0; i <= steps; i++) {
2318
+ const v = (max * i) / steps;
2319
+ const y = padT + plotH - (plotH * i) / steps;
2320
+ out += '<line x1="' + padL + '" y1="' + y + '" x2="' + (width - padR) + '" y2="' + y + '" stroke="currentColor" stroke-opacity="0.12"/>';
2321
+ out += '<text x="' + (padL - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="9" fill="currentColor" fill-opacity="0.7">' +
2322
+ esc(opts.format ? opts.format(v) : Math.round(v)) + "</text>";
2323
+ }
2324
+ items.forEach(function (it, i) {
2325
+ const x = padL + i * slot + (slot - barW) / 2;
2326
+ const h = Math.max(1, (plotH * it.value) / max);
2327
+ const y = padT + plotH - h;
2328
+ out += '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + barW.toFixed(1) + '" height="' + h.toFixed(1) + '" rx="2" fill="' + (it.color || "#8250df") + '"><title>' + esc(it.title || it.label) + "</title></rect>";
2329
+ if (n <= 24) {
2330
+ out += '<text x="' + (x + barW / 2).toFixed(1) + '" y="' + (height - 6) + '" text-anchor="middle" font-size="8.5" fill="currentColor" fill-opacity="0.7">' + esc(it.label) + "</text>";
2331
+ }
2332
+ });
2333
+ out += "</svg>";
2334
+ return out;
2335
+ }
2336
+
2337
+ // Generic inline-SVG single-series line chart (issue #145). items:
2338
+ // [{label, value, title}]. opts.max fixes the y-axis scale (e.g. 1 for a
2339
+ // 0..100% rate); omitted = auto-scale to the data's own max. opts.format(v)
2340
+ // renders y-axis labels. Deliberately a plain line (no fill/area) — this
2341
+ // page's other charts (chBars, chCostChart) already cover bars; this fills
2342
+ // the "a rate/mean trending over time" gap those don't fit well.
2343
+ function chLine(items, opts) {
2344
+ opts = opts || {};
2345
+ const width = 640;
2346
+ const height = opts.height || 170;
2347
+ const padL = 48, padR = 8, padT = 10, padB = 20;
2348
+ const plotW = width - padL - padR;
2349
+ const plotH = height - padT - padB;
2350
+ let max = typeof opts.max === "number" ? opts.max : 0;
2351
+ if (typeof opts.max !== "number") {
2352
+ for (const it of items) {
2353
+ if (typeof it.value === "number" && it.value > max) max = it.value;
2354
+ }
2355
+ }
2356
+ if (max <= 0) max = 1;
2357
+ const n = items.length;
2358
+ const slot = n > 1 ? plotW / (n - 1) : plotW;
2359
+ const steps = 4;
2360
+ let out = '<svg viewBox="0 0 ' + width + " " + height + '" width="100%" style="max-width:640px;height:auto" role="img">';
2361
+ for (let i = 0; i <= steps; i++) {
2362
+ const v = (max * i) / steps;
2363
+ const y = padT + plotH - (plotH * i) / steps;
2364
+ out += '<line x1="' + padL + '" y1="' + y + '" x2="' + (width - padR) + '" y2="' + y + '" stroke="currentColor" stroke-opacity="0.12"/>';
2365
+ out += '<text x="' + (padL - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="9" fill="currentColor" fill-opacity="0.7">' +
2366
+ esc(opts.format ? opts.format(v) : Math.round(v)) + "</text>";
2367
+ }
2368
+ const pts = [];
2369
+ items.forEach(function (it, i) {
2370
+ const x = n > 1 ? padL + i * slot : padL + plotW / 2;
2371
+ const v = typeof it.value === "number" ? it.value : 0;
2372
+ const y = padT + plotH - (plotH * Math.min(v, max)) / max;
2373
+ pts.push(x.toFixed(1) + "," + y.toFixed(1));
2374
+ });
2375
+ if (pts.length > 1) {
2376
+ out += '<polyline points="' + pts.join(" ") + '" fill="none" stroke="' + (opts.color || "#1a7f37") + '" stroke-width="1.5"/>';
2377
+ }
2378
+ items.forEach(function (it, i) {
2379
+ const x = n > 1 ? padL + i * slot : padL + plotW / 2;
2380
+ const v = typeof it.value === "number" ? it.value : 0;
2381
+ const y = padT + plotH - (plotH * Math.min(v, max)) / max;
2382
+ out += '<circle cx="' + x.toFixed(1) + '" cy="' + y.toFixed(1) + '" r="2.5" fill="' + (opts.color || "#1a7f37") + '"><title>' +
2383
+ esc(it.title || (it.label + ": " + (opts.format ? opts.format(v) : v))) + "</title></circle>";
2384
+ if (n <= 24) {
2385
+ out += '<text x="' + x.toFixed(1) + '" y="' + (height - 6) + '" text-anchor="middle" font-size="8.5" fill="currentColor" fill-opacity="0.7">' + esc(it.label) + "</text>";
2386
+ }
2387
+ });
2388
+ out += "</svg>";
2389
+ return out;
2390
+ }
2391
+
2392
+ // Spend per round (bars) + cumulative spend (line on a 0..total scale).
2393
+ function chCostChart(asc) {
2394
+ const width = 640, height = 170;
2395
+ const padL = 48, padR = 8, padT = 10, padB = 20;
2396
+ const plotW = width - padL - padR;
2397
+ const plotH = height - padT - padB;
2398
+ let max = 0;
2399
+ let total = 0;
2400
+ for (const g of asc) {
2401
+ if (g.costUsd > max) max = g.costUsd;
2402
+ total += g.costUsd;
2403
+ }
2404
+ if (max <= 0) max = 1;
2405
+ const n = asc.length;
2406
+ const slot = n > 0 ? plotW / n : plotW;
2407
+ const barW = Math.max(2, slot * 0.5);
2408
+ const steps = 4;
2409
+ let out = '<svg viewBox="0 0 ' + width + " " + height + '" width="100%" style="max-width:640px;height:auto" role="img">';
2410
+ for (let i = 0; i <= steps; i++) {
2411
+ const v = (max * i) / steps;
2412
+ const y = padT + plotH - (plotH * i) / steps;
2413
+ out += '<line x1="' + padL + '" y1="' + y + '" x2="' + (width - padR) + '" y2="' + y + '" stroke="currentColor" stroke-opacity="0.12"/>';
2414
+ out += '<text x="' + (padL - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="9" fill="currentColor" fill-opacity="0.7">$' + v.toFixed(2) + "</text>";
2415
+ }
2416
+ const pts = [];
2417
+ let cum = 0;
2418
+ asc.forEach(function (g, i) {
2419
+ cum += g.costUsd;
2420
+ const cx = padL + i * slot + slot / 2;
2421
+ const cy = padT + plotH - (plotH * cum) / (total > 0 ? total : 1);
2422
+ pts.push(cx.toFixed(1) + "," + cy.toFixed(1));
2423
+ const x = padL + i * slot + (slot - barW) / 2;
2424
+ const h = Math.max(1, (plotH * g.costUsd) / max);
2425
+ const y = padT + plotH - h;
2426
+ out += '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + barW.toFixed(1) + '" height="' + h.toFixed(1) + '" rx="2" fill="#8250df"><title>' + esc(chGroupLabel(g)) + ": " + chCost(g.costUsd) + "</title></rect>";
2427
+ if (n <= 24) {
2428
+ out += '<text x="' + (x + barW / 2).toFixed(1) + '" y="' + (height - 6) + '" text-anchor="middle" font-size="8.5" fill="currentColor" fill-opacity="0.7">' + esc(chGroupLabel(g)) + "</text>";
2429
+ }
2430
+ });
2431
+ if (pts.length > 1) {
2432
+ out += '<polyline points="' + pts.join(" ") + '" fill="none" stroke="#1a7f37" stroke-width="1.5"><title>cumulative spend</title></polyline>';
2433
+ }
2434
+ out += "</svg>";
2435
+ return out;
2436
+ }
2437
+
2438
+ // Token usage per round: grouped bars for input / output / cached + gold dots
2439
+ // for the cache-hit rate (cached ÷ input) on the same 0..max scale.
2440
+ function chTokensChart(asc) {
2441
+ const width = 640, height = 170;
2442
+ const padL = 48, padR = 8, padT = 10, padB = 20;
2443
+ const plotW = width - padL - padR;
2444
+ const plotH = height - padT - padB;
2445
+ let max = 0;
2446
+ for (const g of asc) {
2447
+ const t = Math.max(g.inputTokens || 0, g.outputTokens || 0, g.cachedTokens || 0);
2448
+ if (t > max) max = t;
2449
+ }
2450
+ if (max <= 0) max = 1;
2451
+ const n = asc.length;
2452
+ const slot = n > 0 ? plotW / n : plotW;
2453
+ const groupW = Math.max(3, slot * 0.7);
2454
+ const barW = Math.max(1, groupW / 3 - 1);
2455
+ const steps = 4;
2456
+ let out = '<svg viewBox="0 0 ' + width + " " + height + '" width="100%" style="max-width:640px;height:auto" role="img">';
2457
+ for (let i = 0; i <= steps; i++) {
2458
+ const v = (max * i) / steps;
2459
+ const y = padT + plotH - (plotH * i) / steps;
2460
+ out += '<line x1="' + padL + '" y1="' + y + '" x2="' + (width - padR) + '" y2="' + y + '" stroke="currentColor" stroke-opacity="0.12"/>';
2461
+ out += '<text x="' + (padL - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="9" fill="currentColor" fill-opacity="0.7">' + esc(fmtInt(Math.round(v))) + "</text>";
2462
+ }
2463
+ const series = [
2464
+ { key: "inputTokens", color: "#8250df", name: "input" },
2465
+ { key: "outputTokens", color: "#1a7f37", name: "output" },
2466
+ { key: "cachedTokens", color: "#38bdf8", name: "cached" },
2467
+ ];
2468
+ asc.forEach(function (g, i) {
2469
+ const gx = padL + i * slot + (slot - groupW) / 2;
2470
+ series.forEach(function (s, j) {
2471
+ const v = g[s.key] || 0;
2472
+ const h = Math.max(1, (plotH * v) / max);
2473
+ const y = padT + plotH - h;
2474
+ const x = gx + j * (barW + 1);
2475
+ out += '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + barW.toFixed(1) + '" height="' + h.toFixed(1) + '" fill="' + s.color + '"><title>' + esc(s.name) + " · " + esc(chGroupLabel(g)) + ": " + fmtInt(v) + "</title></rect>";
2476
+ });
2477
+ const rate = g.inputTokens > 0 ? (g.cachedTokens || 0) / g.inputTokens : 0;
2478
+ const cx = padL + i * slot + slot / 2;
2479
+ const cy = padT + plotH - plotH * rate;
2480
+ out += '<circle cx="' + cx.toFixed(1) + '" cy="' + cy.toFixed(1) + '" r="2.5" fill="#b8860b"><title>cache hit rate ' + fmtPct(rate * 100) + "</title></circle>";
2481
+ if (n <= 24) {
2482
+ out += '<text x="' + (padL + i * slot + slot / 2).toFixed(1) + '" y="' + (height - 6) + '" text-anchor="middle" font-size="8.5" fill="currentColor" fill-opacity="0.7">' + esc(chGroupLabel(g)) + "</text>";
2483
+ }
2484
+ });
2485
+ out += "</svg>";
2486
+ return out;
2487
+ }
2488
+
2489
+ function chChip(label, value, cls) {
2490
+ return '<span class="ch-chip' + (cls ? " " + cls : "") + '"><span class="ch-chip-label">' + esc(label) + '</span><span class="ch-chip-value">' + esc(value) + "</span></span>";
2491
+ }
2492
+
2493
+ async function loadCostHistory() {
2494
+ const el = costHistoryEl;
2495
+ if (!el) return;
2496
+ const repo = chRepo();
2497
+ if (!repo) {
2498
+ el.innerHTML = '<div class="empty">enter a workspace path in the file browser above (or start the dashboard with --repo) to load cost history</div>';
2499
+ return;
2500
+ }
2501
+ try {
2502
+ const res = await fetch("/api/cost-history?repo=" + encodeURIComponent(repo));
2503
+ if (!res.ok) throw new Error("HTTP " + res.status);
2504
+ const data = await res.json();
2505
+ renderCostHistory(data);
2506
+ } catch (err) {
2507
+ el.innerHTML = '<div class="empty">failed to load cost history: ' + esc(err) + "</div>";
2508
+ }
2509
+ }
2510
+
2511
+ function renderCostHistory(data) {
2512
+ const el = costHistoryEl;
2513
+ if (!el) return;
2514
+ const groups = data.groups || [];
2515
+ const sessions = data.sessions || [];
2516
+ el.innerHTML = "";
2517
+ if (groups.length === 0 && sessions.length === 0) {
2518
+ el.innerHTML = '<div class="empty">no cost history recorded for this repo yet — per-round records appear once an orchestrated round reaches a terminal status</div>';
2519
+ return;
2520
+ }
2521
+ // Charts read oldest → newest; the server returns newest-first.
2522
+ const asc = groups.slice().sort(function (a, b) {
2523
+ return a.recordedAt < b.recordedAt ? -1 : a.recordedAt > b.recordedAt ? 1 : 0;
2524
+ });
2525
+ const totalCost = groups.reduce(function (s, g) { return s + g.costUsd; }, 0);
2526
+ const totalMs = groups.reduce(function (s, g) { return s + (g.wallClockMs || 0); }, 0);
2527
+ const totalIter = groups.reduce(function (s, g) { return s + (g.iterations || 0); }, 0);
2528
+ const wasted = sessions.filter(function (r) { return r.status !== "success"; });
2529
+ const wastedCost = wasted.reduce(function (s, r) { return s + r.costUsd; }, 0);
2530
+
2531
+ const chips = document.createElement("div");
2532
+ chips.className = "ch-chips";
2533
+ chips.innerHTML =
2534
+ chChip("rounds", fmtInt(groups.length)) +
2535
+ chChip("total spend", chCost(totalCost)) +
2536
+ chChip("wall-clock", chDuration(totalMs)) +
2537
+ chChip("iterations", fmtInt(totalIter)) +
2538
+ chChip("wasted sessions", fmtInt(wasted.length) + " · " + chCost(wastedCost), wasted.length ? "ch-wasted" : "");
2539
+ el.appendChild(chips);
2540
+
2541
+ const costBlock = document.createElement("div");
2542
+ costBlock.className = "ch-block";
2543
+ costBlock.innerHTML = '<div class="ch-block-title">Spend over time</div>' +
2544
+ '<div class="ch-block-sub">cost per round (bars) + cumulative spend (line); rounds labeled by issue number(s).</div>' +
2545
+ chCostChart(asc);
2546
+ el.appendChild(costBlock);
2547
+
2548
+ const tokBlock = document.createElement("div");
2549
+ tokBlock.className = "ch-block";
2550
+ tokBlock.innerHTML = '<div class="ch-block-title">Token usage per round</div>' +
2551
+ '<div class="ch-block-sub">input / output / cached bars; gold dot per round = cache-hit rate (cached ÷ input) — a useful efficiency signal.</div>' +
2552
+ chTokensChart(asc);
2553
+ el.appendChild(tokBlock);
2554
+
2555
+ const wallItems = asc.map(function (g) {
2556
+ return {
2557
+ label: chGroupLabel(g),
2558
+ value: g.wallClockMs || 0,
2559
+ color: "#4f9cf9",
2560
+ title: chGroupLabel(g) + ": " + chDuration(g.wallClockMs),
2561
+ };
2562
+ });
2563
+ const wallBlock = document.createElement("div");
2564
+ wallBlock.className = "ch-block";
2565
+ wallBlock.innerHTML = '<div class="ch-block-title">Wall-clock time to completion per round</div>' +
2566
+ '<div class="ch-block-sub">dispatch (first session start) → recorded (terminal status). "?" = no start timestamp (records from before that field existed).</div>' +
2567
+ chBars(wallItems, { format: chDuration });
2568
+ el.appendChild(wallBlock);
2569
+
2570
+ const iterItems = asc.map(function (g) {
2571
+ return {
2572
+ label: chGroupLabel(g),
2573
+ value: g.iterations || 0,
2574
+ color: "#f59e0b",
2575
+ title: chGroupLabel(g) + ": " + fmtInt(g.iterations) + " iterations · " + fmtInt(g.continuationCount || 0) + " continuations · " + fmtInt(g.reworkCount || 0) + " reworks",
2576
+ };
2577
+ });
2578
+ const iterBlock = document.createElement("div");
2579
+ iterBlock.className = "ch-block";
2580
+ iterBlock.innerHTML = '<div class="ch-block-title">Iterations per round</div>' +
2581
+ '<div class="ch-block-sub">hover a bar for continuation/rework counts — a proxy for how much friction the round hit.</div>' +
2582
+ chBars(iterItems, {});
2583
+ el.appendChild(iterBlock);
2584
+
2585
+ // Wasted-spend breakdown, from the per-session records (same definition the
2586
+ // cost-history CLI's summary line uses: any non-success outcome).
2587
+ if (wasted.length > 0) {
2588
+ const byStatus = {};
2589
+ for (const r of wasted) {
2590
+ byStatus[r.status] = byStatus[r.status] || { count: 0, cost: 0 };
2591
+ byStatus[r.status].count += 1;
2592
+ byStatus[r.status].cost += r.costUsd;
2593
+ }
2594
+ let wasteRows = "";
2595
+ for (const st of ["error", "budget", "killed"]) {
2596
+ const info = byStatus[st];
2597
+ if (!info) continue;
2598
+ wasteRows += '<tr><td class="status-' + esc(st) + '">' + esc(st) + "</td><td>" + fmtInt(info.count) + "</td><td>" + chCost(info.cost) + "</td></tr>";
2599
+ }
2600
+ const wasteBlock = document.createElement("div");
2601
+ wasteBlock.className = "ch-block";
2602
+ wasteBlock.innerHTML = '<div class="ch-block-title">Wasted-spend breakdown</div>' +
2603
+ '<div class="ch-block-sub">sessions that ended error / budget / killed — spend that produced no completed-round outcome on its own.</div>' +
2604
+ '<div class="ch-table-wrap"><table><thead><tr><th>status</th><th>sessions</th><th>cost</th></tr></thead><tbody>' + wasteRows + "</tbody></table></div>";
2605
+ el.appendChild(wasteBlock);
2606
+ }
2607
+
2608
+ // Full per-round table (the CLI table's data, browsable).
2609
+ const rows = asc.map(function (g) {
2610
+ return "<tr>" +
2611
+ "<td>" + esc(g.groupName) + "</td>" +
2612
+ '<td class="mono">' + esc(chGroupLabel(g)) + "</td>" +
2613
+ '<td class="status-' + esc(g.status) + '">' + esc(g.status) + "</td>" +
2614
+ "<td>" + chCost(g.costUsd) + "</td>" +
2615
+ "<td>" + fmtInt(g.iterations || 0) + "</td>" +
2616
+ "<td>" + chDuration(g.wallClockMs) + "</td>" +
2617
+ "<td>" + fmtInt(g.continuationCount || 0) + "</td>" +
2618
+ "<td>" + fmtInt(g.reworkCount || 0) + "</td>" +
2619
+ '<td class="mono">' + esc(g.recordedAt) + "</td>" +
2620
+ "</tr>";
2621
+ }).join("");
2622
+ const tableBlock = document.createElement("div");
2623
+ tableBlock.className = "ch-block";
2624
+ tableBlock.innerHTML = '<div class="ch-block-title">Rounds</div>' +
2625
+ '<div class="ch-table-wrap"><table><thead><tr><th>group</th><th>issues</th><th>status</th><th>cost</th><th>iter</th><th>time</th><th>cont</th><th>rework</th><th>recorded</th></tr></thead>' +
2626
+ "<tbody>" + rows + "</tbody></table></div>";
2627
+ el.appendChild(tableBlock);
2628
+ }
2629
+
2630
+ // ── Self-improvement progress (issue #145) ──────────────────────────────────
2631
+ // Recursive self-improvement loop metrics: session outcome/efficiency trends
2632
+ // from this repo's own .headlesscode/usage/*.jsonl, standing guardrails
2633
+ // landed from git log, issue lifecycle from "gh issue list" — served by
2634
+ // GET /api/self-improvement?repo=<path> (see self-improvement-metrics.ts for
2635
+ // the computation). Local-machine only, per direct product direction
2636
+ // (2026-08-21): no external dashboard, this page IS the dashboard.
2637
+
2638
+ function siFmtHour(iso) {
2639
+ const d = new Date(iso);
2640
+ const mo = d.toLocaleString(undefined, { month: "short" });
2641
+ return mo + " " + d.getDate() + " " + String(d.getHours()).padStart(2, "0") + ":00";
2642
+ }
2643
+
2644
+ function siFmtPct(v) {
2645
+ return Math.round(v * 100) + "%";
2646
+ }
2647
+
2648
+ async function loadSelfImprovement() {
2649
+ const el = selfImprovementEl;
2650
+ if (!el) return;
2651
+ const repo = chRepo();
2652
+ if (!repo) {
2653
+ el.innerHTML = '<div class="empty">enter a workspace path in the file browser above (or start the dashboard with --repo) to load self-improvement metrics</div>';
2654
+ return;
2655
+ }
2656
+ try {
2657
+ const res = await fetch("/api/self-improvement?repo=" + encodeURIComponent(repo));
2658
+ if (!res.ok) throw new Error("HTTP " + res.status);
2659
+ const data = await res.json();
2660
+ renderSelfImprovement(data);
2661
+ } catch (err) {
2662
+ el.innerHTML = '<div class="empty">failed to load self-improvement metrics: ' + esc(err) + "</div>";
2663
+ }
2664
+ }
2665
+
2666
+ function renderSelfImprovement(data) {
2667
+ const el = selfImprovementEl;
2668
+ if (!el) return;
2669
+ el.innerHTML = "";
2670
+ if (!data.totalSessions) {
2671
+ el.innerHTML = '<div class="empty">no session records yet for this repo — run a session (any mode) to start building history</div>';
2672
+ return;
2673
+ }
2674
+
2675
+ const lifecycle = data.issueLifecycle;
2676
+ const chips = document.createElement("div");
2677
+ chips.className = "ch-chips";
2678
+ chips.innerHTML =
2679
+ chChip("sessions", fmtInt(data.totalSessions)) +
2680
+ chChip("overall success rate", siFmtPct(data.overallSuccessRate), data.overallSuccessRate < 0.5 ? "ch-wasted" : "") +
2681
+ chChip("standing guardrails landed", fmtInt(data.standingGuardrails.totalCount)) +
2682
+ (lifecycle
2683
+ ? chChip("issues open / closed", fmtInt(lifecycle.currentOpenCount) + " / " + fmtInt(lifecycle.currentClosedCount))
2684
+ : chChip("issue lifecycle", "gh unavailable"));
2685
+ el.appendChild(chips);
2686
+
2687
+ const outcomeItems = data.sessionOutcomesByHour.map(function (b) {
2688
+ return { label: siFmtHour(b.hour), value: b.successRate, title: siFmtHour(b.hour) + ": " + siFmtPct(b.successRate) + " (" + b.success + "/" + b.total + ")" };
2689
+ });
2690
+ const outcomeBlock = document.createElement("div");
2691
+ outcomeBlock.className = "ch-block";
2692
+ outcomeBlock.innerHTML = '<div class="ch-block-title">Session success rate over time</div>' +
2693
+ '<div class="ch-block-sub">the most direct "is it working" signal — % of dispatched sessions reaching real completion, per hour.</div>' +
2694
+ chLine(outcomeItems, { max: 1, format: siFmtPct });
2695
+ el.appendChild(outcomeBlock);
2696
+
2697
+ const volItems = data.sessionVolumeByHour.map(function (b) {
2698
+ return { label: siFmtHour(b.hour), value: b.count, title: siFmtHour(b.hour) + ": " + b.count + " session(s)", color: "#4f9cf9" };
2699
+ });
2700
+ const volBlock = document.createElement("div");
2701
+ volBlock.className = "ch-block";
2702
+ volBlock.innerHTML = '<div class="ch-block-title">Session volume over time</div>' +
2703
+ '<div class="ch-block-sub">activity level — read the success-rate chart above against this: a spike on 2 sessions means less than one on 20.</div>' +
2704
+ chBars(volItems);
2705
+ el.appendChild(volBlock);
2706
+
2707
+ const iterItems = data.iterationEfficiencyByHour.map(function (b) {
2708
+ return { label: siFmtHour(b.hour), value: b.meanIterations, title: siFmtHour(b.hour) + ": " + b.meanIterations.toFixed(1) + " mean iterations (" + b.successCount + " successes)" };
2709
+ });
2710
+ const iterBlock = document.createElement("div");
2711
+ iterBlock.className = "ch-block";
2712
+ iterBlock.innerHTML = '<div class="ch-block-title">Iteration efficiency (successful sessions only)</div>' +
2713
+ '<div class="ch-block-sub">mean iterations to reach a real completion — trending down means the model is getting more direct, not just more numerous.</div>' +
2714
+ chLine(iterItems, { color: "#8250df" });
2715
+ el.appendChild(iterBlock);
2716
+
2717
+ const guardItems = data.standingGuardrails.cumulativeByHour.map(function (b) {
2718
+ return { label: siFmtHour(b.hour), value: b.cumulative, title: siFmtHour(b.hour) + ": " + b.cumulative + " cumulative guardrail commit(s)" };
2719
+ });
2720
+ const guardBlock = document.createElement("div");
2721
+ guardBlock.className = "ch-block";
2722
+ guardBlock.innerHTML = '<div class="ch-block-title">Standing guardrails landed (cumulative)</div>' +
2723
+ '<div class="ch-block-sub">commits referencing a real issue number — a proxy for "the system is learning from its own failures" (each fix still human/Claude-authored today).</div>' +
2724
+ (guardItems.length ? chLine(guardItems, { color: "#c0392b" }) : '<div class="empty">no guardrail commits found</div>');
2725
+ el.appendChild(guardBlock);
2726
+
2727
+ if (lifecycle && lifecycle.hours.length) {
2728
+ const lifeItems = lifecycle.hours.map(function (h) {
2729
+ return { label: siFmtHour(h.hour), value: h.filed, title: siFmtHour(h.hour) + ": " + h.filed + " filed, " + h.closed + " closed" };
2730
+ });
2731
+ const lifeBlock = document.createElement("div");
2732
+ lifeBlock.className = "ch-block";
2733
+ lifeBlock.innerHTML = '<div class="ch-block-title">Issues filed over time</div>' +
2734
+ '<div class="ch-block-sub">' + fmtInt(lifecycle.totalTracked) + ' issues tracked total on this repo (via gh issue list).</div>' +
2735
+ chBars(lifeItems, { color: "#f0883e" });
2736
+ el.appendChild(lifeBlock);
2737
+ }
2738
+ }
2739
+
2740
+ async function poll() {
2741
+ try {
2742
+ const res = await fetch("/api/summary");
2743
+ if (!res.ok) throw new Error("HTTP " + res.status);
2744
+ const summary = await res.json();
2745
+ document.getElementById("error").style.display = "none";
2746
+ document.getElementById("generatedAt").textContent = "· last updated " + new Date(summary.generatedAt).toLocaleTimeString();
2747
+ renderTotals(summary.totals);
2748
+ renderBlocked(summary.round ? summary.round.blocked : []);
2749
+ renderRound(summary.round);
2750
+ renderSessions(summary.sessions);
2751
+ } catch (err) {
2752
+ const el = document.getElementById("error");
2753
+ el.style.display = "block";
2754
+ el.textContent = "Failed to fetch /api/summary: " + err;
2755
+ }
2756
+ }
2757
+
2758
+ document.getElementById("detailClose").addEventListener("click", closeDetail);
2759
+ document.getElementById("btnPause").addEventListener("click", () => controlSession("pause"));
2760
+ document.getElementById("btnResume").addEventListener("click", () => controlSession("resume"));
2761
+ document.getElementById("btnStart").addEventListener("click", startSession);
2762
+ // Chat-thread vs. flat event-log vs. timeline toggle: all three render the
2763
+ // same feed data, just shaped differently.
2764
+ document.getElementById("tabLog").addEventListener("click", () => setView("log"));
2765
+ document.getElementById("tabChat").addEventListener("click", () => setView("chat"));
2766
+ document.getElementById("tabTimeline").addEventListener("click", () => setView("timeline"));
2767
+ document.getElementById("btnRestore").addEventListener("click", restoreSelected);
2768
+
2769
+ document.getElementById("fbLoad").addEventListener("click", fbLoad);
2770
+ // Enter in the file-browser repo box loads too.
2771
+ document.getElementById("fbRepo").addEventListener("keydown", (ev) => {
2772
+ if (ev.key === "Enter") {
2773
+ ev.preventDefault();
2774
+ fbLoad();
2775
+ }
2776
+ });
2777
+ // Enter in the task box submits too (shift+enter for a newline).
2778
+ document.getElementById("launchTask").addEventListener("keydown", (ev) => {
2779
+ if (ev.key === "Enter" && !ev.shiftKey) {
2780
+ ev.preventDefault();
2781
+ startSession();
2782
+ }
2783
+ });
2784
+ // Stop yanking the view down while the user reads history above.
2785
+ document.getElementById("feed").addEventListener("scroll", () => {
2786
+ if (detail) detail.userScrolledUp = !feedAutoScrolls();
2787
+ });
2788
+
2789
+ poll();
2790
+ loadModeModels();
2791
+ loadPermissions();
2792
+ loadModes();
2793
+ loadCostHistory();
2794
+ loadSelfImprovement();
2795
+ setInterval(poll, 4000);
2796
+ // Live event feed poll: faster than the summary poll (1.5s) so the detail
2797
+ // view feels live, and only runs while a detail view is open.
2798
+ setInterval(pollEvents, 1500);
2799
+ </script>
2800
+ </body>
2801
+ </html>
2802
+ `
2803
+ }