@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,1045 @@
1
+ import { configSchema, type Config } from '../config/schema'
2
+ import { humanActor } from '../events/envelope'
3
+ import type { RepositoryEvent } from '../events/repository'
4
+ import { reduceDispatchStatus, type DispatchStatus } from '../kernel/dispatch-status'
5
+ import type { BuildState } from '../kernel/reducer'
6
+ import { scanUnclaimedObservations } from '../processes/harvest'
7
+ import {
8
+ controlHarvestRun,
9
+ OperatorControlError,
10
+ toggleHarvestGate,
11
+ toggleRepositorySetting,
12
+ } from '../operator/control'
13
+ import type { BuildStore, Clock } from '../store/types'
14
+ import { systemClock } from '../store/types'
15
+ import { BuildControlError, buildControlUser, controlBuild } from './build-control'
16
+ import { bulkControlReport, bulkControlRepository } from './bulk-control'
17
+ import { deleteBefore, insertText, moveCursor, type ComposerMotion } from './dashboard/composer'
18
+ import { dashboardBuildControl } from './dashboard/actions'
19
+ import {
20
+ buildDashboardFromProjected,
21
+ type DashboardBuild,
22
+ type DashboardModel,
23
+ type DashboardSelection,
24
+ type DashboardView,
25
+ } from './dashboard/model'
26
+ import { DashboardBuildPollCache } from './dashboard/poll'
27
+ import {
28
+ dashboardContentWidth,
29
+ detailScrollLimit,
30
+ moveDetailScroll,
31
+ moveTranscriptScroll,
32
+ renderDashboard,
33
+ revealDetailFocus,
34
+ type DashboardRendererResolver,
35
+ } from './dashboard/render'
36
+ import { dashboardSelections, moveSelection, reconcileSelection } from './dashboard/selection'
37
+ import { parseTranscript } from './dashboard/transcript'
38
+ import { applySessionFeedUpdate, SessionStreamFeed } from './dashboard/session-feed'
39
+ import { moveSessionScroll, sessionScrollLimit } from './dashboard/render'
40
+ import { LiveRegion, paintableRows } from './dashboard/live'
41
+ import { createKeyboardProtocol } from './keyboard'
42
+ import type { TerminalInput, TerminalInputEvent, TerminalOut } from './terminal'
43
+ import {
44
+ superviseDispatchChild,
45
+ type DispatchChildHandle,
46
+ type DispatchChildOptions,
47
+ } from './dispatch-process'
48
+
49
+ const POLL_MS = 500
50
+ const PAINT_MS = 250
51
+ const ABORTABLE_STATUSES = new Set(['queued', 'running', 'paused', 'blocked'])
52
+
53
+ function validAbortConfirmation(state: BuildState | undefined): boolean {
54
+ return (
55
+ state !== undefined &&
56
+ ABORTABLE_STATUSES.has(state.status) &&
57
+ !state.pendingCommands.some((command) => command.command === 'abort')
58
+ )
59
+ }
60
+
61
+ function isStaleAbortControlError(error: unknown): error is BuildControlError {
62
+ return (
63
+ error instanceof BuildControlError &&
64
+ ['not-found', 'wrong-repository', 'inactive', 'no-longer-active', 'abort-pending'].includes(
65
+ error.code,
66
+ )
67
+ )
68
+ }
69
+
70
+ function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
71
+ return (
72
+ event.type.startsWith('harvest.') ||
73
+ event.type === 'dispatcher.intake-set' ||
74
+ event.type === 'dispatcher.pause-set' ||
75
+ event.type === 'dispatcher.auto-merge-default-set'
76
+ )
77
+ }
78
+
79
+ interface ResumePrompt {
80
+ slug: string
81
+ escalationIds: string[]
82
+ value: string
83
+ cursor: number
84
+ }
85
+
86
+ export interface DispatchFrontendOptions {
87
+ /** Repository identity (§12): the normalized origin, or the checkout path
88
+ * when there is no origin. Every Store key uses this. */
89
+ repo: string
90
+ /** The physical main checkout — the supervised kernel's process cwd. */
91
+ checkout?: string
92
+ storeRef: string
93
+ store: BuildStore
94
+ env: Record<string, string | undefined>
95
+ terminal: TerminalOut
96
+ input: TerminalInput
97
+ signal?: AbortSignal
98
+ once: boolean
99
+ intervalMs?: number
100
+ intake?: boolean
101
+ defaultAutoMerge?: boolean
102
+ clock?: Clock
103
+ resolveDashboardRenderer?: DashboardRendererResolver
104
+ launchChild?: (input: {
105
+ store: BuildStore
106
+ repo: string
107
+ cwd?: string
108
+ run: string
109
+ env: Record<string, string | undefined>
110
+ options: DispatchChildOptions
111
+ }) => DispatchChildHandle
112
+ }
113
+
114
+ /** Terminal controller with a deliberately narrow wiring surface: terminal,
115
+ * BuildStore, immutable repository identity, clock, and child supervision. */
116
+ export class DispatchFrontend {
117
+ private readonly runId = crypto.randomUUID()
118
+ private readonly clock: Clock
119
+ private readonly region: LiveRegion
120
+ private readonly keyboard
121
+ private child: DispatchChildHandle | undefined
122
+ private repositorySeq = 0
123
+ private repositoryEvents: RepositoryEvent[] = []
124
+ private dispatchStatus: DispatchStatus = reduceDispatchStatus([], this.runId)
125
+ private eventRefreshTail: Promise<void> = Promise.resolve()
126
+ private cache: DashboardBuildPollCache | undefined
127
+ private config: Config | undefined
128
+ private configRef: string | undefined
129
+ private configRevision = 0
130
+ /** Display-only pressure is sampled by the Store-only frontend. Undefined
131
+ * means no factual sample has succeeded yet; failures retain the last value. */
132
+ private observationCount: number | undefined
133
+ private model: DashboardModel | undefined
134
+ private selection: DashboardSelection | undefined = { kind: 'global' }
135
+ private view: DashboardView | undefined
136
+ /** The session view's live-read cycle, created with the view it serves and
137
+ * discarded on Escape. All read semantics live in the shared feed module —
138
+ * the same instance contract the DispatchLoop path uses. */
139
+ private sessionFeed: SessionStreamFeed | undefined
140
+ private resumePrompt: ResumePrompt | undefined
141
+ private abortConfirmation: { slug: string } | undefined
142
+ private accepting = false
143
+ private cleanupInput: (() => void) | undefined
144
+ private pollTimer: ReturnType<typeof setInterval> | undefined
145
+ private paintTimer: ReturnType<typeof setInterval> | undefined
146
+ private polling = false
147
+ private pollInFlight: Promise<void> | undefined
148
+ private actionTail: Promise<void> = Promise.resolve()
149
+ private presentationRestored = false
150
+ private operatorStopRequested = false
151
+
152
+ constructor(private readonly opts: DispatchFrontendOptions) {
153
+ this.clock = opts.clock ?? systemClock
154
+ this.keyboard = createKeyboardProtocol(
155
+ (chunk) => opts.terminal.write(chunk),
156
+ opts.terminal.modes,
157
+ )
158
+ this.region = new LiveRegion(opts.terminal, this.keyboard)
159
+ }
160
+
161
+ /** Advance the repository cursor exactly once even when polling overlaps an
162
+ * operator action. High-frequency dispatcher status is folded from deltas;
163
+ * only the low-volume settings/Harvest facts needed by the dashboard model
164
+ * are retained for their existing replay reducers. */
165
+ private async events(): Promise<RepositoryEvent[]> {
166
+ const refresh = this.eventRefreshTail.then(async () => {
167
+ const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
168
+ if (delta.length === 0) return
169
+ this.repositorySeq = delta.at(-1)!.seq
170
+ this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
171
+ this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
172
+ })
173
+ this.eventRefreshTail = refresh.catch(() => {})
174
+ await refresh
175
+ return this.repositoryEvents
176
+ }
177
+
178
+ private async report(message: string, level: 'info' | 'warning' = 'info'): Promise<void> {
179
+ await this.opts.store.appendRepo(this.opts.repo, {
180
+ actor: humanActor(buildControlUser(this.opts.env)),
181
+ type: 'dispatcher.operator-reported',
182
+ payload: { run: this.runId, level, message },
183
+ })
184
+ }
185
+
186
+ private queue(operation: () => Promise<void>): void {
187
+ const result = this.actionTail.then(operation)
188
+ this.actionTail = result.then(
189
+ () => undefined,
190
+ async (error) => {
191
+ try {
192
+ await this.report(
193
+ `dashboard action failed: ${error instanceof Error ? error.message : String(error)}`,
194
+ 'warning',
195
+ )
196
+ await this.renderOnce()
197
+ } catch {
198
+ // The next poll remains the recovery boundary.
199
+ }
200
+ },
201
+ )
202
+ }
203
+
204
+ private selectedSlug(): string | undefined {
205
+ return this.view?.slug ?? (this.selection?.kind === 'build' ? this.selection.slug : undefined)
206
+ }
207
+
208
+ private selectedBuild(): DashboardBuild | undefined {
209
+ const slug = this.selectedSlug()
210
+ return this.model?.builds.find((build) => build.slug === slug)
211
+ }
212
+
213
+ private syncControls(): void {
214
+ if (this.model === undefined) return
215
+ const {
216
+ selection: _selection,
217
+ resumeInput: _resume,
218
+ abortConfirmation: _abort,
219
+ view: _view,
220
+ ...base
221
+ } = this.model
222
+ let next: DashboardModel = {
223
+ ...base,
224
+ ...(this.selection !== undefined ? { selection: this.selection } : {}),
225
+ ...(this.resumePrompt !== undefined
226
+ ? {
227
+ resumeInput: {
228
+ slug: this.resumePrompt.slug,
229
+ value: this.resumePrompt.value,
230
+ cursor: this.resumePrompt.cursor,
231
+ },
232
+ }
233
+ : {}),
234
+ ...(this.abortConfirmation !== undefined
235
+ ? { abortConfirmation: this.abortConfirmation }
236
+ : {}),
237
+ ...(this.view !== undefined ? { view: this.view } : {}),
238
+ }
239
+ if (this.view?.kind === 'detail') {
240
+ this.view = {
241
+ ...this.view,
242
+ scroll: Math.max(
243
+ 0,
244
+ Math.min(
245
+ this.view.scroll,
246
+ detailScrollLimit(
247
+ next,
248
+ dashboardContentWidth(this.opts.terminal.columns),
249
+ paintableRows(this.opts.terminal.rows),
250
+ ),
251
+ ),
252
+ ),
253
+ }
254
+ next = { ...next, view: this.view }
255
+ }
256
+ this.model = next
257
+ }
258
+
259
+ private paint(): void {
260
+ if (this.model === undefined) return
261
+ const renderer = this.opts.resolveDashboardRenderer?.() ?? renderDashboard
262
+ this.region.update(
263
+ renderer(this.model, {
264
+ color: true,
265
+ width: this.opts.terminal.columns,
266
+ height: paintableRows(this.opts.terminal.rows),
267
+ now: this.clock().getTime(),
268
+ }),
269
+ )
270
+ }
271
+
272
+ private async loadConfig(kind: string, rev: number): Promise<Config> {
273
+ const key = `${kind}@${rev}`
274
+ if (this.config !== undefined && this.configRef === key) return this.config
275
+ const artifact = await this.opts.store.getRepoArtifact(this.opts.repo, kind, rev)
276
+ if (artifact === null) throw new Error(`effective config ${key} is not retrievable`)
277
+ const parsed = configSchema.parse(JSON.parse(new TextDecoder().decode(artifact.content)))
278
+ this.config = parsed
279
+ this.configRef = key
280
+ this.configRevision += 1
281
+ return parsed
282
+ }
283
+
284
+ private async renderOnce(): Promise<void> {
285
+ let repositoryEvents = await this.events()
286
+ let status = this.dispatchStatus
287
+ const ref = status.effectiveConfig
288
+ // No filesystem fallback: the first frame waits for the child's durable,
289
+ // run-correlated effective snapshot.
290
+ if (ref === undefined) {
291
+ if (status.health === 'failed' && status.warningNotice !== undefined) {
292
+ this.region.update([status.warningNotice])
293
+ }
294
+ return
295
+ }
296
+ const config = await this.loadConfig(ref.kind, ref.rev)
297
+ try {
298
+ const scan = await scanUnclaimedObservations(this.opts.store, this.opts.repo)
299
+ this.observationCount = scan.observations.length
300
+ } catch {
301
+ // Before the first successful sample there is no factual zero to place in
302
+ // a complete frame. Leave the prior frame untouched and retry on the next
303
+ // ordinary poll, without manufacturing a repository notice.
304
+ if (this.observationCount === undefined) return
305
+ }
306
+ this.cache ??= new DashboardBuildPollCache(this.opts.store, this.opts.repo, config)
307
+ const snapshot = await this.cache.refresh(config, this.configRevision)
308
+ if (!this.cache.isCurrent(snapshot)) return
309
+
310
+ if (this.resumePrompt !== undefined) {
311
+ const state = snapshot.states.get(this.resumePrompt.slug)
312
+ const open = new Set(state?.openEscalations.map((item) => item.id) ?? [])
313
+ const remaining = this.resumePrompt.escalationIds.filter((id) => open.has(id))
314
+ if (remaining.length === 0) this.resumePrompt = undefined
315
+ else this.resumePrompt = { ...this.resumePrompt, escalationIds: remaining }
316
+ }
317
+ if (this.abortConfirmation !== undefined) {
318
+ const slug = this.abortConfirmation.slug
319
+ if (!validAbortConfirmation(snapshot.states.get(slug))) {
320
+ await this.dismissStaleAbortConfirmation(slug)
321
+ repositoryEvents = await this.events()
322
+ status = this.dispatchStatus
323
+ }
324
+ }
325
+
326
+ const previous = this.model === undefined ? [] : dashboardSelections(this.model)
327
+ const warningLines = [
328
+ ...status.roleWarnings,
329
+ ...(status.warningNotice !== undefined ? [status.warningNotice] : []),
330
+ ]
331
+ const projected = buildDashboardFromProjected(
332
+ snapshot.builds,
333
+ {
334
+ repo: this.opts.repo,
335
+ queued: status.queued ?? 0,
336
+ activeCount: [...snapshot.states.values()].filter(
337
+ (state) => state.status !== 'done' && state.status !== 'aborted',
338
+ ).length,
339
+ capacity: config.capacity,
340
+ observationCount: this.observationCount,
341
+ observationLimit: config.policy.harvestThreshold,
342
+ ...(status.availableUpgrade !== undefined
343
+ ? { availableUpgrade: status.availableUpgrade }
344
+ : {}),
345
+ ...(warningLines.length > 0 ? { warningLines } : {}),
346
+ },
347
+ repositoryEvents,
348
+ )
349
+ this.selection = reconcileSelection(previous, dashboardSelections(projected), this.selection)
350
+ if (
351
+ this.view !== undefined &&
352
+ !projected.builds.some((build) => build.slug === this.view!.slug)
353
+ ) {
354
+ this.view = undefined
355
+ }
356
+ this.model = projected
357
+ this.syncControls()
358
+ this.paint()
359
+ // The session view's live-read cycle rides the poll tick: one immediate
360
+ // read per interval, applied through the action queue under the fence.
361
+ this.pollSessionFeed()
362
+ }
363
+
364
+ private moveVertical(delta: number): void {
365
+ if (this.view?.kind === 'session') {
366
+ const view = this.view
367
+ const upgrade = this.model?.availableUpgrade !== undefined
368
+ const limit = sessionScrollLimit(
369
+ view,
370
+ this.opts.terminal.columns,
371
+ paintableRows(this.opts.terminal.rows),
372
+ upgrade,
373
+ )
374
+ const current = view.follow ? limit : view.scroll
375
+ const scroll = moveSessionScroll(
376
+ view,
377
+ this.opts.terminal.columns,
378
+ paintableRows(this.opts.terminal.rows),
379
+ current,
380
+ delta,
381
+ upgrade,
382
+ )
383
+ let follow = view.follow
384
+ if (delta < 0 && follow && scroll < limit) follow = false
385
+ if (delta > 0 && !follow && scroll >= limit) follow = true
386
+ this.view = { ...view, scroll, follow }
387
+ this.syncControls()
388
+ this.paint()
389
+ return
390
+ }
391
+ if (this.view?.kind === 'transcript') {
392
+ this.view = {
393
+ ...this.view,
394
+ scroll: moveTranscriptScroll(
395
+ this.view.transcript,
396
+ this.opts.terminal.columns,
397
+ paintableRows(this.opts.terminal.rows),
398
+ this.view.scroll,
399
+ delta,
400
+ this.model?.availableUpgrade !== undefined,
401
+ ),
402
+ }
403
+ } else if (this.view?.kind === 'detail' && this.model !== undefined) {
404
+ this.view = {
405
+ ...this.view,
406
+ scroll: moveDetailScroll(
407
+ this.model,
408
+ dashboardContentWidth(this.opts.terminal.columns),
409
+ paintableRows(this.opts.terminal.rows),
410
+ this.view.scroll,
411
+ delta,
412
+ ),
413
+ }
414
+ } else {
415
+ const rows =
416
+ this.model === undefined ? [{ kind: 'global' } as const] : dashboardSelections(this.model)
417
+ this.selection = moveSelection(rows, this.selection, delta)
418
+ }
419
+ this.syncControls()
420
+ this.paint()
421
+ }
422
+
423
+ private moveSession(delta: number): void {
424
+ if (this.view?.kind !== 'detail') return
425
+ const build = this.model?.builds.find((item) => item.slug === this.view!.slug)
426
+ const sessions = build?.sessions ?? []
427
+ if (sessions.length === 0) return
428
+ const current = sessions.findIndex((session) => session.id === this.view!.sessionId)
429
+ const sessionId =
430
+ sessions[Math.max(0, Math.min(sessions.length - 1, (current < 0 ? 0 : current) + delta))]!.id
431
+ const candidate = { ...this.view, sessionId }
432
+ this.view = {
433
+ ...candidate,
434
+ scroll: revealDetailFocus(
435
+ { ...this.model!, view: candidate },
436
+ dashboardContentWidth(this.opts.terminal.columns),
437
+ paintableRows(this.opts.terminal.rows),
438
+ 'session',
439
+ candidate.scroll,
440
+ ),
441
+ }
442
+ this.syncControls()
443
+ this.paint()
444
+ }
445
+
446
+ private async openSelected(): Promise<void> {
447
+ if (this.view === undefined) {
448
+ const build = this.selectedBuild()
449
+ if (build === undefined) return
450
+ this.view = {
451
+ kind: 'detail',
452
+ slug: build.slug,
453
+ scroll: 0,
454
+ ...(build.sessions?.[0] !== undefined ? { sessionId: build.sessions[0].id } : {}),
455
+ }
456
+ this.syncControls()
457
+ this.paint()
458
+ return
459
+ }
460
+ if (this.view.kind !== 'detail') return
461
+ const captured = this.view
462
+ const session = this.selectedBuild()?.sessions?.find((item) => item.id === captured.sessionId)
463
+ if (session === undefined) return
464
+ // The stream branch outranks every transcript outcome: a session with a
465
+ // stream — open or closed — opens the read-only live view. Sessions
466
+ // without a stream keep the frontend's existing behavior byte-for-byte
467
+ // (the silent open-session return, the reclaimed message, the transcript).
468
+ if (session.stream !== undefined) {
469
+ this.view = {
470
+ kind: 'session',
471
+ slug: captured.slug,
472
+ sessionId: session.id,
473
+ stream: session.stream,
474
+ status: session.streamStatus,
475
+ source: { kind: 'parts', parts: [], lastSeq: 0 },
476
+ follow: true,
477
+ scroll: 0,
478
+ }
479
+ this.sessionFeed = new SessionStreamFeed(this.opts.store, captured.slug)
480
+ this.syncControls()
481
+ this.paint()
482
+ // Kick the first read immediately rather than waiting one interval.
483
+ this.pollSessionFeed()
484
+ return
485
+ }
486
+ if (session.status === 'reclaimed') {
487
+ const { message: _message, messageWhileSessionOpen: _fence, ...stable } = captured
488
+ const candidate = {
489
+ ...stable,
490
+ message: 'This session was reclaimed by a recovering runner; transcript unavailable.',
491
+ }
492
+ this.view = {
493
+ ...candidate,
494
+ scroll: revealDetailFocus(
495
+ { ...this.model!, view: candidate },
496
+ dashboardContentWidth(this.opts.terminal.columns),
497
+ paintableRows(this.opts.terminal.rows),
498
+ 'message',
499
+ candidate.scroll,
500
+ ),
501
+ }
502
+ this.syncControls()
503
+ this.paint()
504
+ return
505
+ }
506
+ if (session.status === 'open' || session.transcript === undefined) return
507
+ const artifact = await this.opts.store.getArtifact(
508
+ captured.slug,
509
+ session.transcript.kind,
510
+ session.transcript.rev,
511
+ )
512
+ if (
513
+ artifact === null ||
514
+ this.view?.kind !== 'detail' ||
515
+ this.view.slug !== captured.slug ||
516
+ this.view.sessionId !== captured.sessionId
517
+ ) {
518
+ return
519
+ }
520
+ this.view = {
521
+ kind: 'transcript',
522
+ slug: captured.slug,
523
+ sessionId: session.id,
524
+ transcript: parseTranscript(new TextDecoder().decode(artifact.content)),
525
+ scroll: 0,
526
+ }
527
+ this.syncControls()
528
+ this.paint()
529
+ }
530
+
531
+ /** Fire-and-forget live-read cycle for an open session view (or one whose
532
+ * last read failed). The apply runs through the action queue under the
533
+ * feed's identity+cursor fence — the same pattern the DispatchLoop path
534
+ * uses, from the same shared feed module. */
535
+ private pollSessionFeed(): void {
536
+ const view = this.view
537
+ if (view?.kind !== 'session') return
538
+ if (view.status !== 'open' && view.error === undefined) {
539
+ // A closed view is final once its content is in hand; an unloaded one
540
+ // (a closed session just opened, empty chunk log so far) still needs
541
+ // its read — which is where the artifact fallback happens too.
542
+ if (view.source.kind !== 'parts' || view.source.lastSeq !== 0) return
543
+ }
544
+ const feed = this.sessionFeed
545
+ if (feed === undefined) return
546
+ void feed
547
+ .poll(view)
548
+ .then((update) => {
549
+ if (update === undefined) return
550
+ this.queue(async () => {
551
+ const next = applySessionFeedUpdate(
552
+ this.view?.kind === 'session' ? this.view : undefined,
553
+ update,
554
+ )
555
+ if (next === undefined) return
556
+ const terminal = this.opts.terminal
557
+ const limit = sessionScrollLimit(
558
+ next,
559
+ terminal.columns,
560
+ paintableRows(terminal.rows),
561
+ this.model?.availableUpgrade !== undefined,
562
+ )
563
+ this.view = {
564
+ ...next,
565
+ scroll: next.follow ? limit : Math.min(next.scroll, limit),
566
+ }
567
+ this.syncControls()
568
+ this.paint()
569
+ })
570
+ })
571
+ .catch(() => {
572
+ // The feed contains its own failures; the next poll retries.
573
+ })
574
+ }
575
+
576
+ private leaveView(): void {
577
+ if (this.view?.kind === 'session') {
578
+ this.sessionFeed = undefined
579
+ this.view = {
580
+ kind: 'detail',
581
+ slug: this.view.slug,
582
+ sessionId: this.view.sessionId,
583
+ scroll: 0,
584
+ }
585
+ } else if (this.view?.kind === 'transcript') {
586
+ this.view = {
587
+ kind: 'detail',
588
+ slug: this.view.slug,
589
+ sessionId: this.view.sessionId,
590
+ scroll: 0,
591
+ }
592
+ } else if (this.view?.kind === 'detail') this.view = undefined
593
+ else return
594
+ this.syncControls()
595
+ this.paint()
596
+ }
597
+
598
+ private async dismissStaleAbortConfirmation(slug: string): Promise<void> {
599
+ if (this.abortConfirmation?.slug === slug) this.abortConfirmation = undefined
600
+ await this.report(
601
+ `build ${slug}: abort confirmation dismissed because the build state changed`,
602
+ 'warning',
603
+ )
604
+ }
605
+
606
+ private async confirmAbort(slug: string): Promise<void> {
607
+ await this.events()
608
+ const ref = this.dispatchStatus.effectiveConfig
609
+ if (ref === undefined) throw new Error('effective config is not available')
610
+ const config = await this.loadConfig(ref.kind, ref.rev)
611
+ this.cache ??= new DashboardBuildPollCache(this.opts.store, this.opts.repo, config)
612
+ let snapshot = await this.cache.refresh(config, this.configRevision)
613
+ while (!this.cache.isCurrent(snapshot)) {
614
+ snapshot = await this.cache.refresh(config, this.configRevision)
615
+ }
616
+
617
+ if (!validAbortConfirmation(snapshot.states.get(slug))) {
618
+ await this.dismissStaleAbortConfirmation(slug)
619
+ await this.renderOnce()
620
+ return
621
+ }
622
+
623
+ try {
624
+ await this.buildAction('abort', slug)
625
+ } catch (error) {
626
+ if (!isStaleAbortControlError(error)) throw error
627
+ await this.dismissStaleAbortConfirmation(slug)
628
+ await this.renderOnce()
629
+ }
630
+ }
631
+
632
+ private async buildAction(
633
+ kind: 'pause' | 'resume' | 'discard' | 'toggle-auto-merge' | 'abort',
634
+ slug: string,
635
+ ): Promise<void> {
636
+ const result = await controlBuild({
637
+ store: this.opts.store,
638
+ repo: this.opts.repo,
639
+ slug,
640
+ env: this.opts.env,
641
+ action:
642
+ kind === 'pause'
643
+ ? { kind: 'dashboard-pause' }
644
+ : kind === 'resume'
645
+ ? { kind: 'dashboard-resume' }
646
+ : { kind },
647
+ })
648
+ if (result.kind === 'answer-required') {
649
+ this.resumePrompt = { slug, escalationIds: result.escalationIds, value: '', cursor: 0 }
650
+ this.syncControls()
651
+ this.paint()
652
+ return
653
+ }
654
+ const message =
655
+ result.kind !== 'command'
656
+ ? `build ${slug}: action recorded`
657
+ : result.command === 'pause'
658
+ ? `build ${slug}: pause requested`
659
+ : result.command === 'resume'
660
+ ? kind === 'pause'
661
+ ? `build ${slug}: pending pause cancelled`
662
+ : `build ${slug}: resume requested`
663
+ : result.command === 'discard'
664
+ ? `build ${slug}: discard requested`
665
+ : result.command === 'abort'
666
+ ? `build ${slug}: abort requested`
667
+ : result.command === 'auto-merge-on'
668
+ ? `build ${slug}: auto-merge requested`
669
+ : `build ${slug}: auto-merge cancelled`
670
+ await this.report(message)
671
+ await this.renderOnce()
672
+ }
673
+
674
+ private async toggleIntake(): Promise<void> {
675
+ const event = await toggleRepositorySetting({
676
+ store: this.opts.store,
677
+ repo: this.opts.repo,
678
+ user: buildControlUser(this.opts.env),
679
+ setting: 'intake',
680
+ })
681
+ await this.report(`dispatcher intake ${event.enabled ? 'ON' : 'OFF'}`)
682
+ await this.renderOnce()
683
+ }
684
+
685
+ private async toggleDefaultAutoMerge(): Promise<void> {
686
+ const event = await toggleRepositorySetting({
687
+ store: this.opts.store,
688
+ repo: this.opts.repo,
689
+ user: buildControlUser(this.opts.env),
690
+ setting: 'auto-merge-default',
691
+ })
692
+ await this.report(`dispatcher auto-merge default ${event.enabled ? 'ON' : 'OFF'}`)
693
+ await this.renderOnce()
694
+ }
695
+
696
+ private async bulk(direction: 'pause' | 'resume'): Promise<void> {
697
+ const summary = await bulkControlRepository({
698
+ store: this.opts.store,
699
+ repo: this.opts.repo,
700
+ env: this.opts.env,
701
+ direction,
702
+ })
703
+ await this.report(bulkControlReport(summary))
704
+ await this.renderOnce()
705
+ }
706
+
707
+ private async toggleHarvest(): Promise<void> {
708
+ const result = await toggleHarvestGate({
709
+ store: this.opts.store,
710
+ repo: this.opts.repo,
711
+ user: buildControlUser(this.opts.env),
712
+ })
713
+ await this.report(`harvest gate: ${result.command} requested`)
714
+ await this.renderOnce()
715
+ }
716
+
717
+ private async harvestRun(expectedRun: string | undefined): Promise<void> {
718
+ if (expectedRun === undefined) {
719
+ await this.report('harvest run action ignored: selected run is no longer active')
720
+ await this.renderOnce()
721
+ return
722
+ }
723
+ try {
724
+ const result = await controlHarvestRun({
725
+ store: this.opts.store,
726
+ repo: this.opts.repo,
727
+ user: buildControlUser(this.opts.env),
728
+ run: expectedRun,
729
+ })
730
+ await this.report(`harvest: ${result.action} requested`)
731
+ } catch (error) {
732
+ await this.report(
733
+ error instanceof OperatorControlError && error.code === 'harvest-unavailable'
734
+ ? 'harvest run action ignored: selected run is no longer active'
735
+ : error instanceof Error
736
+ ? error.message
737
+ : String(error),
738
+ )
739
+ }
740
+ await this.renderOnce()
741
+ }
742
+
743
+ private async submitResume(prompt: ResumePrompt): Promise<void> {
744
+ const result = await controlBuild({
745
+ store: this.opts.store,
746
+ repo: this.opts.repo,
747
+ slug: prompt.slug,
748
+ env: this.opts.env,
749
+ action: { kind: 'answer', text: prompt.value, escalationIds: prompt.escalationIds },
750
+ })
751
+ this.resumePrompt = undefined
752
+ await this.report(
753
+ `build ${prompt.slug}: blocked resume requested${
754
+ result.kind === 'answered' && result.resolution === 'guidance' ? ' with guidance' : ''
755
+ }`,
756
+ )
757
+ await this.renderOnce()
758
+ }
759
+
760
+ private editResume(input: TerminalInputEvent): void {
761
+ const prompt = this.resumePrompt
762
+ if (prompt === undefined) return
763
+ const update = (value: string, cursor: number): void => {
764
+ this.resumePrompt = { ...prompt, value, cursor }
765
+ this.syncControls()
766
+ this.paint()
767
+ }
768
+ if (input.type === 'text' || input.type === 'paste') {
769
+ const next = insertText(prompt.value, prompt.cursor, input.text)
770
+ update(next.value, next.cursor)
771
+ } else if (input.type === 'newline') {
772
+ const next = insertText(prompt.value, prompt.cursor, '\n')
773
+ update(next.value, next.cursor)
774
+ } else if (input.type === 'backspace') {
775
+ const next = deleteBefore(prompt.value, prompt.cursor)
776
+ update(next.value, next.cursor)
777
+ } else if (['left', 'right', 'up', 'down', 'home', 'end'].includes(input.type)) {
778
+ update(prompt.value, moveCursor(prompt.value, prompt.cursor, input.type as ComposerMotion))
779
+ } else if (input.type === 'escape') {
780
+ this.resumePrompt = undefined
781
+ this.syncControls()
782
+ this.paint()
783
+ } else if (input.type === 'enter') this.queue(() => this.submitResume(prompt))
784
+ }
785
+
786
+ private onInput(input: TerminalInputEvent): void {
787
+ if (!this.accepting) return
788
+ if (input.type === 'interrupt') {
789
+ this.requestOperatorStop()
790
+ return
791
+ }
792
+ if (this.resumePrompt !== undefined) {
793
+ this.editResume(input)
794
+ return
795
+ }
796
+ // The session view is strictly read-only: before the abort-confirmation,
797
+ // Enter, and text branches can be reached, every key except Up/Down
798
+ // (scroll) and Escape (back) returns without queueing any action. No key
799
+ // handled in the session view can append an event, and the abort
800
+ // confirmation cannot be opened from it.
801
+ if (this.view?.kind === 'session') {
802
+ if (input.type === 'up' || input.type === 'down') {
803
+ this.moveVertical(input.type === 'up' ? -1 : 1)
804
+ } else if (input.type === 'escape') {
805
+ this.leaveView()
806
+ }
807
+ return
808
+ }
809
+ const enter = input.type === 'enter' || input.type === 'newline'
810
+ if (this.abortConfirmation !== undefined) {
811
+ if (enter) {
812
+ const slug = this.abortConfirmation.slug
813
+ this.abortConfirmation = undefined
814
+ this.queue(() => this.confirmAbort(slug))
815
+ } else if (input.type === 'escape') {
816
+ this.abortConfirmation = undefined
817
+ this.syncControls()
818
+ this.paint()
819
+ }
820
+ return
821
+ }
822
+ if (input.type === 'up' || input.type === 'down') {
823
+ this.moveVertical(input.type === 'up' ? -1 : 1)
824
+ return
825
+ }
826
+ if (input.type === 'left' || input.type === 'right') {
827
+ this.moveSession(input.type === 'left' ? -1 : 1)
828
+ return
829
+ }
830
+ if (enter) {
831
+ // List/detail identity is captured synchronously before any Store read;
832
+ // a later selection move cannot retarget this navigation.
833
+ void this.openSelected().catch((error) =>
834
+ this.report(
835
+ `dashboard open action failed: ${error instanceof Error ? error.message : String(error)}`,
836
+ 'warning',
837
+ ),
838
+ )
839
+ return
840
+ }
841
+ if (input.type === 'escape') {
842
+ this.leaveView()
843
+ return
844
+ }
845
+ if (input.type !== 'text') return
846
+ switch (input.text.toLowerCase()) {
847
+ case 'i':
848
+ if (this.selection?.kind === 'global' && this.view === undefined)
849
+ this.queue(() => this.toggleIntake())
850
+ break
851
+ case 'm': {
852
+ const slug = this.selectedSlug()
853
+ if (this.view === undefined && this.selection?.kind === 'global') {
854
+ this.queue(() => this.toggleDefaultAutoMerge())
855
+ } else if (slug !== undefined) {
856
+ this.queue(() => this.buildAction('toggle-auto-merge', slug))
857
+ }
858
+ break
859
+ }
860
+ case 'p': {
861
+ const build = this.selectedBuild()
862
+ if (this.selection?.kind === 'global' && this.view === undefined)
863
+ this.queue(() => this.bulk('pause'))
864
+ else if (this.selection?.kind === 'harvest' && this.view === undefined) {
865
+ const run = this.model?.harvest?.run
866
+ this.queue(() => this.harvestRun(run))
867
+ } else if (dashboardBuildControl(build?.status ?? 'queued')?.key === 'p') {
868
+ this.queue(() => this.buildAction('pause', build!.slug))
869
+ }
870
+ break
871
+ }
872
+ case 'r': {
873
+ const build = this.selectedBuild()
874
+ if (this.selection?.kind === 'global' && this.view === undefined)
875
+ this.queue(() => this.bulk('resume'))
876
+ else if (dashboardBuildControl(build?.status ?? 'queued')?.key === 'r')
877
+ this.queue(() => this.buildAction('resume', build!.slug))
878
+ break
879
+ }
880
+ case 'd': {
881
+ const build = this.selectedBuild()
882
+ if (build?.status === 'queued') this.queue(() => this.buildAction('discard', build.slug))
883
+ break
884
+ }
885
+ case 'a':
886
+ // The session-view gate above already returns for a session view; the
887
+ // transcript guard predates it. Together they keep the abort
888
+ // confirmation unreachable from any read-only view.
889
+ if (this.selectedBuild() !== undefined && this.view?.kind !== 'transcript') {
890
+ this.abortConfirmation = { slug: this.selectedBuild()!.slug }
891
+ this.syncControls()
892
+ this.paint()
893
+ }
894
+ break
895
+ case 'h':
896
+ if (this.selection?.kind === 'global' && this.view === undefined)
897
+ this.queue(() => this.toggleHarvest())
898
+ break
899
+ }
900
+ }
901
+
902
+ private startPresentation(): void {
903
+ this.accepting = true
904
+ this.cleanupInput = this.opts.input.start((input) => this.onInput(input), {
905
+ onListening: () => this.keyboard.query(),
906
+ onKeyboardFlags: (flags) => this.keyboard.reported(flags),
907
+ onDeviceAttributes: () => this.keyboard.deviceAttributes(),
908
+ })
909
+ const poll = (): void => {
910
+ if (this.polling) return
911
+ this.polling = true
912
+ const current = this.renderOnce()
913
+ .catch(async (error) => {
914
+ try {
915
+ await this.report(
916
+ `dashboard render failed: ${error instanceof Error ? error.message : String(error)}`,
917
+ 'warning',
918
+ )
919
+ } catch {
920
+ // retry on the next poll
921
+ }
922
+ })
923
+ .finally(() => {
924
+ if (this.pollInFlight === current) this.pollInFlight = undefined
925
+ this.polling = false
926
+ })
927
+ this.pollInFlight = current
928
+ }
929
+ this.pollTimer = setInterval(poll, POLL_MS)
930
+ this.paintTimer = setInterval(() => this.paint(), PAINT_MS)
931
+ this.pollTimer.unref?.()
932
+ this.paintTimer.unref?.()
933
+ poll()
934
+ }
935
+
936
+ private requestOperatorStop(): void {
937
+ this.operatorStopRequested = true
938
+ this.restorePresentationForStop()
939
+ void this.child?.stop()
940
+ }
941
+
942
+ /** Stop terminal ownership synchronously before awaiting a kernel that may
943
+ * still be finishing an unsafe ticket-claim tick. Normal/abnormal child exits
944
+ * retain the final-frame path below; operator interruption prioritizes shell
945
+ * restoration without force-killing that tick. */
946
+ private restorePresentationForStop(): void {
947
+ if (this.presentationRestored) return
948
+ this.presentationRestored = true
949
+ this.accepting = false
950
+ try {
951
+ this.cleanupInput?.()
952
+ } finally {
953
+ this.cleanupInput = undefined
954
+ if (this.pollTimer !== undefined) clearInterval(this.pollTimer)
955
+ if (this.paintTimer !== undefined) clearInterval(this.paintTimer)
956
+ this.pollTimer = undefined
957
+ this.paintTimer = undefined
958
+ this.region.finish()
959
+ }
960
+ }
961
+
962
+ private async finishPresentation(): Promise<void> {
963
+ if (!this.presentationRestored) {
964
+ this.accepting = false
965
+ try {
966
+ this.cleanupInput?.()
967
+ } finally {
968
+ this.cleanupInput = undefined
969
+ }
970
+ if (this.pollTimer !== undefined) clearInterval(this.pollTimer)
971
+ if (this.paintTimer !== undefined) clearInterval(this.paintTimer)
972
+ this.pollTimer = undefined
973
+ this.paintTimer = undefined
974
+ }
975
+ try {
976
+ await this.pollInFlight
977
+ await this.actionTail
978
+ await this.renderOnce()
979
+ } catch {
980
+ // final frame is best-effort
981
+ } finally {
982
+ this.region.finish()
983
+ }
984
+ }
985
+
986
+ async run(): Promise<void> {
987
+ await this.opts.store.ensureRepo(this.opts.repo)
988
+ const actor = humanActor(buildControlUser(this.opts.env))
989
+ if (this.opts.intake !== undefined) {
990
+ await this.opts.store.appendRepo(this.opts.repo, {
991
+ actor,
992
+ type: 'dispatcher.intake-set',
993
+ payload: { enabled: this.opts.intake },
994
+ })
995
+ }
996
+ if (this.opts.defaultAutoMerge !== undefined) {
997
+ await this.opts.store.appendRepo(this.opts.repo, {
998
+ actor,
999
+ type: 'dispatcher.auto-merge-default-set',
1000
+ payload: { enabled: this.opts.defaultAutoMerge },
1001
+ })
1002
+ }
1003
+
1004
+ const options: DispatchChildOptions = {
1005
+ targetRepo: this.opts.checkout ?? this.opts.repo,
1006
+ storeRef: this.opts.storeRef,
1007
+ run: this.runId,
1008
+ once: this.opts.once,
1009
+ ...(this.opts.intervalMs !== undefined ? { intervalMs: this.opts.intervalMs } : {}),
1010
+ }
1011
+ this.child = (this.opts.launchChild ?? ((input) => superviseDispatchChild(input)))({
1012
+ store: this.opts.store,
1013
+ repo: this.opts.repo,
1014
+ cwd: this.opts.checkout,
1015
+ run: this.runId,
1016
+ env: this.opts.env,
1017
+ options,
1018
+ })
1019
+ const stop = (): void => this.requestOperatorStop()
1020
+ this.opts.signal?.addEventListener('abort', stop, { once: true })
1021
+ try {
1022
+ if (!this.opts.once) this.startPresentation()
1023
+ if (this.opts.signal?.aborted) this.requestOperatorStop()
1024
+ const result = await this.child.completed
1025
+ if (
1026
+ !(result.outcome === 'normal' && result.exitCode === 0) &&
1027
+ !(result.outcome === 'forced' && this.operatorStopRequested)
1028
+ ) {
1029
+ const processDetails = [
1030
+ `exit code ${result.exitCode}`,
1031
+ ...(result.signal !== undefined ? [`signal ${result.signal}`] : []),
1032
+ ].join(', ')
1033
+ throw new Error(
1034
+ result.error !== undefined
1035
+ ? `dispatcher kernel failed: ${result.error} (${processDetails})`
1036
+ : `dispatcher kernel ${result.outcome} (${processDetails})`,
1037
+ )
1038
+ }
1039
+ } finally {
1040
+ this.opts.signal?.removeEventListener('abort', stop)
1041
+ await this.child.stop()
1042
+ await this.finishPresentation()
1043
+ }
1044
+ }
1045
+ }