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,364 @@
1
+ /**
2
+ * Cost/token dashboard — pure aggregation logic (workstream 3) + live
3
+ * per-session event feed (live worker monitoring).
4
+ *
5
+ * Scans `.headlesscode/usage/*.jsonl` files (see `src/engine/usage.ts` for
6
+ * the on-disk shape/writer) under a repo root — both the repo's own usage
7
+ * dir (direct, non-orchestrated runs) and every `.worktrees/<name>/` worker
8
+ * worktree (orchestrated runs) — and, when `.worktrees/.orchestrator-state.json`
9
+ * exists, folds in the round-level rollup (per-group usage + any group
10
+ * currently `status: "blocked"`, surfacing the pending question).
11
+ *
12
+ * `readSessionEvents` (the Phase 1 live-monitoring feed) reads a session's
13
+ * `<workspaceRoot>/.headlesscode/events/<sessionId>.jsonl` (see
14
+ * `src/engine/events.ts` for the on-disk shape/writer) from the repo root's
15
+ * own events dir OR any `.worktrees/<name>/` events dir — the same dual
16
+ * search `buildSummary` performs for usage files.
17
+ *
18
+ * Deliberately pure + fs-only: no `node:http` here, so it's unit-testable
19
+ * without spinning up the dashboard server (see `src/dashboard/server.ts`
20
+ * for the HTTP wrapper that calls `buildSummary`).
21
+ *
22
+ * Validation is loose (matches the project's established ethos — plain fs +
23
+ * JSON, no schema library): missing/malformed files are skipped, never
24
+ * thrown from `buildSummary`.
25
+ */
26
+
27
+ import * as fs from "node:fs/promises"
28
+ import * as path from "node:path"
29
+
30
+ import { eventsFilePath, readEventsFile, type EventRecord } from "../engine/events.js"
31
+ import { readLiveUsageFile, readUsageFile, type LiveUsageRecord, type UsageRecord } from "../engine/usage.js"
32
+ import { loadState, type OrchestratorGroup, type OrchestratorState } from "../orchestrator/state.js"
33
+
34
+ /** One row in the dashboard's per-session table. */
35
+ export interface SessionRow {
36
+ sessionId: string
37
+ mode: string
38
+ model: string
39
+ iterations: number
40
+ inputTokens: number
41
+ outputTokens: number
42
+ /** Subset of inputTokens served from the provider's prompt cache (0 when unreported). */
43
+ cachedTokens: number
44
+ costUsd: number
45
+ startedAt: string
46
+ endedAt: string
47
+ status: string
48
+ workspaceRoot: string
49
+ /** Which worktree/usage dir this record was found under (relative to repo, for display). */
50
+ source: string
51
+ }
52
+
53
+ export interface UsageTotals {
54
+ costUsd: number
55
+ inputTokens: number
56
+ outputTokens: number
57
+ cachedTokens: number
58
+ iterations: number
59
+ sessionCount: number
60
+ }
61
+
62
+ /** A group currently blocked on `ask_followup_question`, surfaced for display. */
63
+ export interface BlockedGroup {
64
+ name: string
65
+ question: string
66
+ suggestions?: string[]
67
+ askedAt?: string
68
+ }
69
+
70
+ export interface RoundSummary {
71
+ batch?: string
72
+ updated?: string
73
+ totalUsage?: OrchestratorState["totalUsage"]
74
+ /** Issue #118: usage summed over only the current batch's groups (undefined when none yet). */
75
+ batchUsage?: OrchestratorState["batchUsage"]
76
+ groups: OrchestratorGroup[]
77
+ blocked: BlockedGroup[]
78
+ /** Issue #13 preflight probe result (present once orchestrate ran it). */
79
+ preflight?: OrchestratorState["preflight"]
80
+ }
81
+
82
+ export interface DashboardSummary {
83
+ generatedAt: string
84
+ repo?: string
85
+ sessions: SessionRow[]
86
+ totals: UsageTotals
87
+ round?: RoundSummary
88
+ }
89
+
90
+ function emptyTotals(): UsageTotals {
91
+ return { costUsd: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, iterations: 0, sessionCount: 0 }
92
+ }
93
+
94
+ /** List `*.jsonl` files directly inside `dir` (non-recursive); [] when missing/unreadable. */
95
+ async function listJsonlFiles(dir: string): Promise<string[]> {
96
+ try {
97
+ const entries = await fs.readdir(dir, { withFileTypes: true })
98
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => path.join(dir, e.name))
99
+ } catch {
100
+ return []
101
+ }
102
+ }
103
+
104
+ /** List `*.live.json` snapshot files directly inside `dir` (non-recursive); [] when missing/unreadable. */
105
+ async function listLiveJsonFiles(dir: string): Promise<string[]> {
106
+ try {
107
+ const entries = await fs.readdir(dir, { withFileTypes: true })
108
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".live.json")).map((e) => path.join(dir, e.name))
109
+ } catch {
110
+ return []
111
+ }
112
+ }
113
+
114
+ /** Map one usage record (completed or live) to a dashboard session row. */
115
+ function toSessionRow(r: UsageRecord | LiveUsageRecord, source: string): SessionRow {
116
+ return {
117
+ sessionId: r.sessionId,
118
+ mode: r.mode,
119
+ model: r.model,
120
+ iterations: r.iterations ?? 0,
121
+ inputTokens: r.inputTokens ?? 0,
122
+ outputTokens: r.outputTokens ?? 0,
123
+ cachedTokens: r.cachedTokens ?? 0,
124
+ costUsd: r.costUsd ?? 0,
125
+ startedAt: r.startedAt,
126
+ // Live snapshots have no endedAt (the session is still running).
127
+ endedAt: "endedAt" in r ? r.endedAt : "",
128
+ status: r.status,
129
+ workspaceRoot: r.workspaceRoot,
130
+ source,
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Read every usage record under `<root>/.headlesscode/usage` — both completed
136
+ * `*.jsonl` records and live `*.live.json` snapshots — tagged with `source`.
137
+ */
138
+ async function readUsageDir(root: string, source: string): Promise<SessionRow[]> {
139
+ const dir = path.join(root, ".headlesscode", "usage")
140
+ const rows: SessionRow[] = []
141
+
142
+ // Completed sessions: one JSONL file per session (append-only format).
143
+ for (const file of await listJsonlFiles(dir)) {
144
+ let records: UsageRecord[]
145
+ try {
146
+ records = await readUsageFile(file)
147
+ } catch {
148
+ continue
149
+ }
150
+ for (const r of records) {
151
+ rows.push(toSessionRow(r, source))
152
+ }
153
+ }
154
+
155
+ // Live (in-progress) sessions: one overwritten JSON snapshot per session.
156
+ for (const file of await listLiveJsonFiles(dir)) {
157
+ let live: LiveUsageRecord | null
158
+ try {
159
+ live = await readLiveUsageFile(file)
160
+ } catch {
161
+ continue
162
+ }
163
+ if (live) {
164
+ rows.push(toSessionRow(live, source))
165
+ }
166
+ }
167
+ return rows
168
+ }
169
+
170
+ /** Sum a set of session rows into totals. */
171
+ export function sumSessions(rows: SessionRow[]): UsageTotals {
172
+ const totals = emptyTotals()
173
+ for (const row of rows) {
174
+ totals.costUsd += row.costUsd
175
+ totals.inputTokens += row.inputTokens
176
+ totals.outputTokens += row.outputTokens
177
+ totals.cachedTokens += row.cachedTokens
178
+ totals.iterations += row.iterations
179
+ totals.sessionCount += 1
180
+ }
181
+ return totals
182
+ }
183
+
184
+ /**
185
+ * Locate the events feed file for one session id, searching the repo root's
186
+ * own events dir AND every `.worktrees/<name>/` worktree events dir (the same
187
+ * dual search `buildSummary` performs for usage files). Returns the file path
188
+ * + a display `source` label, or undefined when no feed exists anywhere.
189
+ */
190
+ export async function findSessionEventsFile(
191
+ repo: string,
192
+ sessionId: string,
193
+ ): Promise<{ file: string; source: string } | undefined> {
194
+ const repoRoot = path.resolve(repo)
195
+
196
+ // 1. Direct (non-orchestrated) sessions run against the repo root itself.
197
+ const rootFile = eventsFilePath(repoRoot, sessionId)
198
+ try {
199
+ await fs.access(rootFile)
200
+ return { file: rootFile, source: "." }
201
+ } catch {
202
+ // Not here — check worktrees.
203
+ }
204
+
205
+ // 2. Orchestrated worker sessions, one events dir per worktree.
206
+ const worktreesDir = path.join(repoRoot, ".worktrees")
207
+ let worktreeNames: string[] = []
208
+ try {
209
+ const entries = await fs.readdir(worktreesDir, { withFileTypes: true })
210
+ worktreeNames = entries.filter((e) => e.isDirectory()).map((e) => e.name)
211
+ } catch {
212
+ worktreeNames = []
213
+ }
214
+ for (const name of worktreeNames) {
215
+ const file = eventsFilePath(path.join(worktreesDir, name), sessionId)
216
+ try {
217
+ await fs.access(file)
218
+ return { file, source: `.worktrees/${name}` }
219
+ } catch {
220
+ // Keep looking.
221
+ }
222
+ }
223
+ return undefined
224
+ }
225
+
226
+ /**
227
+ * Read a session's structured event feed, optionally starting from a byte
228
+ * offset (`sinceOffset` — the incremental "give me only what's new since I
229
+ * last asked" polling idea, like `tail -f`). Returns the new events plus the
230
+ * next offset to pass on the following poll.
231
+ *
232
+ * Pure + fs-only, never throws for a missing feed: a session with no events
233
+ * file yet (or already cleaned up) yields `{ events: [], nextOffset: 0 }`.
234
+ * Loose validation is inherited from `readEventsFile` (malformed /
235
+ * partially-written trailing lines are skipped).
236
+ */
237
+ export async function readSessionEvents(
238
+ repo: string,
239
+ sessionId: string,
240
+ sinceOffset = 0,
241
+ ): Promise<{ events: EventRecord[]; nextOffset: number }> {
242
+ const found = await findSessionEventsFile(repo, sessionId)
243
+ if (!found) {
244
+ return { events: [], nextOffset: 0 }
245
+ }
246
+ const events = await readEventsFile(found.file)
247
+ // Byte-offset semantics (tail -f): a line is considered consumed only
248
+ // when its END (start + encoded length, newline included) lies at or
249
+ // before the offset — a partially-appended line (the caller's offset
250
+ // lands mid-line) is returned whole. Lines after the consumed prefix are
251
+ // returned; nextOffset is the byte offset just past the last returned
252
+ // line, so the next poll passes it back as sinceOffset.
253
+ let fileSize = 0
254
+ try {
255
+ fileSize = (await fs.stat(found.file)).size
256
+ } catch {
257
+ fileSize = 0
258
+ }
259
+ // The offset never exceeds the current file size, so a shrunk/recreated
260
+ // file degrades to "read from the start".
261
+ const safeOffset = Math.min(Math.max(0, sinceOffset), fileSize)
262
+ const encoded = events.map((e) => JSON.stringify(e) + "\n")
263
+ let startIndex = 0
264
+ let bytesSeen = 0
265
+ for (let i = 0; i < encoded.length; i++) {
266
+ const lineBytes = Buffer.byteLength(encoded[i])
267
+ if (safeOffset < bytesSeen + lineBytes) {
268
+ // This line is not fully consumed — it (and everything after) is new.
269
+ startIndex = i
270
+ break
271
+ }
272
+ bytesSeen += lineBytes
273
+ startIndex = i + 1
274
+ }
275
+ const newEvents = events.slice(startIndex)
276
+ const newBytes = encoded.slice(startIndex).reduce((acc, line) => acc + Buffer.byteLength(line), 0)
277
+ return { events: newEvents, nextOffset: safeOffset + newBytes }
278
+ }
279
+
280
+ /**
281
+ * Build the dashboard's summary for one repo (or none — sessions/totals are
282
+ * simply empty). Never throws: every fs read is defensive (missing dir,
283
+ * malformed JSON, missing state file all degrade gracefully).
284
+ */
285
+ export async function buildSummary(repo?: string): Promise<DashboardSummary> {
286
+ if (!repo) {
287
+ return { generatedAt: new Date().toISOString(), sessions: [], totals: emptyTotals() }
288
+ }
289
+
290
+ const repoRoot = path.resolve(repo)
291
+ const sessions: SessionRow[] = []
292
+
293
+ // 1. Direct (non-orchestrated) sessions run against the repo root itself.
294
+ sessions.push(...(await readUsageDir(repoRoot, ".")))
295
+
296
+ // 2. Orchestrated worker sessions, one usage dir per worktree.
297
+ const worktreesDir = path.join(repoRoot, ".worktrees")
298
+ let worktreeNames: string[] = []
299
+ try {
300
+ const entries = await fs.readdir(worktreesDir, { withFileTypes: true })
301
+ worktreeNames = entries.filter((e) => e.isDirectory()).map((e) => e.name)
302
+ } catch {
303
+ worktreeNames = []
304
+ }
305
+ for (const name of worktreeNames) {
306
+ sessions.push(...(await readUsageDir(path.join(worktreesDir, name), `.worktrees/${name}`)))
307
+ }
308
+
309
+ // 3. Round-level rollup from .orchestrator-state.json, when present.
310
+ let round: RoundSummary | undefined
311
+ const statePath = path.join(worktreesDir, ".orchestrator-state.json")
312
+ try {
313
+ await fs.stat(statePath)
314
+ const state = await loadState(statePath)
315
+ const blocked: BlockedGroup[] = state.groups
316
+ .filter((g) => g.status === "blocked" && g.blocked?.question)
317
+ .map((g) => ({
318
+ name: g.name,
319
+ question: g.blocked?.question ?? "",
320
+ suggestions: g.blocked?.suggestions,
321
+ askedAt: g.blocked?.askedAt,
322
+ }))
323
+ round = {
324
+ batch: state.batch,
325
+ updated: state.updated,
326
+ totalUsage: state.totalUsage,
327
+ batchUsage: state.batchUsage,
328
+ groups: state.groups,
329
+ blocked,
330
+ preflight: state.preflight,
331
+ }
332
+ } catch {
333
+ round = undefined
334
+ }
335
+
336
+ // Gap 1: merge live snapshots with completed records. If both a
337
+ // `.live.json` and a completed `.jsonl` record exist for the same session
338
+ // id (a narrow race at the exact moment of completion), the completed
339
+ // record wins — never double-count or show duplicates. Duplicate completed
340
+ // records (same id, multiple jsonl lines) keep their prior behavior.
341
+ const deduped: SessionRow[] = []
342
+ const bySessionId = new Map<string, number>()
343
+ for (const row of sessions) {
344
+ const idx = bySessionId.get(row.sessionId)
345
+ if (idx === undefined) {
346
+ bySessionId.set(row.sessionId, deduped.length)
347
+ deduped.push(row)
348
+ } else if (deduped[idx].status === "running" && row.status !== "running") {
349
+ // A completed .jsonl record takes precedence over a stale live snapshot.
350
+ deduped[idx] = row
351
+ }
352
+ }
353
+
354
+ // Deterministic ordering: most recently started session first.
355
+ deduped.sort((a, b) => (a.startedAt < b.startedAt ? 1 : a.startedAt > b.startedAt ? -1 : 0))
356
+
357
+ return {
358
+ generatedAt: new Date().toISOString(),
359
+ repo: repoRoot,
360
+ sessions: deduped,
361
+ totals: sumSessions(deduped),
362
+ round,
363
+ }
364
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Chat-thread rendering support for the dashboard's session detail view —
3
+ * groups the flat chronological event feed (the same data served by
4
+ * `GET /api/session/:id/events`) into conversation "turns" so the page can
5
+ * render it as a chat thread (assistant bubble + inline tool calls + results)
6
+ * instead of a flat event log.
7
+ *
8
+ * This module is deliberately pure (no DOM, no fetch): the grouping is the
9
+ * one piece of the feature that's worth unit-testing, and keeping it here
10
+ * lets `src/dashboard/__tests__/chat-thread.test.ts` exercise it with fixture
11
+ * event arrays. All DOM work stays in `src/dashboard/page.ts`'s inline
12
+ * vanilla JS, matching that page's no-framework style.
13
+ *
14
+ * Grouping rules (per the existing event ordering in `src/engine/events.ts`):
15
+ * - An assistant "turn" STARTS at an `iteration_start` event (a fresh
16
+ * iteration = a fresh assistant turn).
17
+ * - The `llm_response` event and the `tool_call`/`tool_result` pairs that
18
+ * follow it belong to that turn, until the next `iteration_start`.
19
+ * - `tool_result` events are attached to the preceding `tool_call` in the
20
+ * same turn (tool name + same iteration), forming a call/result pair.
21
+ * - `decision_blocked` starts a turn (a new question the model asked the
22
+ * human); the matching `decision_answered` is rendered as an inline
23
+ * system marker within that same turn.
24
+ * - `llm_stream_chunk` events belong to the current turn (streamed chunks
25
+ * arrive BETWEEN iteration_start and llm_response). They must NOT close
26
+ * the turn — the turn would fragment into one system marker per chunk.
27
+ * - Everything else (`checkpoint_saved`, `paused`, `resumed`, `llm_error`,
28
+ * `session_end`, …) is a system-level marker rendered as a small inline
29
+ * chip between turns, not a chat bubble.
30
+ */
31
+
32
+ import type { EventRecord } from "../engine/events.js"
33
+
34
+ /** The chat-relevant payloads extracted from a flat event feed. */
35
+ export type ChatBlock =
36
+ | { kind: "system"; event: EventRecord }
37
+ | { kind: "turn"; iteration?: number; turnStart: EventRecord; events: EventRecord[] }
38
+
39
+ export interface ChatGrouping {
40
+ /** Message list (the user's original task), only present when the feed has a session_start. */
41
+ task?: string
42
+ /** The blocks in feed order: turns + system markers interleaved. */
43
+ blocks: ChatBlock[]
44
+ }
45
+
46
+ /** The user's task text from a session_start event ("" when absent). */
47
+ function taskFromSessionStart(e: EventRecord): string {
48
+ if (e.type !== "session_start") return ""
49
+ return typeof e.task === "string" ? e.task : ""
50
+ }
51
+
52
+ /** A tool_call event's short argument summary — mirrors loop.ts's summarizeToolArg style. */
53
+ export function summarizeArg(e: EventRecord): string {
54
+ if (e.type !== "tool_call") return ""
55
+ const raw = typeof e.args === "string" ? e.args : ""
56
+ // The feed already carries a server-side short summary (path/command), so
57
+ // this is a belt-and-braces re-summary for robustness only.
58
+ const trimmed = raw.trim()
59
+ if (trimmed.length <= 80) return trimmed
60
+ return trimmed.slice(0, 80) + "…"
61
+ }
62
+
63
+ /**
64
+ * Group a flat, chronological event array into chat blocks. Pure: no DOM, no
65
+ * I/O, no mutation of the input. Malformed/unknown event types fall through
66
+ * to system markers (the flat log remains the source of truth for anything
67
+ * this grouping doesn't understand).
68
+ */
69
+ export function groupEventsIntoChat(events: EventRecord[]): ChatGrouping {
70
+ const blocks: ChatBlock[] = []
71
+ let task = ""
72
+ let current: { iteration?: number; turnStart: EventRecord; events: EventRecord[] } | undefined
73
+
74
+ const closeTurn = (): void => {
75
+ if (current) {
76
+ blocks.push({ kind: "turn", iteration: current.iteration, turnStart: current.turnStart, events: current.events })
77
+ current = undefined
78
+ }
79
+ }
80
+
81
+ for (const e of events) {
82
+ switch (e.type) {
83
+ case "session_start": {
84
+ task = taskFromSessionStart(e)
85
+ blocks.push({ kind: "system", event: e })
86
+ break
87
+ }
88
+ case "iteration_start":
89
+ closeTurn()
90
+ current = { iteration: e.iteration, turnStart: e, events: [] }
91
+ break
92
+ case "decision_blocked":
93
+ // A new question = a new "turn" (it's a fresh user→assistant
94
+ // exchange); the answer arrives as a later decision_answered.
95
+ closeTurn()
96
+ current = { iteration: e.iteration, turnStart: e, events: [] }
97
+ break
98
+ case "tool_result": {
99
+ // Attach to the preceding tool_call in the same turn, if any.
100
+ if (current && current.events.length > 0) {
101
+ const last = current.events[current.events.length - 1]
102
+ if (last.type === "tool_call") {
103
+ current.events.push(e)
104
+ break
105
+ }
106
+ }
107
+ // Orphaned result (no call in the current turn): system marker.
108
+ blocks.push({ kind: "system", event: e })
109
+ break
110
+ }
111
+ case "session_end":
112
+ case "llm_error":
113
+ case "checkpoint_saved":
114
+ case "paused":
115
+ case "resumed":
116
+ closeTurn()
117
+ blocks.push({ kind: "system", event: e })
118
+ break
119
+ case "llm_response":
120
+ case "tool_call":
121
+ case "decision_answered":
122
+ case "llm_stream_chunk":
123
+ // Belongs to the current turn when there is one; otherwise a
124
+ // lone marker (a feed that starts mid-session).
125
+ if (current) {
126
+ current.events.push(e)
127
+ } else {
128
+ blocks.push({ kind: "system", event: e })
129
+ }
130
+ break
131
+ default:
132
+ // Unknown type: keep it as a system marker rather than hiding it.
133
+ closeTurn()
134
+ blocks.push({ kind: "system", event: e })
135
+ break
136
+ }
137
+ }
138
+ closeTurn()
139
+
140
+ return { task: task || undefined, blocks }
141
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Browser control plane — checkpoint list/diff/restore, as a thin HTTP-agnostic
3
+ * wrapper around `src/checkpoints/service.ts` (the same way
4
+ * `src/dashboard/session-launch.ts` wraps the session-launch logic).
5
+ *
6
+ * The dashboard routes pass `repo` (the workspace whose files the shadow git
7
+ * repo tracks) + `session` (the session/task id — HeadlessSession uses its
8
+ * sessionId as the checkpoint service's taskId, see src/engine/loop.ts) and
9
+ * this module does the rest: resolve the service against the default shadow
10
+ * storage root, init it, and delegate list/diff/restore.
11
+ *
12
+ * `renderUnifiedDiff` turns the service's `CheckpointDiff[]` (one entry per
13
+ * changed file, each with before/after full content) into a simple
14
+ * unified-diff-style text block — the same plain-text ethos the CLI uses, no
15
+ * diff library. This is a deliberate UI-only rendering choice; nothing here
16
+ * reimplements shadow-git diffing.
17
+ *
18
+ * Restore is DESTRUCTIVE (reverts real workspace files) — the HTTP layer
19
+ * gates it behind the optional bearer token, and the UI requires an explicit
20
+ * confirmation step before calling it. This module just performs the call.
21
+ */
22
+
23
+ import * as path from "node:path"
24
+
25
+ import { createCheckpointService, defaultCheckpointDir, type CheckpointLogEntry } from "../checkpoints/service.js"
26
+ import type { CheckpointDiff } from "../vendor/zoo-code/src/services/checkpoints/types.js"
27
+
28
+ /** Options for resolving a checkpoint service for one session. */
29
+ export interface CheckpointRouteOptions {
30
+ /** Workspace root whose files the shadow repo tracks (the dashboard's repo param). */
31
+ workspaceRoot: string
32
+ /** Session/task id (the checkpoint service's taskId). */
33
+ sessionId: string
34
+ /** Shadow-git storage root override (default: defaultCheckpointDir()). Test-only. */
35
+ checkpointDir?: string
36
+ }
37
+
38
+ /**
39
+ * Resolve + initialize the checkpoint service for a session. Throws on
40
+ * invalid input (e.g. checkpointDir nested inside workspaceRoot) — the HTTP
41
+ * layer surfaces the message as an error response.
42
+ */
43
+ export async function resolveCheckpointService(
44
+ options: CheckpointRouteOptions,
45
+ ): Promise<ReturnType<typeof createCheckpointService>> {
46
+ const checkpointDir = options.checkpointDir
47
+ ? path.resolve(options.checkpointDir)
48
+ : defaultCheckpointDir()
49
+ const service = createCheckpointService({
50
+ taskId: options.sessionId,
51
+ workspaceRoot: path.resolve(options.workspaceRoot),
52
+ checkpointDir,
53
+ })
54
+ await service.init()
55
+ return service
56
+ }
57
+
58
+ /** List checkpoints oldest-first (message, date, hash). */
59
+ export async function listCheckpoints(options: CheckpointRouteOptions): Promise<CheckpointLogEntry[]> {
60
+ const service = await resolveCheckpointService(options)
61
+ return service.list()
62
+ }
63
+
64
+ /** Diff between two checkpoints, or a checkpoint and the current working tree (to omitted). */
65
+ export async function diffCheckpoints(
66
+ options: CheckpointRouteOptions,
67
+ from: string,
68
+ to?: string,
69
+ ): Promise<CheckpointDiff[]> {
70
+ const service = await resolveCheckpointService(options)
71
+ return service.diff({ from, to })
72
+ }
73
+
74
+ /** Restore a checkpoint — reverts REAL workspace files (destructive). */
75
+ export async function restoreCheckpoint(options: CheckpointRouteOptions, hash: string): Promise<void> {
76
+ const service = await resolveCheckpointService(options)
77
+ await service.restore(hash)
78
+ }
79
+
80
+ /**
81
+ * Render `CheckpointDiff[]` as a plain unified-diff-style text block:
82
+ *
83
+ * --- a/src/greet.js
84
+ * +++ b/src/greet.js
85
+ * @@ src/greet.js @@
86
+ * -// BUG: old line
87
+ * +// fixed line
88
+ *
89
+ * Line-level `-`/`+` marking requires the before/after content line counts to
90
+ * align for readability, so this does a trivial line-by-line prefix (before
91
+ * lines get `-`, after lines get `+`, padding the shorter side with a blank
92
+ * marker) — deliberately NOT a real diff algorithm. Good enough for a plain
93
+ * dashboard; matches the project's zero-dependency ethos.
94
+ */
95
+ export function renderUnifiedDiff(changes: CheckpointDiff[]): string {
96
+ if (changes.length === 0) {
97
+ return "(no changes)"
98
+ }
99
+ const blocks: string[] = []
100
+ for (const change of changes) {
101
+ const rel = change.paths.relative
102
+ const before = change.content.before.split("\n")
103
+ const after = change.content.after.split("\n")
104
+ // split() leaves a trailing "" when content ends with \n — drop it so
105
+ // the marker count matches the actual displayed lines.
106
+ if (before.length > 0 && before[before.length - 1] === "") before.pop()
107
+ if (after.length > 0 && after[after.length - 1] === "") after.pop()
108
+
109
+ const width = Math.max(before.length, after.length)
110
+ const lines: string[] = []
111
+ for (let i = 0; i < width; i++) {
112
+ const b = i < before.length ? before[i] : ""
113
+ const a = i < after.length ? after[i] : ""
114
+ if (b !== a) {
115
+ if (b !== "") lines.push("-" + b)
116
+ if (a !== "") lines.push("+" + a)
117
+ }
118
+ }
119
+ blocks.push(
120
+ `--- a/${rel}\n+++ b/${rel}\n@@ ${rel} @@\n` + (lines.length > 0 ? lines.join("\n") : "(identical content)"),
121
+ )
122
+ }
123
+ return blocks.join("\n\n")
124
+ }