@defrex/autobuild 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,230 @@
1
+ /** Pure Unicode-aware text geometry for the dashboard's editable field. */
2
+ import {
3
+ cellWidth,
4
+ displayGraphemes,
5
+ displayText,
6
+ graphemeBoundary,
7
+ graphemes,
8
+ splitCells,
9
+ } from './cells'
10
+
11
+ export { displayText } from './cells'
12
+
13
+ /** Wrap prose in terminal cells without splitting a grapheme cluster. */
14
+ export function wrapDisplay(value: string, width: number, indent = ''): string[] {
15
+ if (width <= 0) return []
16
+ const pad = cellWidth(indent) < width ? indent : ''
17
+ const budget = width - cellWidth(pad)
18
+ const rows: string[] = []
19
+
20
+ for (const paragraph of value.split(/\r?\n/)) {
21
+ const paragraphStart = rows.length
22
+ const tokens = displayText(paragraph)
23
+ .split(/\s+/u)
24
+ .filter((token) => token !== '')
25
+ if (tokens.length === 0) {
26
+ rows.push(pad)
27
+ continue
28
+ }
29
+
30
+ let line = ''
31
+ let used = 0
32
+ for (const token of tokens) {
33
+ const tokenWidth = cellWidth(token)
34
+ if (line !== '' && used + 1 + tokenWidth <= budget) {
35
+ line += ` ${token}`
36
+ used += 1 + tokenWidth
37
+ continue
38
+ }
39
+ if (line !== '') {
40
+ rows.push(`${pad}${line}`)
41
+ line = ''
42
+ used = 0
43
+ }
44
+ if (tokenWidth <= budget) {
45
+ line = token
46
+ used = tokenWidth
47
+ continue
48
+ }
49
+ const pieces = splitCells(token, budget)
50
+ for (const piece of pieces.slice(0, -1)) rows.push(`${pad}${piece}`)
51
+ line = pieces.at(-1) ?? ''
52
+ used = cellWidth(line)
53
+ }
54
+ if (line !== '') rows.push(`${pad}${line}`)
55
+ else if (rows.length === paragraphStart) rows.push(pad)
56
+ }
57
+ return rows
58
+ }
59
+
60
+ /** Pack labels atomically, dropping a label that cannot fit one row. */
61
+ export function packAtomic(tokens: string[], width: number, separator = ' '): string[] {
62
+ if (width <= 0) return []
63
+ const rows: string[] = []
64
+ let line = ''
65
+ for (const token of tokens) {
66
+ if (cellWidth(token) > width) continue
67
+ if (line === '') {
68
+ line = token
69
+ continue
70
+ }
71
+ const candidate = `${line}${separator}${token}`
72
+ if (cellWidth(candidate) <= width) line = candidate
73
+ else {
74
+ rows.push(line)
75
+ line = token
76
+ }
77
+ }
78
+ if (line !== '') rows.push(line)
79
+ return rows
80
+ }
81
+
82
+ export interface ComposerLayout {
83
+ rows: string[]
84
+ cursorRow: number
85
+ /** Caret column in terminal display cells. */
86
+ cursorColumn: number
87
+ /** UTF-16 insertion offset within cursorRow. */
88
+ cursorOffset: number
89
+ }
90
+
91
+ export function composerBudget(width: number): number {
92
+ return Math.max(1, width - 1)
93
+ }
94
+
95
+ /** Wrap the original value and map a UTF-16 grapheme boundary to its caret. */
96
+ export function layoutComposer(value: string, cursor: number, width: number): ComposerLayout {
97
+ const budget = composerBudget(width)
98
+ const rows: string[] = []
99
+ const positions = new Map<number, { row: number; column: number; offset: number }>()
100
+ let current = ''
101
+ let used = 0
102
+ positions.set(0, { row: 0, column: 0, offset: 0 })
103
+
104
+ for (const source of graphemes(value)) {
105
+ positions.set(source.start, { row: rows.length, column: used, offset: current.length })
106
+ if (source.text === '\n') {
107
+ rows.push(current)
108
+ current = ''
109
+ used = 0
110
+ positions.set(source.end, { row: rows.length, column: 0, offset: 0 })
111
+ continue
112
+ }
113
+
114
+ for (const displayed of displayGraphemes(source.text)) {
115
+ if (displayed.width > budget) continue
116
+ if (displayed.width > 0 && used + displayed.width > budget) {
117
+ rows.push(current)
118
+ current = ''
119
+ used = 0
120
+ }
121
+ current += displayed.text
122
+ used += displayed.width
123
+ }
124
+ positions.set(source.end, {
125
+ row: rows.length,
126
+ column: used,
127
+ offset: current.length,
128
+ })
129
+ }
130
+ rows.push(current)
131
+ const normalized = clampCursor(value, cursor)
132
+ const at = positions.get(normalized) ?? {
133
+ row: rows.length - 1,
134
+ column: used,
135
+ offset: current.length,
136
+ }
137
+ return {
138
+ rows,
139
+ cursorRow: at.row,
140
+ cursorColumn: at.column,
141
+ cursorOffset: at.offset,
142
+ }
143
+ }
144
+
145
+ /** Clamp to a UTF-16 offset that is also a whole-grapheme boundary. */
146
+ export function clampCursor(value: string, cursor: number): number {
147
+ return graphemeBoundary(value, cursor)
148
+ }
149
+
150
+ export function insertText(
151
+ value: string,
152
+ cursor: number,
153
+ text: string,
154
+ ): { value: string; cursor: number } {
155
+ const at = clampCursor(value, cursor)
156
+ const next = `${value.slice(0, at)}${text}${value.slice(at)}`
157
+ const wanted = at + text.length
158
+ // Insertion can combine with a following mark and erase the exact boundary
159
+ // that used to follow the inserted bytes. In that case advance over the
160
+ // newly formed cluster rather than jumping the caret back before it.
161
+ const combined = graphemes(next).find((cluster) => cluster.start < wanted && wanted < cluster.end)
162
+ return { value: next, cursor: combined?.end ?? graphemeBoundary(next, wanted) }
163
+ }
164
+
165
+ export function deleteBefore(value: string, cursor: number): { value: string; cursor: number } {
166
+ const at = clampCursor(value, cursor)
167
+ if (at === 0) return { value, cursor: 0 }
168
+ const previous = graphemes(value).findLast((cluster) => cluster.end <= at)
169
+ if (previous === undefined) return { value, cursor: 0 }
170
+ return {
171
+ value: `${value.slice(0, previous.start)}${value.slice(at)}`,
172
+ cursor: previous.start,
173
+ }
174
+ }
175
+
176
+ export type ComposerMotion = 'left' | 'right' | 'up' | 'down' | 'home' | 'end'
177
+
178
+ interface LogicalLine {
179
+ start: number
180
+ end: number
181
+ /** UTF-16 boundaries by grapheme column, including both line ends. */
182
+ boundaries: number[]
183
+ }
184
+
185
+ function logicalLines(value: string): LogicalLine[] {
186
+ const lines: LogicalLine[] = []
187
+ let start = 0
188
+ let boundaries = [0]
189
+ for (const cluster of graphemes(value)) {
190
+ if (cluster.text === '\n') {
191
+ lines.push({ start, end: cluster.start, boundaries })
192
+ start = cluster.end
193
+ boundaries = [start]
194
+ } else {
195
+ boundaries.push(cluster.end)
196
+ }
197
+ }
198
+ lines.push({ start, end: value.length, boundaries })
199
+ return lines
200
+ }
201
+
202
+ export function moveCursor(value: string, cursor: number, motion: ComposerMotion): number {
203
+ const clusters = graphemes(value)
204
+ const at = clampCursor(value, cursor)
205
+ const lines = logicalLines(value)
206
+ const lineIndex = lines.findLastIndex((line) => line.start <= at)
207
+ const line = lines[lineIndex]!
208
+ const column = Math.max(0, line.boundaries.indexOf(at))
209
+
210
+ switch (motion) {
211
+ case 'left':
212
+ return clusters.findLast((cluster) => cluster.end <= at)?.start ?? 0
213
+ case 'right':
214
+ return clusters.find((cluster) => cluster.start >= at)?.end ?? value.length
215
+ case 'home':
216
+ return line.start
217
+ case 'end':
218
+ return line.end
219
+ case 'up': {
220
+ if (lineIndex === 0) return 0
221
+ const target = lines[lineIndex - 1]!
222
+ return target.boundaries[Math.min(column, target.boundaries.length - 1)]!
223
+ }
224
+ case 'down': {
225
+ if (lineIndex === lines.length - 1) return value.length
226
+ const target = lines[lineIndex + 1]!
227
+ return target.boundaries[Math.min(column, target.boundaries.length - 1)]!
228
+ }
229
+ }
230
+ }
@@ -0,0 +1,100 @@
1
+ import type { AbEvent } from '../../events/catalog'
2
+ import type { StreamRecord } from '../../store/types'
3
+
4
+ export interface SessionUsage {
5
+ inputTokens: number
6
+ outputTokens: number
7
+ turns: number
8
+ }
9
+
10
+ export interface DashboardSession {
11
+ id: string
12
+ role: string
13
+ phase: string
14
+ round?: number
15
+ runtime: string
16
+ model?: string
17
+ startedSeq: number
18
+ status: 'open' | 'ended' | 'reclaimed'
19
+ usage?: SessionUsage
20
+ transcript?: { kind: string; rev: number }
21
+ reclaimedBy?: { instance: string; resumedFromSeq: number }
22
+ /** Live-view stream id (SPEC §9); absent on sessions that never streamed. */
23
+ stream?: string
24
+ /** Open until the session's `session.ended` lands; the store's records
25
+ * refine this to the stream's authoritative status when available. */
26
+ streamStatus: 'open' | 'closed'
27
+ }
28
+
29
+ /**
30
+ * Display-only chronological session history. The lifecycle reducer retains
31
+ * only open sessions; the dashboard needs both halves, so it pairs the raw
32
+ * append-only facts without introducing transition state. `streams` is the
33
+ * optional authoritative enrichment from `listStreams` on the build scope:
34
+ * records whose label matches `session:<id>` supply each session's stream id
35
+ * and open/closed status.
36
+ */
37
+ export function projectSessions(
38
+ events: readonly AbEvent[],
39
+ streams?: readonly StreamRecord[],
40
+ ): DashboardSession[] {
41
+ const streamsByLabel = new Map<string, StreamRecord>()
42
+ if (streams !== undefined) {
43
+ for (const record of streams) {
44
+ if (record.scope.kind === 'build') streamsByLabel.set(record.label, record)
45
+ }
46
+ }
47
+
48
+ const sessions: DashboardSession[] = []
49
+ const open = new Map<string, DashboardSession>()
50
+
51
+ for (const event of events) {
52
+ if (event.type === 'session.started') {
53
+ const record = streamsByLabel.get(`session:${event.payload.session}`)
54
+ const session: DashboardSession = {
55
+ id: event.payload.session,
56
+ role: event.payload.role,
57
+ phase: event.payload.phase,
58
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
59
+ runtime: event.payload.runner,
60
+ ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
61
+ startedSeq: event.seq,
62
+ status: 'open',
63
+ // The store's records are authoritative when available; without them
64
+ // the pairing degrades gracefully (open until `session.ended`).
65
+ ...(record !== undefined
66
+ ? { stream: record.id, streamStatus: record.status }
67
+ : {
68
+ ...(event.payload.stream !== undefined ? { stream: event.payload.stream } : {}),
69
+ streamStatus: event.payload.stream !== undefined ? 'open' : 'closed',
70
+ }),
71
+ }
72
+ sessions.push(session)
73
+ open.set(session.id, session)
74
+ continue
75
+ }
76
+ if (event.type !== 'session.ended') continue
77
+ const started = open.get(event.payload.session)
78
+ if (started === undefined) continue
79
+ if ('transcript' in event.payload) {
80
+ started.status = 'ended'
81
+ started.usage = { ...event.payload.usage }
82
+ started.transcript = { ...event.payload.transcript }
83
+ } else {
84
+ started.status = 'reclaimed'
85
+ started.reclaimedBy = { ...event.payload.reclaimedBy }
86
+ }
87
+ // A session bracket has ended: without an authoritative record its
88
+ // stream reads closed.
89
+ const record = streamsByLabel.get(`session:${started.id}`)
90
+ if (record !== undefined) {
91
+ started.stream = record.id
92
+ started.streamStatus = record.status
93
+ } else {
94
+ started.streamStatus = 'closed'
95
+ }
96
+ open.delete(started.id)
97
+ }
98
+
99
+ return sessions
100
+ }
@@ -0,0 +1,373 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
4
+ import { Resvg } from '@resvg/resvg-js'
5
+ import { distributionPath } from '../../distribution'
6
+ import { graphemes } from './cells'
7
+
8
+ /**
9
+ * Deterministic dashboard-frame rendering.
10
+ *
11
+ * The dashboard deliberately emits a tiny terminal vocabulary: SGR reset,
12
+ * bold/dim and six named foreground colours, plus OSC 8 hyperlinks. This
13
+ * adapter accepts exactly that vocabulary. Unknown control traffic is an
14
+ * error rather than evidence that merely looks plausible after bytes were
15
+ * dropped.
16
+ */
17
+
18
+ const ESC = '\x1b'
19
+ const BEL = '\x07'
20
+ const FONT_FAMILY = 'DejaVu Sans Mono'
21
+ const FONT_FAMILIES = [
22
+ FONT_FAMILY,
23
+ 'Dashboard CJK 113',
24
+ 'Dashboard CJK 117',
25
+ 'Dashboard CJK 118',
26
+ 'Dashboard Emoji Flags',
27
+ 'Dashboard Emoji Food',
28
+ 'Dashboard Emoji People',
29
+ ].join(', ')
30
+ const FONT_SIZE = 16
31
+ const CELL_WIDTH = 10
32
+ const LINE_HEIGHT = 20
33
+ const PADDING_X = 12
34
+ const PADDING_Y = 10
35
+ const BASELINE = 16
36
+ const EMOJI_CLUSTER = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u
37
+
38
+ const PALETTE = {
39
+ background: '#0d1117',
40
+ foreground: '#c9d1d9',
41
+ red: '#ff7b72',
42
+ green: '#3fb950',
43
+ yellow: '#d29922',
44
+ blue: '#58a6ff',
45
+ cyan: '#39c5cf',
46
+ } as const
47
+
48
+ type Foreground = Exclude<keyof typeof PALETTE, 'background'>
49
+
50
+ interface Style {
51
+ foreground: Foreground
52
+ bold: boolean
53
+ dim: boolean
54
+ href?: string
55
+ }
56
+
57
+ interface TextRun {
58
+ column: number
59
+ cells: number
60
+ text: string
61
+ style: Style
62
+ }
63
+
64
+ interface ParsedLine {
65
+ text: string
66
+ cells: number
67
+ runs: TextRun[]
68
+ }
69
+
70
+ export interface FrameImageOptions {
71
+ /** Declared terminal width. Every parsed line must fit this grid. */
72
+ columns: number
73
+ }
74
+
75
+ export interface RenderedFrameImage {
76
+ /** ANSI/OSC-free text from the exact parsed cells, with a final newline. */
77
+ text: string
78
+ svg: string
79
+ png: Uint8Array
80
+ width: number
81
+ height: number
82
+ rows: number
83
+ columns: number
84
+ }
85
+
86
+ function cloneStyle(style: Style): Style {
87
+ return {
88
+ foreground: style.foreground,
89
+ bold: style.bold,
90
+ dim: style.dim,
91
+ ...(style.href !== undefined ? { href: style.href } : {}),
92
+ }
93
+ }
94
+
95
+ function sameStyle(left: Style, right: Style): boolean {
96
+ return (
97
+ left.foreground === right.foreground &&
98
+ left.bold === right.bold &&
99
+ left.dim === right.dim &&
100
+ left.href === right.href
101
+ )
102
+ }
103
+
104
+ function applySgr(style: Style, raw: string, line: number): void {
105
+ const codes = raw === '' ? [0] : raw.split(';').map((part) => Number(part))
106
+ if (codes.some((code) => !Number.isInteger(code))) {
107
+ throw new Error(`dashboard frame line ${line}: malformed SGR sequence ESC[${raw}m`)
108
+ }
109
+ for (const code of codes) {
110
+ switch (code) {
111
+ case 0:
112
+ style.foreground = 'foreground'
113
+ style.bold = false
114
+ style.dim = false
115
+ break
116
+ case 1:
117
+ style.bold = true
118
+ break
119
+ case 2:
120
+ style.dim = true
121
+ break
122
+ case 22:
123
+ style.bold = false
124
+ style.dim = false
125
+ break
126
+ case 31:
127
+ style.foreground = 'red'
128
+ break
129
+ case 32:
130
+ style.foreground = 'green'
131
+ break
132
+ case 33:
133
+ style.foreground = 'yellow'
134
+ break
135
+ case 34:
136
+ style.foreground = 'blue'
137
+ break
138
+ case 36:
139
+ style.foreground = 'cyan'
140
+ break
141
+ case 39:
142
+ style.foreground = 'foreground'
143
+ break
144
+ default:
145
+ throw new Error(`dashboard frame line ${line}: unsupported SGR code ${code} in ESC[${raw}m`)
146
+ }
147
+ }
148
+ }
149
+
150
+ function parseLine(value: string, lineNumber: number): ParsedLine {
151
+ const style: Style = {
152
+ foreground: 'foreground',
153
+ bold: false,
154
+ dim: false,
155
+ }
156
+ const runs: TextRun[] = []
157
+ let text = ''
158
+ let cells = 0
159
+
160
+ const append = (cluster: string, width: number): void => {
161
+ const previous = runs.at(-1)
162
+ // ASCII runs may coalesce. Unicode clusters stay independently pinned to
163
+ // their terminal cell columns so font fallback/advance cannot shift the
164
+ // run that follows a wide glyph.
165
+ if (
166
+ previous !== undefined &&
167
+ previous.column + previous.cells === cells &&
168
+ /^[\x20-\x7e]*$/.test(previous.text) &&
169
+ /^[\x20-\x7e]$/.test(cluster) &&
170
+ sameStyle(previous.style, style)
171
+ ) {
172
+ previous.text += cluster
173
+ previous.cells += width
174
+ } else {
175
+ runs.push({ column: cells, cells: width, text: cluster, style: cloneStyle(style) })
176
+ }
177
+ text += cluster
178
+ cells += width
179
+ }
180
+
181
+ for (let index = 0; index < value.length; ) {
182
+ if (value[index] === ESC) {
183
+ const family = value[index + 1]
184
+ if (family === '[') {
185
+ const rest = value.slice(index + 2)
186
+ const match = /^([0-9;]*)m/.exec(rest)
187
+ if (match === null) {
188
+ throw new Error(
189
+ `dashboard frame line ${lineNumber}: unsupported or unterminated CSI sequence`,
190
+ )
191
+ }
192
+ applySgr(style, match[1]!, lineNumber)
193
+ index += 2 + match[0].length
194
+ continue
195
+ }
196
+ if (family === ']') {
197
+ const end = value.indexOf(BEL, index + 2)
198
+ if (end === -1) {
199
+ throw new Error(`dashboard frame line ${lineNumber}: unterminated OSC sequence`)
200
+ }
201
+ const payload = value.slice(index + 2, end)
202
+ const match = /^8;;(.*)$/.exec(payload)
203
+ if (match === null) {
204
+ throw new Error(
205
+ `dashboard frame line ${lineNumber}: unsupported OSC sequence ${JSON.stringify(payload)}`,
206
+ )
207
+ }
208
+ const href = match[1]!
209
+ style.href = href === '' ? undefined : href
210
+ index = end + 1
211
+ continue
212
+ }
213
+ throw new Error(
214
+ `dashboard frame line ${lineNumber}: unsupported escape family ${JSON.stringify(family ?? '')}`,
215
+ )
216
+ }
217
+
218
+ const nextEscape = value.indexOf(ESC, index)
219
+ const stop = nextEscape === -1 ? value.length : nextEscape
220
+ const plain = value.slice(index, stop)
221
+ for (const character of plain) {
222
+ const code = character.codePointAt(0)!
223
+ if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
224
+ throw new Error(
225
+ `dashboard frame line ${lineNumber}: unsupported control U+${code
226
+ .toString(16)
227
+ .toUpperCase()
228
+ .padStart(4, '0')}`,
229
+ )
230
+ }
231
+ }
232
+ for (const cluster of graphemes(plain)) append(cluster.text, cluster.width)
233
+ index = stop
234
+ }
235
+
236
+ if (style.href !== undefined) {
237
+ throw new Error(`dashboard frame line ${lineNumber}: OSC 8 hyperlink was not closed`)
238
+ }
239
+ if (style.foreground !== 'foreground' || style.bold || style.dim) {
240
+ throw new Error(
241
+ `dashboard frame line ${lineNumber}: SGR style was not reset before end of line`,
242
+ )
243
+ }
244
+ return { text, cells, runs }
245
+ }
246
+
247
+ function xml(value: string): string {
248
+ return value
249
+ .replaceAll('&', '&amp;')
250
+ .replaceAll('<', '&lt;')
251
+ .replaceAll('>', '&gt;')
252
+ .replaceAll('"', '&quot;')
253
+ .replaceAll("'", '&apos;')
254
+ }
255
+
256
+ function svgFor(
257
+ parsed: ParsedLine[],
258
+ columns: number,
259
+ ): {
260
+ svg: string
261
+ width: number
262
+ height: number
263
+ } {
264
+ const width = PADDING_X * 2 + columns * CELL_WIDTH
265
+ const height = PADDING_Y * 2 + Math.max(1, parsed.length) * LINE_HEIGHT
266
+ const content: string[] = []
267
+
268
+ for (const [row, line] of parsed.entries()) {
269
+ for (const run of line.runs) {
270
+ const x = PADDING_X + run.column * CELL_WIDTH
271
+ const y = PADDING_Y + BASELINE + row * LINE_HEIGHT
272
+ const opacity = run.style.dim ? ' fill-opacity="0.58"' : ''
273
+ const weight = run.style.bold ? ' font-weight="700"' : ' font-weight="400"'
274
+ // DejaVu's native advance is slightly narrower than the 10 px evidence
275
+ // grid. Fit coalesced ASCII runs to their exact terminal cells so the
276
+ // error cannot accumulate into a visible gap before a pinned Unicode
277
+ // cluster. Emoji get the same exact fit at a slightly larger size: the
278
+ // monochrome fallback remains readable without crossing its cell range.
279
+ const emoji = EMOJI_CLUSTER.test(run.text)
280
+ const geometry =
281
+ /^[\x20-\x7e]+$/.test(run.text) || emoji
282
+ ? ` textLength="${run.cells * CELL_WIDTH}" lengthAdjust="spacingAndGlyphs"`
283
+ : ''
284
+ const size = emoji ? ' font-size="18"' : ''
285
+ const node =
286
+ `<text x="${x}" y="${y}" fill="${PALETTE[run.style.foreground]}"` +
287
+ `${weight}${opacity}${geometry}${size}>${xml(run.text)}</text>`
288
+ content.push(
289
+ run.style.href === undefined ? node : `<a href="${xml(run.style.href)}">${node}</a>`,
290
+ )
291
+ }
292
+ }
293
+
294
+ return {
295
+ width,
296
+ height,
297
+ svg: [
298
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
299
+ `<rect width="${width}" height="${height}" fill="${PALETTE.background}"/>`,
300
+ `<g font-family="${FONT_FAMILIES}" font-size="${FONT_SIZE}" xml:space="preserve" text-rendering="geometricPrecision">`,
301
+ ...content,
302
+ '</g>',
303
+ '</svg>',
304
+ ].join(''),
305
+ }
306
+ }
307
+
308
+ function fontFiles(): string[] {
309
+ const require = createRequire(import.meta.url)
310
+ const root = dirname(require.resolve('dejavu-fonts-ttf/package.json'))
311
+ const fallbacks = distributionPath('tools', 'fonts')
312
+ return [
313
+ join(root, 'ttf', 'DejaVuSansMono.ttf'),
314
+ join(root, 'ttf', 'DejaVuSansMono-Bold.ttf'),
315
+ ...readdirSync(fallbacks)
316
+ .filter((name) => name.endsWith('.ttf'))
317
+ .sort()
318
+ .map((name) => join(fallbacks, name)),
319
+ ]
320
+ }
321
+
322
+ /** Parse once, then derive both evidence forms from the same exact cells. */
323
+ export function renderDashboardFrameImage(
324
+ lines: readonly string[],
325
+ options: FrameImageOptions,
326
+ ): RenderedFrameImage {
327
+ if (!Number.isInteger(options.columns) || options.columns <= 0) {
328
+ throw new Error(`dashboard frame columns must be a positive integer, got ${options.columns}`)
329
+ }
330
+ if (lines.length === 0) {
331
+ throw new Error('dashboard frame is empty')
332
+ }
333
+ const parsed = lines.map((line, index) => parseLine(line, index + 1))
334
+ for (const [index, line] of parsed.entries()) {
335
+ if (line.cells > options.columns) {
336
+ throw new Error(
337
+ `dashboard frame line ${index + 1} is ${line.cells} cells wide, exceeding declared terminal width ${options.columns}`,
338
+ )
339
+ }
340
+ }
341
+
342
+ const { svg, width, height } = svgFor(parsed, options.columns)
343
+ const rendered = new Resvg(svg, {
344
+ fitTo: { mode: 'original' },
345
+ background: PALETTE.background,
346
+ font: {
347
+ fontFiles: fontFiles(),
348
+ loadSystemFonts: false,
349
+ defaultFontFamily: FONT_FAMILY,
350
+ monospaceFamily: FONT_FAMILY,
351
+ defaultFontSize: FONT_SIZE,
352
+ },
353
+ shapeRendering: 2,
354
+ textRendering: 2,
355
+ imageRendering: 0,
356
+ logLevel: 'off',
357
+ }).render()
358
+ if (rendered.width !== width || rendered.height !== height) {
359
+ throw new Error(
360
+ `dashboard PNG dimensions ${rendered.width}x${rendered.height} did not match SVG ${width}x${height}`,
361
+ )
362
+ }
363
+
364
+ return {
365
+ text: `${parsed.map((line) => line.text).join('\n')}\n`,
366
+ svg,
367
+ png: new Uint8Array(rendered.asPng()),
368
+ width,
369
+ height,
370
+ rows: lines.length,
371
+ columns: options.columns,
372
+ }
373
+ }