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,1103 @@
1
+ /**
2
+ * Cost/token dashboard — plain `node:http` server (workstream 3) + live
3
+ * per-session events + pause/resume control (live worker monitoring) +
4
+ * per-mode model settings (mode-model-assignment) + browser control-plane
5
+ * (session launch + answer) + checkpoint list/diff/restore.
6
+ *
7
+ * ZERO new UI/framework dependencies, matching this project's established
8
+ * "plain fs + JSON, no schema library" ethos (see `src/orchestrator/state.ts`'s
9
+ * own file-header comment). Bound to `127.0.0.1` only, no auth — explicitly a
10
+ * local-only tool per the plan doc.
11
+ *
12
+ * GET / the static HTML page (src/dashboard/page.ts)
13
+ * GET /api/summary JSON from src/dashboard/aggregate.ts's buildSummary()
14
+ * GET /api/session/:id/events JSON events feed (incremental via ?since=<offset>)
15
+ * POST /api/session/:id/pause write `.harness.pause-requested` (worker pauses between iterations)
16
+ * POST /api/session/:id/resume remove `.harness.pause-requested` (worker resumes)
17
+ * GET /api/settings/mode-models?repo=<path> current mode-models.json content ({} if absent)
18
+ * POST /api/settings/mode-models?repo=<path> validate + write mode-models.json (creates .headlesscode/)
19
+ * POST /api/session/start launch a detached top-level session (see src/dashboard/session-launch.ts)
20
+ * POST /api/tool/execute run ONE executor tool synchronously against a workspace
21
+ * (see src/dashboard/tool-exec.ts; used by UwUChat's code-mode
22
+ * agent tools — inherits all executor guardrails)
23
+ * POST /api/session/:id/answer write `.harness.decision-answer` to unblock an escalated question
24
+ * POST /api/session/:id/message write `.harness.inject-message` ({ text, injectedAt }) — a new
25
+ * user message the RUNNING session picks up before its next LLM call
26
+ * (mid-session message injection; see src/engine/loop.ts)
27
+ * GET /api/modes?repo=<path> merged available modes (.roomodes + global) for the mode selector
28
+ * GET /api/checkpoints?repo=<path>&session=<taskId>
29
+ * list checkpoints for a session (CheckpointService.list())
30
+ * GET /api/checkpoints/diff?repo=<path>&session=<taskId>&from=<hash>&to=<hash?>
31
+ * diff between two checkpoints, or a checkpoint vs. the
32
+ * current working tree when `to` is omitted
33
+ * POST /api/checkpoints/restore?repo=<path>&session=<taskId> body {hash} — restores
34
+ * a checkpoint onto the REAL workspace (destructive;
35
+ * gated by the same optional bearer token as
36
+ * /api/session/* POSTs — see below)
37
+ * GET /api/files?repo=<path>&dir=<rel> list a workspace directory (file browser)
38
+ * GET /api/files/content?repo=<path>&file=<rel> single file's text content (capped/binary-safe)
39
+ * GET /api/settings/permissions?repo=<path> current permissions.json + resolved defaults
40
+ * POST /api/settings/permissions?repo=<path> validate + write permissions.json (creates .headlesscode/)
41
+ * GET /api/codemap?repo=<path> the project's codemap.json (deterministic
42
+ * module/import map; see src/codemap/) — from the
43
+ * central per-project store; 404 until
44
+ * `headlesscode codemap --workspace <path>` has run
45
+ * GET /api/codemap/html?repo=<path> the self-contained interactive visualizer
46
+ * (same store; opens standalone in a browser)
47
+ * GET /api/cost-history?repo=<path>[&since=<iso>][&limit=N]
48
+ * recorded per-group + per-session cost/token/
49
+ * duration history from the central store's
50
+ * cost-history.jsonl / session-cost-history.jsonl
51
+ * (see src/orchestrator/cost-history.ts) as JSON,
52
+ * optionally windowed by recordedAt (since) or
53
+ * most-recent-N (limit)
54
+ * GET /api/projects[?all=1] enumerate the central per-project store
55
+ * (see src/project-store.ts's listProjectEntries):
56
+ * every registered or still-existing project by
57
+ * default; ?all=1 also shows pure stale-unregistered
58
+ * litter (the pre-Part-A orphaned dirs)
59
+ *
60
+ * NOTE (control plane): the POST /api/session/start route can launch real,
61
+ * billed work and run arbitrary shell via `gh`/`orchestrate` from a form
62
+ * POST — a bigger step across the read-only-to-control line than the
63
+ * pause/resume or settings POSTs were individually. POST
64
+ * /api/checkpoints/restore is MORE dangerous still: it reverts REAL
65
+ * workspace files (the shadow repo's core.worktree). Both are local-only /
66
+ * no-auth by default (binds 127.0.0.1), but if these endpoints are ever
67
+ * exposed beyond localhost, that assumption needs revisiting — the restore
68
+ * endpoint in particular MUST stay behind the optional bearer-token gate
69
+ * when one is configured.
70
+ * pause/resume or settings POSTs were individually. Still local-only /
71
+ * no-auth (binds 127.0.0.1), but if this endpoint is ever exposed beyond
72
+ * localhost, that assumption needs revisiting — no auth was added here.
73
+ *
74
+ * NOTE (permissions settings): POST /api/settings/permissions is covered by
75
+ * the same optional bearer-token gate as /api/session/* when a token IS
76
+ * configured. When it isn't, this route is (deliberately) no-auth like
77
+ * everything else — the whole dashboard binds 127.0.0.1 only and is
78
+ * documented as a local-only tool. A separate default-on auth for just this
79
+ * route was considered and rejected: it would be the ONLY endpoint with a
80
+ * different default, surprising anyone running the established local
81
+ * workflow, and the binding/localhost contract is the actual security
82
+ * boundary (a real auth system is a separate, deliberate project-owner
83
+ * decision — see DashboardServerOptions.token). The one thing that is NOT
84
+ * negotiable is that when the gate IS configured, this route is inside it —
85
+ * a weakened permissions file is more consequential than starting a session,
86
+ * so it must never be gated *less* strictly than the session routes.
87
+ */
88
+
89
+ import * as fsp from "node:fs/promises"
90
+ import * as http from "node:http"
91
+ import * as path from "node:path"
92
+
93
+ import { buildSummary, findSessionEventsFile, readSessionEvents } from "./aggregate.js"
94
+ import { computeSelfImprovementMetrics, type SelfImprovementMetrics } from "./self-improvement-metrics.js"
95
+ import { diffCheckpoints, listCheckpoints, renderUnifiedDiff, restoreCheckpoint } from "./checkpoints.js"
96
+ import { codemapMissingError, readCodemapHtml, readCodemapJson } from "./codemap.js"
97
+ import { renderPage } from "./page.js"
98
+ import { answerSessionDecision, ensureWorktree, injectSessionMessage, launchSession, validateSessionStartBody } from "./session-launch.js"
99
+ import { loadCustomModes } from "../engine/prompt.js"
100
+ import {
101
+ loadModeModelsFile,
102
+ modeModelsFilePath,
103
+ stringifyModeModelsFile,
104
+ validateModeModelsBody,
105
+ type ModeModelsFile,
106
+ } from "../config/mode-models.js"
107
+ import { readCostHistory, readSessionCostHistory } from "../orchestrator/cost-history.js"
108
+ import { listProjectEntries } from "../project-store.js"
109
+ import { listWorkspaceDir, readWorkspaceFile } from "./files.js"
110
+ import { executeTool, type ToolExecuteRequest } from "./tool-exec.js"
111
+ import {
112
+ loadPermissionsFile,
113
+ parsePermissionsFileBody,
114
+ permissionsFilePath,
115
+ resolvePermissions,
116
+ stringifyPermissionsFile,
117
+ type PermissionsFile,
118
+ } from "../permissions/config.js"
119
+ import { PathTraversalError } from "../tools/executor.js"
120
+
121
+ /** Default dashboard port (overridable via --port or $HEADLESSCODE_DASHBOARD_PORT). */
122
+ export const DEFAULT_DASHBOARD_PORT = 4390
123
+
124
+ /**
125
+ * Self-improvement metrics (issue #145) cache: computeSelfImprovementMetrics
126
+ * shells out to `gh issue list` (real network + GitHub API), which the
127
+ * dashboard's poll loop hitting this endpoint every few seconds would
128
+ * otherwise fire on every poll — a short in-memory TTL keeps the page
129
+ * responsive and avoids hammering the GitHub API for a value that only
130
+ * meaningfully changes on the order of minutes, not seconds. Deliberately
131
+ * a server/HTTP-layer concern, not baked into the (pure, unit-tested)
132
+ * computation module itself.
133
+ */
134
+ const SELF_IMPROVEMENT_CACHE_TTL_MS = 30_000
135
+ let selfImprovementCache: { repo: string; computedAt: number; data: SelfImprovementMetrics } | undefined
136
+
137
+ export interface DashboardServerOptions {
138
+ port: number
139
+ /** Repo root to scan for usage files + .orchestrator-state.json (optional). */
140
+ repo?: string
141
+ /**
142
+ * Optional control-plane bearer token. When set, POST routes under
143
+ * /api/session/* require `Authorization: Bearer <token>`. Unset (default)
144
+ * = today's behavior: no auth at all. Deliberately optional — a real auth
145
+ * system is a separate, deliberate project-owner decision (see the header
146
+ * note); this is a cheap hook, not a substitute.
147
+ */
148
+ token?: string
149
+ /**
150
+ * CLI invocation for POST /api/session/start (default "npx tsx src/cli.ts").
151
+ * Test-only injection point so the HTTP layer can be exercised against a
152
+ * tiny fake script instead of the real CLI; not a public config surface.
153
+ */
154
+ cli?: string
155
+ /** Directory the launched CLI runs in (default: process.cwd()). Test-only. */
156
+ repoRoot?: string
157
+ /**
158
+ * Root directory for POST /api/projects/ensure-worktree's disposable
159
+ * per-project worktrees (see session-launch.ts's ensureWorktree).
160
+ * Unset (default) = that endpoint responds 501; registration is
161
+ * expected to fall back to the raw repo path in that case.
162
+ */
163
+ worktreeRoot?: string
164
+ /**
165
+ * Shadow-git storage root for the /api/checkpoints/* routes (default:
166
+ * `~/.headlesscode/checkpoints` — the same default the CLI/engine use).
167
+ * Test-only injection so the routes can be exercised against a temp dir
168
+ * instead of the developer's home directory; not a public config surface.
169
+ */
170
+ checkpointDir?: string
171
+ }
172
+
173
+ /** Start the dashboard HTTP server. Resolves once it's listening. */
174
+ export function startDashboardServer(options: DashboardServerOptions): Promise<http.Server> {
175
+ const server = http.createServer((req, res) => {
176
+ // .catch (issue #82): handleRequest is fire-and-forget from this
177
+ // callback's perspective (http.createServer's handler isn't awaited),
178
+ // so an unhandled rejection here would crash the whole process on
179
+ // Node 15+ if any route ever threw instead of writing a response.
180
+ // Fall back to a bare 500 — best-effort, since the response may
181
+ // already be (partially) written by the time a route fails.
182
+ void handleRequest(req, res, options).catch((error) => {
183
+ if (!res.headersSent) {
184
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" }).end(
185
+ JSON.stringify({ error: `internal error: ${error instanceof Error ? error.message : String(error)}` }),
186
+ )
187
+ } else {
188
+ res.end()
189
+ }
190
+ })
191
+ })
192
+
193
+ return new Promise((resolve, reject) => {
194
+ server.once("error", reject)
195
+ server.listen(options.port, "127.0.0.1", () => {
196
+ server.removeListener("error", reject)
197
+ resolve(server)
198
+ })
199
+ })
200
+ }
201
+
202
+ async function handleRequest(
203
+ req: http.IncomingMessage,
204
+ res: http.ServerResponse,
205
+ options: DashboardServerOptions,
206
+ ): Promise<void> {
207
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
208
+
209
+ // Optional control-plane token (HEADLESSCODE_DASHBOARD_TOKEN — see
210
+ // DashboardServerOptions.token). When configured, every state-changing
211
+ // POST requires the bearer header: /api/session/* (start/pause/resume/
212
+ // answer), the destructive POST /api/checkpoints/restore (reverts real
213
+ // workspace files — never gated looser than the session routes), and
214
+ // POST /api/settings/permissions (a weakened permissions file is more
215
+ // consequential than starting a session — same gate).
216
+ //
217
+ // SEC-7: the same token ALSO gates the GET routes that read workspace
218
+ // files or project data (file browser, checkpoints, codemap, cost
219
+ // history, project listing). Without this, any page open in the
220
+ // operator's browser — including a malicious one, via DNS-rebinding or
221
+ // simple localhost-CSRF (`fetch("http://127.0.0.1:4390/api/files/content?...")`
222
+ // from an unrelated origin) — could read `.env` and other workspace
223
+ // files with zero auth, since GETs carry no CSRF protection by default
224
+ // and the dashboard binds 127.0.0.1 only (reachable by anything running
225
+ // on the same machine, including browser tabs). Unset token = no auth,
226
+ // exactly today's behavior — this is opt-in defense in depth, not a
227
+ // guarantee (see SECURITY.md's "Known limitations").
228
+ const tokenGatedGetPrefixes = [
229
+ "/api/files",
230
+ "/api/checkpoints",
231
+ "/api/codemap",
232
+ "/api/cost-history",
233
+ "/api/projects",
234
+ ]
235
+ const isTokenGatedGet =
236
+ req.method === "GET" && tokenGatedGetPrefixes.some((p) => url.pathname === p || url.pathname.startsWith(`${p}/`))
237
+ if (
238
+ options.token &&
239
+ ((req.method === "POST" &&
240
+ (url.pathname.startsWith("/api/session/") ||
241
+ url.pathname === "/api/checkpoints/restore" ||
242
+ url.pathname === "/api/settings/permissions" ||
243
+ url.pathname === "/api/tool/execute")) ||
244
+ isTokenGatedGet)
245
+ ) {
246
+ const auth = req.headers.authorization ?? ""
247
+ const expected = `Bearer ${options.token}`
248
+ if (auth !== expected) {
249
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" }).end(
250
+ JSON.stringify({ error: "unauthorized — set Authorization: Bearer <token>" }),
251
+ )
252
+ return
253
+ }
254
+ }
255
+
256
+ // Live session detail: GET /api/session/:id/events?since=<offset>&repo=<path>
257
+ const eventsMatch = /^\/api\/session\/([^/]+)\/events$/.exec(url.pathname)
258
+ if (eventsMatch && req.method === "GET") {
259
+ const sessionId = decodeURIComponent(eventsMatch[1])
260
+ const repo = url.searchParams.get("repo") || options.repo
261
+ if (!repo) {
262
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
263
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
264
+ )
265
+ return
266
+ }
267
+ const sinceRaw = url.searchParams.get("since")
268
+ const since = sinceRaw === null || sinceRaw === "" ? 0 : Number(sinceRaw)
269
+ if (!Number.isFinite(since) || since < 0) {
270
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
271
+ JSON.stringify({ error: "invalid since offset" }),
272
+ )
273
+ return
274
+ }
275
+ try {
276
+ const { events, nextOffset } = await readSessionEvents(repo, sessionId, since)
277
+ res
278
+ .writeHead(200, { "content-type": "application/json; charset=utf-8" })
279
+ .end(JSON.stringify({ sessionId, events, nextOffset }))
280
+ } catch (error) {
281
+ res
282
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
283
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
284
+ }
285
+ return
286
+ }
287
+
288
+ // Pause/resume control (Phase 3): POST /api/session/:id/pause|resume —
289
+ // deliberately POST-only (a state-changing action must not be triggerable
290
+ // by a GET/prefetch). Pause writes `.harness.pause-requested`; resume
291
+ // removes it (presence/absence IS the signal — see src/engine/loop.ts).
292
+ const pauseMatch = /^\/api\/session\/([^/]+)\/(pause|resume)$/.exec(url.pathname)
293
+ if (pauseMatch && req.method !== "POST") {
294
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
295
+ return
296
+ }
297
+ if (pauseMatch && req.method === "POST") {
298
+ const sessionId = decodeURIComponent(pauseMatch[1])
299
+ const action = pauseMatch[2]
300
+ const repo = url.searchParams.get("repo") || options.repo
301
+ if (!repo) {
302
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
303
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
304
+ )
305
+ return
306
+ }
307
+ try {
308
+ const found = await findSessionEventsFile(repo, sessionId)
309
+ if (!found) {
310
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
311
+ JSON.stringify({ error: `no session ${sessionId} found under ${repo}` }),
312
+ )
313
+ return
314
+ }
315
+ // The marker lives in the worktree that owns the session's events feed.
316
+ const worktreeRoot = found.source === "." ? path.resolve(repo) : path.resolve(repo, found.source)
317
+ const markerPath = path.join(worktreeRoot, ".harness.pause-requested")
318
+ if (action === "pause") {
319
+ await fsp.writeFile(markerPath, new Date().toISOString() + "\n", "utf-8")
320
+ } else {
321
+ await fsp.rm(markerPath, { force: true })
322
+ }
323
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
324
+ JSON.stringify({ sessionId, action, ok: true, worktree: found.source }),
325
+ )
326
+ } catch (error) {
327
+ res
328
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
329
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
330
+ }
331
+ return
332
+ }
333
+
334
+ // Resolve (creating if needed) the disposable worktree a registered
335
+ // project's sessions should run against: POST /api/projects/ensure-worktree
336
+ // {repo: <source repo path>} -> {workspace: <worktree path>}. Called once
337
+ // at project-registration time (see the host project's headlesscode service),
338
+ // not per-launch — the caller stores the returned path and reuses it for
339
+ // every session/poll/pause/resume/message operation on that project.
340
+ if (url.pathname === "/api/projects/ensure-worktree" && req.method !== "POST") {
341
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
342
+ return
343
+ }
344
+ if (url.pathname === "/api/projects/ensure-worktree" && req.method === "POST") {
345
+ if (!options.worktreeRoot) {
346
+ res
347
+ .writeHead(501, { "content-type": "application/json; charset=utf-8" })
348
+ .end(JSON.stringify({ error: "worktreeRoot not configured (HEADLESSCODE_DASHBOARD_WORKTREE_ROOT)" }))
349
+ return
350
+ }
351
+ try {
352
+ const raw = await readJsonBody(req)
353
+ let body: unknown
354
+ try {
355
+ body = JSON.parse(raw || "{}")
356
+ } catch (err) {
357
+ res
358
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
359
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
360
+ return
361
+ }
362
+ const repo = (body as { repo?: string }).repo?.trim()
363
+ if (!repo) {
364
+ res
365
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
366
+ .end(JSON.stringify({ error: "missing 'repo'" }))
367
+ return
368
+ }
369
+ const workspace = ensureWorktree(options.worktreeRoot, repo)
370
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ workspace }))
371
+ } catch (error) {
372
+ res
373
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
374
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
375
+ }
376
+ return
377
+ }
378
+
379
+ // Launch a top-level session (browser control plane): POST /api/session/start
380
+ // spawns a detached `npx tsx src/cli.ts --task ... --mode ... --workspace ...`
381
+ // background process (see src/dashboard/session-launch.ts) and returns the
382
+ // new session's id immediately, so the browser can open its live event view
383
+ // before the first event exists. repo defaults to the dashboard's --repo;
384
+ // mode defaults to multi-agent-orchestrator-headless.
385
+ if (url.pathname === "/api/session/start" && req.method !== "POST") {
386
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
387
+ return
388
+ }
389
+ if (url.pathname === "/api/session/start" && req.method === "POST") {
390
+ try {
391
+ const raw = await readJsonBody(req)
392
+ let body: unknown
393
+ try {
394
+ body = JSON.parse(raw || "{}")
395
+ } catch (err) {
396
+ res
397
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
398
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
399
+ return
400
+ }
401
+ const validationError = validateSessionStartBody(body)
402
+ if (validationError) {
403
+ res
404
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
405
+ .end(JSON.stringify({ error: validationError }))
406
+ return
407
+ }
408
+ const start = body as { task: string; repo?: string; mode?: string }
409
+ const repo = start.repo?.trim() || options.repo
410
+ if (!repo) {
411
+ res
412
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
413
+ .end(
414
+ JSON.stringify({
415
+ error: "missing repo — pass 'repo' in the body or start the dashboard with --repo",
416
+ }),
417
+ )
418
+ return
419
+ }
420
+ const launched = await launchSession({
421
+ workspace: repo,
422
+ task: start.task,
423
+ mode: start.mode,
424
+ cli: options.cli,
425
+ repoRoot: options.repoRoot,
426
+ })
427
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(launched))
428
+ } catch (error) {
429
+ res
430
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
431
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
432
+ }
433
+ return
434
+ }
435
+
436
+ // Run ONE executor tool synchronously against a workspace (UwUChat's
437
+ // code-mode agent tools): POST /api/tool/execute { workspace, name, args }.
438
+ // Builds a fresh ToolExecutor per call (stateless), runs the named tool,
439
+ // returns { ok, isError, content }. Every guardrail is inherited from the
440
+ // executor — workspace path safety, command allow/deny, protected-file
441
+ // permissions, output truncation. Backgrounded execute_command children are
442
+ // hard-killed after the call (executeTool's dispose() — the proxy does not
443
+ // support long-running background commands). Gated by the same optional
444
+ // bearer token as /api/session/* (see the token check above).
445
+ if (url.pathname === "/api/tool/execute" && req.method !== "POST") {
446
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
447
+ return
448
+ }
449
+ if (url.pathname === "/api/tool/execute" && req.method === "POST") {
450
+ try {
451
+ const raw = await readJsonBody(req)
452
+ let body: unknown
453
+ try {
454
+ body = JSON.parse(raw || "{}")
455
+ } catch (err) {
456
+ res
457
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
458
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
459
+ return
460
+ }
461
+ const request = body as ToolExecuteRequest
462
+ const result = await executeTool(request)
463
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result))
464
+ } catch (error) {
465
+ res
466
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
467
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
468
+ }
469
+ return
470
+ }
471
+
472
+ // Answer a blocked session's escalated question (browser control plane):
473
+ // POST /api/session/:id/answer writes `.harness.decision-answer` in the
474
+ // session's workspace root, which ask_followup_question polls for (see
475
+ // src/tools/executor.ts). Works for ANY session — worker or top-level —
476
+ // because the marker mechanism doesn't know who answers it.
477
+ const answerMatch = /^\/api\/session\/([^/]+)\/answer$/.exec(url.pathname)
478
+ if (answerMatch && req.method !== "POST") {
479
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
480
+ return
481
+ }
482
+ if (answerMatch && req.method === "POST") {
483
+ const sessionId = decodeURIComponent(answerMatch[1])
484
+ const repo = url.searchParams.get("repo") || options.repo
485
+ if (!repo) {
486
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
487
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
488
+ )
489
+ return
490
+ }
491
+ try {
492
+ const found = await findSessionEventsFile(repo, sessionId)
493
+ if (!found) {
494
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
495
+ JSON.stringify({ error: `no session ${sessionId} found under ${repo}` }),
496
+ )
497
+ return
498
+ }
499
+ const raw = await readJsonBody(req)
500
+ let body: unknown
501
+ try {
502
+ body = JSON.parse(raw || "{}")
503
+ } catch (err) {
504
+ res
505
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
506
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
507
+ return
508
+ }
509
+ const answer = (body as { answer?: unknown })?.answer
510
+ if (typeof answer !== "string" || answer.trim() === "") {
511
+ res
512
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
513
+ .end(JSON.stringify({ error: "missing or empty 'answer' — provide the answer text to unblock the session" }))
514
+ return
515
+ }
516
+ // The marker lives in the worktree that owns the session's events
517
+ // feed (same resolution pause/resume uses above).
518
+ const worktreeRoot = found.source === "." ? path.resolve(repo) : path.resolve(repo, found.source)
519
+ await answerSessionDecision(worktreeRoot, answer.trim())
520
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
521
+ JSON.stringify({ sessionId, ok: true, worktree: found.source }),
522
+ )
523
+ } catch (error) {
524
+ res
525
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
526
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
527
+ }
528
+ return
529
+ }
530
+
531
+ // Inject a new user message into a RUNNING session (live chat-UI control):
532
+ // POST /api/session/:id/message writes `.harness.inject-message` (JSON:
533
+ // { text, injectedAt }) in the session's workspace root — the SAME marker
534
+ // protocol pause/resume/answer use. The loop's checkInjectedMessage (see
535
+ // src/engine/loop.ts) picks it up before its next LLM call, appends the
536
+ // text as a plain user-role message, and deletes the marker — the model
537
+ // sees it as if the user had just typed it, NOT as a tool result or an
538
+ // interruption. Deliberately distinct from /answer (answering a pending
539
+ // ask_followup_question vs. an unprompted new message). Policy: one
540
+ // pending message — a second POST before the first is picked up
541
+ // OVERWRITES it (overwrite-with-latest, no queue). Same auth gate as the
542
+ // other /api/session/* POSTs (the generic check at the top).
543
+ const messageMatch = /^\/api\/session\/([^/]+)\/message$/.exec(url.pathname)
544
+ if (messageMatch && req.method !== "POST") {
545
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
546
+ return
547
+ }
548
+ if (messageMatch && req.method === "POST") {
549
+ const sessionId = decodeURIComponent(messageMatch[1])
550
+ const repo = url.searchParams.get("repo") || options.repo
551
+ if (!repo) {
552
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
553
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
554
+ )
555
+ return
556
+ }
557
+ try {
558
+ const found = await findSessionEventsFile(repo, sessionId)
559
+ if (!found) {
560
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
561
+ JSON.stringify({ error: `no session ${sessionId} found under ${repo}` }),
562
+ )
563
+ return
564
+ }
565
+ const raw = await readJsonBody(req)
566
+ let body: unknown
567
+ try {
568
+ body = JSON.parse(raw || "{}")
569
+ } catch (err) {
570
+ res
571
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
572
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
573
+ return
574
+ }
575
+ const text = (body as { text?: unknown })?.text
576
+ if (typeof text !== "string" || text.trim() === "") {
577
+ res
578
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
579
+ .end(JSON.stringify({ error: "missing or empty 'text' — provide the message text to inject into the running session" }))
580
+ return
581
+ }
582
+ // The marker lives in the worktree that owns the session's events
583
+ // feed (same resolution pause/resume/answer use above).
584
+ const worktreeRoot = found.source === "." ? path.resolve(repo) : path.resolve(repo, found.source)
585
+ await injectSessionMessage(worktreeRoot, text.trim())
586
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
587
+ JSON.stringify({ sessionId, ok: true, worktree: found.source }),
588
+ )
589
+ } catch (error) {
590
+ res
591
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
592
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
593
+ }
594
+ return
595
+ }
596
+
597
+ // Per-mode model settings (mode-model-assignment): GET returns the current
598
+ // `.headlesscode/mode-models.json` content ({} when absent); POST validates
599
+ // the JSON body with the same rules the config loader uses and writes it
600
+ // (creating `.headlesscode/` if needed). A malformed save is rejected with
601
+ // a clear error — never writes garbage that later throws on a worker read.
602
+ if (url.pathname === "/api/settings/mode-models") {
603
+ const repo = url.searchParams.get("repo") || options.repo
604
+ if (!repo) {
605
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
606
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
607
+ )
608
+ return
609
+ }
610
+ if (req.method === "GET") {
611
+ try {
612
+ // Reuse the strict loader: a malformed on-disk file surfaces as
613
+ // an error here instead of a silently-empty settings page.
614
+ const file = loadModeModelsFile(repo)
615
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
616
+ JSON.stringify(file ?? {}),
617
+ )
618
+ } catch (error) {
619
+ res
620
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
621
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
622
+ }
623
+ return
624
+ }
625
+ if (req.method === "POST") {
626
+ try {
627
+ const raw = await readJsonBody(req)
628
+ let body: unknown
629
+ try {
630
+ body = JSON.parse(raw)
631
+ } catch (err) {
632
+ res
633
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
634
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
635
+ return
636
+ }
637
+ // Same strict validation the config loader applies on read.
638
+ try {
639
+ validateModeModelsBody(body)
640
+ } catch (err) {
641
+ res
642
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
643
+ .end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
644
+ return
645
+ }
646
+ const file = modeModelsFilePath(repo)
647
+ await fsp.mkdir(path.dirname(file), { recursive: true })
648
+ await fsp.writeFile(file, stringifyModeModelsFile(body as ModeModelsFile), "utf-8")
649
+ res
650
+ .writeHead(200, { "content-type": "application/json; charset=utf-8" })
651
+ .end(JSON.stringify({ ok: true, path: file }))
652
+ } catch (error) {
653
+ res
654
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
655
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
656
+ }
657
+ return
658
+ }
659
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
660
+ return
661
+ }
662
+
663
+ // Checkpoints — browser control plane. `repo` is the workspace whose
664
+ // files the shadow git repo tracks (the dashboard's --repo); `session` is
665
+ // the session/task id (HeadlessSession uses its sessionId as the
666
+ // checkpoint taskId — see src/engine/loop.ts). list/diff wrap the
667
+ // existing CheckpointService (src/checkpoints/service.ts) read-only;
668
+ // restore is the one DESTRUCTIVE action this dashboard can perform and is
669
+ // gated by the optional bearer token above (same gate as /api/session/*
670
+ // POSTs — never looser).
671
+ if (url.pathname === "/api/checkpoints" || url.pathname === "/api/checkpoints/diff") {
672
+ const repo = url.searchParams.get("repo") || options.repo
673
+ const session = url.searchParams.get("session")
674
+ if (!repo || !session) {
675
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
676
+ JSON.stringify({ error: "missing repo or session — pass ?repo=<path>&session=<taskId>" }),
677
+ )
678
+ return
679
+ }
680
+ const routeOptions = { workspaceRoot: repo, sessionId: session, checkpointDir: options.checkpointDir }
681
+ try {
682
+ if (url.pathname === "/api/checkpoints") {
683
+ const entries = await listCheckpoints(routeOptions)
684
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ entries }))
685
+ } else {
686
+ const from = url.searchParams.get("from")
687
+ if (!from) {
688
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
689
+ JSON.stringify({ error: "missing from — pass ?from=<hash> (optional &to=<hash>)" }),
690
+ )
691
+ return
692
+ }
693
+ const to = url.searchParams.get("to") ?? undefined
694
+ const changes = await diffCheckpoints(routeOptions, from, to)
695
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
696
+ JSON.stringify({ diff: renderUnifiedDiff(changes), changes }),
697
+ )
698
+ }
699
+ } catch (error) {
700
+ res
701
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
702
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
703
+ }
704
+ return
705
+ }
706
+
707
+ // File browser (dashboard-file-browser-and-permissions-ui): GET
708
+ // /api/files?repo=<path>&dir=<rel> lists a workspace directory's entries.
709
+ // The path-safety guard (resolveWithinWorkspace + PathTraversalError) is
710
+ // applied inside src/dashboard/files.ts — a traversal attempt surfaces as
711
+ // a 400, never a file-system read outside the workspace.
712
+ if (url.pathname === "/api/files" && req.method === "GET") {
713
+ const repo = url.searchParams.get("repo") || options.repo
714
+ if (!repo) {
715
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
716
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
717
+ )
718
+ return
719
+ }
720
+ const dir = url.searchParams.get("dir") || "."
721
+ try {
722
+ const entries = await listWorkspaceDir(repo, dir)
723
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ dir, entries }))
724
+ } catch (error) {
725
+ if (error instanceof PathTraversalError) {
726
+ res
727
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
728
+ .end(JSON.stringify({ error: error.message }))
729
+ return
730
+ }
731
+ res
732
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
733
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
734
+ }
735
+ return
736
+ }
737
+
738
+ // Restore a checkpoint — DESTRUCTIVE (reverts REAL workspace files).
739
+ // POST-only (a state-changing action must not be triggerable by a GET),
740
+ // body {hash}, gated by the optional bearer token above.
741
+ if (url.pathname === "/api/checkpoints/restore" && req.method !== "POST") {
742
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
743
+ return
744
+ }
745
+ if (url.pathname === "/api/checkpoints/restore" && req.method === "POST") {
746
+ const repo = url.searchParams.get("repo") || options.repo
747
+ const session = url.searchParams.get("session")
748
+ if (!repo || !session) {
749
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
750
+ JSON.stringify({ error: "missing repo or session — pass ?repo=<path>&session=<taskId>" }),
751
+ )
752
+ return
753
+ }
754
+ try {
755
+ const raw = await readJsonBody(req)
756
+ let body: unknown
757
+ try {
758
+ body = JSON.parse(raw || "{}")
759
+ } catch (err) {
760
+ res
761
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
762
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
763
+ return
764
+ }
765
+ const hash = (body as { hash?: unknown })?.hash
766
+ if (typeof hash !== "string" || hash.trim() === "") {
767
+ res
768
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
769
+ .end(JSON.stringify({ error: "missing or empty 'hash' — provide the checkpoint hash to restore" }))
770
+ return
771
+ }
772
+ await restoreCheckpoint(
773
+ { workspaceRoot: repo, sessionId: session, checkpointDir: options.checkpointDir },
774
+ hash.trim(),
775
+ )
776
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
777
+ JSON.stringify({ ok: true, restored: hash.trim() }),
778
+ )
779
+ } catch (error) {
780
+ res
781
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
782
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
783
+ }
784
+ return
785
+ }
786
+ // GET /api/files/content?repo=<path>&file=<rel> — one file's text content,
787
+ // capped/binary-safe (see src/dashboard/files.ts). Same traversal guard.
788
+ if (url.pathname === "/api/files/content" && req.method === "GET") {
789
+ const repo = url.searchParams.get("repo") || options.repo
790
+ if (!repo) {
791
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
792
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
793
+ )
794
+ return
795
+ }
796
+ const file = url.searchParams.get("file")
797
+ if (!file) {
798
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
799
+ JSON.stringify({ error: "missing file — pass ?file=<relative-path>" }),
800
+ )
801
+ return
802
+ }
803
+ try {
804
+ const result = await readWorkspaceFile(repo, file)
805
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ file, ...result }))
806
+ } catch (error) {
807
+ if (error instanceof PathTraversalError) {
808
+ res
809
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
810
+ .end(JSON.stringify({ error: error.message }))
811
+ return
812
+ }
813
+ res
814
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
815
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
816
+ }
817
+ return
818
+ }
819
+
820
+ // Permissions settings (dashboard-file-browser-and-permissions-ui): GET
821
+ // /api/settings/permissions?repo=<path> returns the current
822
+ // .headlesscode/permissions.json content OR the resolved built-in defaults
823
+ // clearly labeled as defaults-not-yet-customized (mirroring the
824
+ // mode-models page's "no file yet" UX, plus the resolved view so the page
825
+ // always shows what a session would actually enforce). POST validates with
826
+ // the SAME parser the config loader uses (parsePermissionsFileBody — no
827
+ // second validator that could drift) and writes the file. POST is covered
828
+ // by the optional bearer-token gate above.
829
+ if (url.pathname === "/api/settings/permissions") {
830
+ const repo = url.searchParams.get("repo") || options.repo
831
+ if (!repo) {
832
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
833
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
834
+ )
835
+ return
836
+ }
837
+ if (req.method === "GET") {
838
+ try {
839
+ // Reuse the strict loader: a malformed on-disk file surfaces as
840
+ // an error here instead of a silently-empty settings page.
841
+ const file = loadPermissionsFile(repo)
842
+ const resolved = resolvePermissions({ workspaceRoot: repo })
843
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
844
+ JSON.stringify({ file, resolved, isDefault: file === null }),
845
+ )
846
+ } catch (error) {
847
+ res
848
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
849
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
850
+ }
851
+ return
852
+ }
853
+ if (req.method === "POST") {
854
+ try {
855
+ const raw = await readJsonBody(req)
856
+ let body: unknown
857
+ try {
858
+ body = JSON.parse(raw)
859
+ } catch (err) {
860
+ res
861
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
862
+ .end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
863
+ return
864
+ }
865
+ // Same strict validation the config loader applies on read —
866
+ // parsePermissionsFileBody IS the loader's validator, so the
867
+ // save path can never drift from what the CLI/env path enforces.
868
+ let parsed: PermissionsFile
869
+ try {
870
+ parsed = parsePermissionsFileBody(body, "body")
871
+ } catch (err) {
872
+ res
873
+ .writeHead(400, { "content-type": "application/json; charset=utf-8" })
874
+ .end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
875
+ return
876
+ }
877
+ const file = permissionsFilePath(repo)
878
+ await fsp.mkdir(path.dirname(file), { recursive: true })
879
+ await fsp.writeFile(file, stringifyPermissionsFile(parsed), "utf-8")
880
+ res
881
+ .writeHead(200, { "content-type": "application/json; charset=utf-8" })
882
+ .end(JSON.stringify({ ok: true, path: file }))
883
+ } catch (error) {
884
+ res
885
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
886
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
887
+ }
888
+ return
889
+ }
890
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
891
+ return
892
+ }
893
+
894
+ if (req.method !== "GET") {
895
+ res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
896
+ return
897
+ }
898
+
899
+ if (url.pathname === "/") {
900
+ const html = renderPage(options.repo)
901
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(html)
902
+ return
903
+ }
904
+
905
+ if (url.pathname === "/api/summary") {
906
+ try {
907
+ const summary = await buildSummary(options.repo)
908
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(summary))
909
+ } catch (error) {
910
+ res
911
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
912
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
913
+ }
914
+ return
915
+ }
916
+
917
+ // Recursive self-improvement progress metrics (issue #145): GET
918
+ // /api/self-improvement?repo=<path>. Local-dashboard-only per direct
919
+ // product direction (2026-08-21) — the external home.capsize.online
920
+ // deploy the original issue named is explicitly out of scope; this repo
921
+ // only needs to expose the data + a local view of it.
922
+ if (url.pathname === "/api/self-improvement" && req.method === "GET") {
923
+ const repo = url.searchParams.get("repo") || options.repo
924
+ if (!repo) {
925
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
926
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
927
+ )
928
+ return
929
+ }
930
+ try {
931
+ const cached = selfImprovementCache
932
+ const fresh = cached && cached.repo === repo && Date.now() - cached.computedAt < SELF_IMPROVEMENT_CACHE_TTL_MS
933
+ const data = fresh && cached ? cached.data : await computeSelfImprovementMetrics(repo)
934
+ if (!fresh) {
935
+ selfImprovementCache = { repo, computedAt: Date.now(), data }
936
+ }
937
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(data))
938
+ } catch (error) {
939
+ res
940
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
941
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
942
+ }
943
+ return
944
+ }
945
+
946
+ // Historical cost/token/duration (issue #29): GET /api/cost-history?repo=<path>
947
+ // serves the central store's recorded per-group cost history AND per-session
948
+ // breakdown (cost-history.jsonl / session-cost-history.jsonl — see
949
+ // src/orchestrator/cost-history.ts) as JSON. Read-only; a repo with no
950
+ // history yet yields empty arrays, never an error (missing files are handled
951
+ // by the readers). Optional windowing: ?since=<iso> keeps only records
952
+ // recorded at/after the timestamp (ISO-8601 strings compare correctly), and
953
+ // ?limit=N keeps the N most recently recorded — both applied after sorting
954
+ // newest-first, matching the CLI's most-recent-first presentation.
955
+ if (url.pathname === "/api/cost-history" && req.method === "GET") {
956
+ const repo = url.searchParams.get("repo") || options.repo
957
+ if (!repo) {
958
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
959
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
960
+ )
961
+ return
962
+ }
963
+ const sinceRaw = url.searchParams.get("since")
964
+ let since: string | undefined
965
+ if (sinceRaw !== null && sinceRaw !== "") {
966
+ if (Number.isNaN(Date.parse(sinceRaw))) {
967
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
968
+ JSON.stringify({ error: "invalid since — pass an ISO-8601 timestamp, e.g. ?since=2026-08-05T00:00:00Z" }),
969
+ )
970
+ return
971
+ }
972
+ since = sinceRaw
973
+ }
974
+ const limitRaw = url.searchParams.get("limit")
975
+ let limit: number | undefined
976
+ if (limitRaw !== null && limitRaw !== "") {
977
+ limit = Number(limitRaw)
978
+ if (!Number.isInteger(limit) || limit <= 0) {
979
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
980
+ JSON.stringify({ error: "invalid limit — pass a positive integer" }),
981
+ )
982
+ return
983
+ }
984
+ }
985
+ try {
986
+ let groups = await readCostHistory(repo)
987
+ let sessions = await readSessionCostHistory(repo)
988
+ if (since) {
989
+ // Compare parsed instants, not raw strings: recordedAt is always
990
+ // millisecond-precision ("...000Z") while a hand-typed ?since=
991
+ // (e.g. the error message's own example) is often second-precision
992
+ // ("...Z") — the same instant in both forms compares unequal (and
993
+ // sorts wrong) as strings because "." (0x2E) < "Z" (0x5A).
994
+ const sinceMs = Date.parse(since)
995
+ groups = groups.filter((r) => Date.parse(r.recordedAt) >= sinceMs)
996
+ sessions = sessions.filter((r) => Date.parse(r.recordedAt) >= sinceMs)
997
+ }
998
+ // Newest first (the ordering the CLI prints), then window to N.
999
+ groups.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt))
1000
+ sessions.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt))
1001
+ if (limit !== undefined) {
1002
+ groups = groups.slice(0, limit)
1003
+ sessions = sessions.slice(0, limit)
1004
+ }
1005
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
1006
+ JSON.stringify({ generatedAt: new Date().toISOString(), repo: path.resolve(repo), groups, sessions }),
1007
+ )
1008
+ } catch (error) {
1009
+ res
1010
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
1011
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
1012
+ }
1013
+ return
1014
+ }
1015
+
1016
+ // Central per-project store registry (project-registry-and-store-cleanup):
1017
+ // GET /api/projects enumerates every <key>/ dir under the store root via
1018
+ // the same listProjectEntries the `headlesscode projects list` CLI uses.
1019
+ // Default view hides pure stale-unregistered litter (registered or
1020
+ // still-existing only); ?all=1 shows everything. Read-only, no ?repo=
1021
+ // needed — the store root is machine-global, not repo-scoped.
1022
+ if (url.pathname === "/api/projects" && req.method === "GET") {
1023
+ try {
1024
+ const all = url.searchParams.get("all") === "1"
1025
+ const entries = listProjectEntries()
1026
+ const filtered = all ? entries : entries.filter((e) => e.registered || e.exists)
1027
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
1028
+ JSON.stringify({ projects: filtered }),
1029
+ )
1030
+ } catch (error) {
1031
+ res
1032
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
1033
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
1034
+ }
1035
+ return
1036
+ }
1037
+
1038
+ // Available modes for the "start a session" mode selector: the project's
1039
+ // own .roomodes merged with global ~/.roo/custom_modes.yaml (same loader
1040
+ // the CLI uses — see src/engine/prompt.ts). The selector falls back to a
1041
+ // plain text input when this is unavailable, so a failure here is a
1042
+ // non-fatal degradation, not an error page.
1043
+ if (url.pathname === "/api/modes") {
1044
+ const repo = url.searchParams.get("repo") || options.repo
1045
+ if (!repo) {
1046
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
1047
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
1048
+ )
1049
+ return
1050
+ }
1051
+ try {
1052
+ const customModes = await loadCustomModes(repo)
1053
+ const modes = customModes.map((m) => ({ slug: m.slug, name: m.name ?? m.slug, source: m.source ?? "unknown" }))
1054
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ modes }))
1055
+ } catch (error) {
1056
+ res
1057
+ .writeHead(500, { "content-type": "application/json; charset=utf-8" })
1058
+ .end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
1059
+ }
1060
+ return
1061
+ }
1062
+
1063
+ // Deterministic per-project codemap (issue #17): GET /api/codemap?repo=<path>
1064
+ // serves the stored codemap.json and GET /api/codemap/html?repo=<path>
1065
+ // serves the self-contained visualizer — both read from the CENTRAL
1066
+ // per-project store, exactly what `headlesscode codemap --workspace`
1067
+ // writes. The dashboard never generates the map on request (it's a
1068
+ // deterministic script job); a missing map is a 404 with a clear pointer
1069
+ // to the generating command.
1070
+ if (url.pathname === "/api/codemap" || url.pathname === "/api/codemap/html") {
1071
+ const repo = url.searchParams.get("repo") || options.repo
1072
+ if (!repo) {
1073
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
1074
+ JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
1075
+ )
1076
+ return
1077
+ }
1078
+ const wantHtml = url.pathname === "/api/codemap/html"
1079
+ const found = wantHtml ? readCodemapHtml(repo) : readCodemapJson(repo)
1080
+ if (found === undefined) {
1081
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
1082
+ JSON.stringify({ error: codemapMissingError(repo) }),
1083
+ )
1084
+ return
1085
+ }
1086
+ res.writeHead(200, { "content-type": found.contentType }).end(found.content)
1087
+ return
1088
+ }
1089
+
1090
+ res.writeHead(404, { "content-type": "text/plain" }).end("Not Found")
1091
+ }
1092
+
1093
+ /** Read a request body as text (bounded — settings payloads are tiny). */
1094
+ async function readJsonBody(req: http.IncomingMessage): Promise<string> {
1095
+ const chunks: Buffer[] = []
1096
+ for await (const chunk of req) {
1097
+ chunks.push(chunk as Buffer)
1098
+ if (chunks.reduce((n, c) => n + c.length, 0) > 1_000_000) {
1099
+ throw new Error("request body too large (limit 1MB)")
1100
+ }
1101
+ }
1102
+ return Buffer.concat(chunks).toString("utf-8")
1103
+ }