@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,361 @@
1
+ import { writeSync } from 'node:fs'
2
+ import { emitKeypressEvents } from 'node:readline'
3
+ import {
4
+ decodeCsi,
5
+ endsCsi,
6
+ INTERRUPT_WATCH_MAX,
7
+ isCsiParameterByte,
8
+ isInterruptSequence,
9
+ MAX_CSI_PARAM_DIGITS,
10
+ type CsiReport,
11
+ } from './keyboard'
12
+ import { createTerminalModeController, type TerminalModeController } from './terminal-restore'
13
+
14
+ /**
15
+ * The output seam for interactive rendering (SPEC §14).
16
+ *
17
+ * `ab dispatch` writes lines through a `stdout: (line) => void` dep. That is
18
+ * the right shape for append-only output and the wrong one for a redrawn
19
+ * region, which needs raw writes (no implicit newline), the terminal width,
20
+ * and — above all — an honest answer to "is anyone watching?".
21
+ *
22
+ * The seam is deliberately tiny and injectable: `DispatchOpts.terminal` is
23
+ * OPTIONAL, and absent ⇒ non-interactive ⇒ plain output. That default is what
24
+ * keeps every existing dispatch test (and every piped invocation) on exactly
25
+ * today's behavior, and it means the dashboard can never be the reason a
26
+ * scripted `ab dispatch` starts emitting escape sequences.
27
+ */
28
+
29
+ export type TerminalInputEvent =
30
+ | { type: 'up' }
31
+ | { type: 'down' }
32
+ | { type: 'left' }
33
+ | { type: 'right' }
34
+ | { type: 'home' }
35
+ | { type: 'end' }
36
+ | { type: 'enter' }
37
+ | { type: 'newline' }
38
+ | { type: 'backspace' }
39
+ | { type: 'escape' }
40
+ | { type: 'interrupt' }
41
+ | { type: 'text'; text: string }
42
+ /** One bracketed paste, delivered whole. Never split, never capped: the
43
+ * buffer has to hold what the operator actually pasted. */
44
+ | { type: 'paste'; text: string }
45
+
46
+ export interface TerminalInputHooks {
47
+ onListening?(): void
48
+ onKeyboardFlags?(flags: number): void
49
+ onDeviceAttributes?(): void
50
+ }
51
+
52
+ /** Injectable keyboard seam. Starting returns an idempotent cleanup. */
53
+ export interface TerminalInput {
54
+ start(onInput: (input: TerminalInputEvent) => void, hooks?: TerminalInputHooks): () => void
55
+ }
56
+
57
+ export interface TerminalOut {
58
+ /** Raw write — no newline appended (unlike the line-oriented stdout dep). */
59
+ write(chunk: string): void
60
+ /** Shared ledger for ordinary teardown and synchronous process restoration. */
61
+ modes: TerminalModeController
62
+ /** Terminal width in columns; a sane fallback when unknown. */
63
+ columns: number
64
+ /** Terminal height in rows; a sane fallback when unknown. The live region
65
+ * snapshots this on every update so a resize invalidates its top-anchored
66
+ * alternate-screen paint. The frame still has to leave one spare row for its
67
+ * trailing newline. */
68
+ rows: number
69
+ /** True only for a real TTY — false for pipes and redirects. */
70
+ interactive: boolean
71
+ }
72
+
73
+ /** Dimensions to assume when the stream reports none — the conventional 80x24. */
74
+ const FALLBACK_COLUMNS = 80
75
+ const FALLBACK_ROWS = 24
76
+
77
+ /**
78
+ * A terminal dimension, or the fallback.
79
+ *
80
+ * The guard is `> 0`, not `?? `: a TTY may report **0** — `script(1)`, many pty
81
+ * wrappers, and some CI runners all do — and `0 ?? 80` is `0`. Zero is not a
82
+ * dimension; it means "this terminal will not say". (A zero width truncated
83
+ * every line to nothing and collapsed the dashboard into a column of
84
+ * ellipses; a zero height would clamp the whole build list away.)
85
+ */
86
+ function dimension(value: number | undefined, fallback: number): number {
87
+ return typeof value === 'number' && value > 0 ? value : fallback
88
+ }
89
+
90
+ /**
91
+ * The real terminal over a Node/Bun write stream.
92
+ *
93
+ * `isTTY` is `undefined` when stdout is a pipe or a file, so
94
+ * `stream.isTTY === true` delivers "non-interactive output, including
95
+ * redirected or piped output, automatically uses plain mode" for free — there
96
+ * is no separate detection path to keep in sync.
97
+ *
98
+ * `columns` and `rows` are getters, not snapshots: a resized window is picked
99
+ * up on the next frame without anyone subscribing to SIGWINCH.
100
+ */
101
+ export function processTerminal(
102
+ stream: NodeJS.WriteStream = process.stdout,
103
+ emergencyWrite: (chunk: string) => void = (chunk) => {
104
+ const fd = (stream as unknown as { fd?: number }).fd
105
+ if (fd === undefined) throw new Error('terminal stream has no synchronous file descriptor')
106
+ writeSync(fd, chunk)
107
+ },
108
+ ): TerminalOut {
109
+ const write = (chunk: string): void => {
110
+ stream.write(chunk)
111
+ }
112
+ return {
113
+ write,
114
+ modes: createTerminalModeController(write, emergencyWrite),
115
+ get columns(): number {
116
+ return dimension(stream.columns, FALLBACK_COLUMNS)
117
+ },
118
+ get rows(): number {
119
+ return dimension(stream.rows, FALLBACK_ROWS)
120
+ },
121
+ interactive: stream.isTTY === true,
122
+ }
123
+ }
124
+
125
+ interface Keypress {
126
+ name?: string
127
+ ctrl?: boolean
128
+ meta?: boolean
129
+ sequence?: string
130
+ }
131
+
132
+ /** A keyboard decoder holding the small amount of state a composer needs:
133
+ * bracketed-paste accumulation and CSI reassembly. One per `start()`. */
134
+ export interface TerminalInputDecoder {
135
+ press(text: string | undefined, key: Keypress): TerminalInputEvent | undefined
136
+ /** Drop any partial paste, CSI run, or interrupt watch when the region tears down. */
137
+ cleanup(): void
138
+ }
139
+
140
+ /**
141
+ * Normalize readline's platform-dependent keypress shape without deciding what
142
+ * printable characters mean. In particular, `m`, `p`, and `d` remain text
143
+ * here: the dispatch controller maps them to commands only while no text input
144
+ * is active, so they can be typed into blocked-build feedback.
145
+ *
146
+ * **CR submits, LF inserts a newline.** They arrive distinctly (`'return'`
147
+ * versus `'enter'`), and Ctrl-J is literally LF - a newline binding that needs
148
+ * no protocol negotiation and works in every terminal that runs the dashboard
149
+ * today. Outside the resume prompt the controller treats `newline` exactly
150
+ * like `enter`, so only the composer can tell the two apart.
151
+ *
152
+ * Two facts about Node/Bun's `emitKeypressEvents`, both probed rather than
153
+ * recalled, shape the state this holds:
154
+ *
155
+ * - `key.name` for an unrecognized CSI is the literal STRING `'undefined'`.
156
+ * - The parser gives up at the first `:` in a parameter list and during longer
157
+ * numeric parameters, emitting the partial sequence plus the remaining bytes
158
+ * as ordinary printable text. Lock-bearing arrows, associated text, and
159
+ * alternate-layout chords therefore all require reassembly.
160
+ *
161
+ * So a partial CSI opens a reassembly buffer and later fragments are appended
162
+ * rather than delivered, which is what stops a Kitty Shift+Enter from
163
+ * injecting `1u` into the operator's guidance.
164
+ */
165
+ export function createTerminalInputDecoder(hooks: TerminalInputHooks = {}): TerminalInputDecoder {
166
+ type CsiState = { buffer: string; digitRun: number; draining: boolean }
167
+ let csi: CsiState | undefined
168
+ let paste: string | undefined
169
+ let watch: string | undefined
170
+
171
+ const reportEvent = (report: CsiReport): TerminalInputEvent | undefined => {
172
+ if (report.kind === 'input') return report.event
173
+ if (report.kind === 'keyboard-flags') hooks.onKeyboardFlags?.(report.flags)
174
+ if (report.kind === 'device-attributes') hooks.onDeviceAttributes?.()
175
+ return undefined
176
+ }
177
+
178
+ /** Consume one parser fragment. `false` lets a malformed fragment reach the keymap. */
179
+ const consumeCsi = (
180
+ fragment: string,
181
+ restart: boolean,
182
+ ): { consumed: boolean; event?: TerminalInputEvent } => {
183
+ if (restart) csi = { buffer: '\x1b[', digitRun: 0, draining: false }
184
+ if (csi === undefined) return { consumed: false }
185
+ const payload = restart ? fragment.slice(2) : fragment
186
+
187
+ for (let index = 0; index < payload.length; index += 1) {
188
+ const ch = payload[index] ?? ''
189
+ if (isCsiParameterByte(ch)) {
190
+ if (ch >= '0' && ch <= '9') csi.digitRun += 1
191
+ else csi.digitRun = 0
192
+ if (csi.digitRun > MAX_CSI_PARAM_DIGITS) {
193
+ csi.draining = true
194
+ csi.buffer = ''
195
+ } else if (!csi.draining) {
196
+ csi.buffer += ch
197
+ }
198
+ continue
199
+ }
200
+
201
+ const code = ch.charCodeAt(0)
202
+ const final = code >= 0x40 && code <= 0x7e
203
+ if (final && index === payload.length - 1) {
204
+ const completed = csi.draining ? undefined : csi.buffer + ch
205
+ csi = undefined
206
+ return {
207
+ consumed: true,
208
+ ...(completed === undefined ? {} : { event: reportEvent(decodeCsi(completed)) }),
209
+ }
210
+ }
211
+
212
+ csi = undefined
213
+ return { consumed: false }
214
+ }
215
+ return { consumed: true }
216
+ }
217
+
218
+ return {
219
+ cleanup(): void {
220
+ csi = undefined
221
+ paste = undefined
222
+ watch = undefined
223
+ },
224
+ press(text: string | undefined, key: Keypress): TerminalInputEvent | undefined {
225
+ const fragment = key.sequence ?? text ?? ''
226
+ // Interrupt FIRST in every state. The parallel watch never consumes a
227
+ // byte; it only adds an interrupt, including inside bracketed paste. A
228
+ // pasted literal CSI-u Ctrl-C therefore interrupts, matching the existing
229
+ // and deliberate behavior of a literal ETX in a paste.
230
+ if ((key.ctrl === true && key.name === 'c') || key.sequence === '\u0003') {
231
+ csi = undefined
232
+ paste = undefined
233
+ watch = undefined
234
+ return { type: 'interrupt' }
235
+ }
236
+ if (fragment.startsWith('\x1b[')) watch = fragment
237
+ else if (watch !== undefined) watch += fragment
238
+ if (watch !== undefined && watch.length > INTERRUPT_WATCH_MAX) watch = undefined
239
+ if (watch !== undefined && endsCsi(watch)) {
240
+ const completed = watch
241
+ watch = undefined
242
+ if (isInterruptSequence(completed)) {
243
+ csi = undefined
244
+ paste = undefined
245
+ return { type: 'interrupt' }
246
+ }
247
+ }
248
+
249
+ // Inside a bracketed paste every byte is content, so this precedes both
250
+ // reassembly and the keymap. Line endings normalize to `\n` and other
251
+ // control characters are dropped, but the text is never shortened.
252
+ if (paste !== undefined) {
253
+ if (key.name === 'paste-end') {
254
+ const pasted = paste
255
+ .replace(/\r\n?/gu, '\n')
256
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/gu, '')
257
+ paste = undefined
258
+ return { type: 'paste', text: pasted }
259
+ }
260
+ paste += fragment
261
+ return undefined
262
+ }
263
+ if (key.name === 'paste-start') {
264
+ csi = undefined
265
+ paste = ''
266
+ return undefined
267
+ }
268
+
269
+ const restart = fragment.startsWith('\x1b[')
270
+ if (csi !== undefined || (key.name === 'undefined' && restart)) {
271
+ const consumed = consumeCsi(fragment, restart)
272
+ if (consumed.consumed) return consumed.event
273
+ }
274
+
275
+ if (key.name === 'up') return { type: 'up' }
276
+ if (key.name === 'down') return { type: 'down' }
277
+ if (key.name === 'left') return { type: 'left' }
278
+ if (key.name === 'right') return { type: 'right' }
279
+ if (key.name === 'home') return { type: 'home' }
280
+ if (key.name === 'end') return { type: 'end' }
281
+ if (key.name === 'return' || key.sequence === '\r') {
282
+ return key.meta === true ? { type: 'newline' } : { type: 'enter' }
283
+ }
284
+ if (key.name === 'enter' || key.sequence === '\n') return { type: 'newline' }
285
+ if (key.name === 'backspace' || key.sequence === '\b' || key.sequence === '\u007f') {
286
+ return { type: 'backspace' }
287
+ }
288
+ if (key.name === 'escape' || key.sequence === '\u001b') return { type: 'escape' }
289
+
290
+ if (key.ctrl === true || key.meta === true) return undefined
291
+ const printable =
292
+ text ??
293
+ (key.name?.length === 1 ? key.name : key.sequence !== undefined ? key.sequence : undefined)
294
+ if (
295
+ printable === undefined ||
296
+ printable.length === 0 ||
297
+ /[\u0000-\u001f\u007f]/u.test(printable)
298
+ ) {
299
+ return undefined
300
+ }
301
+ return { type: 'text', text: printable }
302
+ },
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Production raw-input adapter. It activates only for a TTY with raw-mode
308
+ * support, disables terminal echo through raw mode, and restores the stream's
309
+ * prior raw/flow state on every idempotent cleanup path.
310
+ */
311
+ export function processTerminalInput(stream: NodeJS.ReadStream = process.stdin): TerminalInput {
312
+ return {
313
+ start(onKey, hooks = {}): () => void {
314
+ if (stream.isTTY !== true || typeof stream.setRawMode !== 'function') {
315
+ return () => {}
316
+ }
317
+
318
+ const priorRaw = stream.isRaw === true
319
+ const priorFlowing = stream.readableFlowing
320
+ let cleaned = false
321
+ // One decoder per activation: its paste and CSI state belongs to this
322
+ // region's lifetime, and an unterminated paste dies with it.
323
+ const decoder = createTerminalInputDecoder(hooks)
324
+ const listener = (text: string | undefined, key: Keypress = {}): void => {
325
+ const normalized = decoder.press(text, key)
326
+ if (normalized !== undefined) onKey(normalized)
327
+ }
328
+
329
+ let listening = false
330
+ const cleanup = (): void => {
331
+ if (cleaned) return
332
+ cleaned = true
333
+ decoder.cleanup()
334
+ if (listening) stream.removeListener('keypress', listener)
335
+ try {
336
+ stream.setRawMode(priorRaw)
337
+ } finally {
338
+ if (priorFlowing === true) stream.resume()
339
+ else stream.pause()
340
+ }
341
+ }
342
+
343
+ try {
344
+ emitKeypressEvents(stream)
345
+ stream.on('keypress', listener)
346
+ listening = true
347
+ stream.setRawMode(true)
348
+ stream.resume()
349
+ hooks.onListening?.()
350
+ return cleanup
351
+ } catch (error) {
352
+ try {
353
+ cleanup()
354
+ } catch {
355
+ // Preserve the activation error; cleanup was best-effort here.
356
+ }
357
+ throw error
358
+ }
359
+ },
360
+ }
361
+ }