@jmoyers/harness 0.1.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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/native/ptyd/Cargo.lock +16 -0
  4. package/native/ptyd/Cargo.toml +7 -0
  5. package/native/ptyd/src/main.rs +257 -0
  6. package/package.json +90 -0
  7. package/scripts/build-ptyd.sh +73 -0
  8. package/scripts/control-plane-daemon.ts +277 -0
  9. package/scripts/cursor-hook-relay.ts +82 -0
  10. package/scripts/harness-animate.ts +469 -0
  11. package/scripts/harness-bin.js +77 -0
  12. package/scripts/harness-core.ts +1 -0
  13. package/scripts/harness-inspector.ts +439 -0
  14. package/scripts/harness.ts +2493 -0
  15. package/src/adapters/agent-session-state.ts +390 -0
  16. package/src/cli/gateway-record.ts +173 -0
  17. package/src/codex/live-session.ts +872 -0
  18. package/src/config/config-core.ts +1359 -0
  19. package/src/config/secrets-core.ts +170 -0
  20. package/src/control-plane/agent-realtime-api.ts +2441 -0
  21. package/src/control-plane/codex-session-stream.ts +392 -0
  22. package/src/control-plane/codex-telemetry.ts +1325 -0
  23. package/src/control-plane/lifecycle-hooks.ts +706 -0
  24. package/src/control-plane/session-summary.ts +380 -0
  25. package/src/control-plane/status/agent-status-reducer.ts +21 -0
  26. package/src/control-plane/status/reducer-base.ts +170 -0
  27. package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
  28. package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
  29. package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
  30. package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
  31. package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
  32. package/src/control-plane/status/session-status-engine.ts +76 -0
  33. package/src/control-plane/stream-client.ts +396 -0
  34. package/src/control-plane/stream-command-parser.ts +1673 -0
  35. package/src/control-plane/stream-protocol.ts +1808 -0
  36. package/src/control-plane/stream-server-background.ts +486 -0
  37. package/src/control-plane/stream-server-command.ts +2557 -0
  38. package/src/control-plane/stream-server-connection.ts +234 -0
  39. package/src/control-plane/stream-server-observed-filter.ts +112 -0
  40. package/src/control-plane/stream-server-session-runtime.ts +566 -0
  41. package/src/control-plane/stream-server-state-store.ts +15 -0
  42. package/src/control-plane/stream-server.ts +3192 -0
  43. package/src/cursor/managed-hooks.ts +282 -0
  44. package/src/domain/conversations.ts +414 -0
  45. package/src/domain/directories.ts +78 -0
  46. package/src/domain/repositories.ts +123 -0
  47. package/src/domain/tasks.ts +148 -0
  48. package/src/domain/workspace.ts +156 -0
  49. package/src/events/normalized-events.ts +124 -0
  50. package/src/mux/ansi-integrity.ts +103 -0
  51. package/src/mux/control-plane-op-queue.ts +212 -0
  52. package/src/mux/conversation-rail.ts +339 -0
  53. package/src/mux/double-click.ts +78 -0
  54. package/src/mux/dual-pane-core.ts +435 -0
  55. package/src/mux/harness-core-ui.ts +817 -0
  56. package/src/mux/input-shortcuts.ts +667 -0
  57. package/src/mux/live-mux/actions-conversation.ts +344 -0
  58. package/src/mux/live-mux/actions-repository.ts +246 -0
  59. package/src/mux/live-mux/actions-task.ts +115 -0
  60. package/src/mux/live-mux/args.ts +142 -0
  61. package/src/mux/live-mux/command-menu.ts +298 -0
  62. package/src/mux/live-mux/control-plane-records.ts +546 -0
  63. package/src/mux/live-mux/conversation-state.ts +188 -0
  64. package/src/mux/live-mux/directory-resolution.ts +34 -0
  65. package/src/mux/live-mux/event-mapping.ts +96 -0
  66. package/src/mux/live-mux/gateway-profiler.ts +152 -0
  67. package/src/mux/live-mux/gateway-render-trace.ts +177 -0
  68. package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
  69. package/src/mux/live-mux/git-parsing.ts +131 -0
  70. package/src/mux/live-mux/git-snapshot.ts +263 -0
  71. package/src/mux/live-mux/git-state.ts +136 -0
  72. package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
  73. package/src/mux/live-mux/home-pane-actions.ts +58 -0
  74. package/src/mux/live-mux/home-pane-drop.ts +44 -0
  75. package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
  76. package/src/mux/live-mux/home-pane-pointer.ts +96 -0
  77. package/src/mux/live-mux/input-forwarding.ts +112 -0
  78. package/src/mux/live-mux/layout.ts +30 -0
  79. package/src/mux/live-mux/left-nav-activation.ts +103 -0
  80. package/src/mux/live-mux/left-nav.ts +85 -0
  81. package/src/mux/live-mux/left-rail-actions.ts +118 -0
  82. package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
  83. package/src/mux/live-mux/left-rail-pointer.ts +74 -0
  84. package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
  85. package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
  86. package/src/mux/live-mux/modal-input-reducers.ts +94 -0
  87. package/src/mux/live-mux/modal-overlays.ts +287 -0
  88. package/src/mux/live-mux/modal-pointer.ts +70 -0
  89. package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
  90. package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
  91. package/src/mux/live-mux/observed-stream.ts +87 -0
  92. package/src/mux/live-mux/palette-parsing.ts +128 -0
  93. package/src/mux/live-mux/pointer-routing.ts +108 -0
  94. package/src/mux/live-mux/process-usage.ts +53 -0
  95. package/src/mux/live-mux/project-pane-pointer.ts +44 -0
  96. package/src/mux/live-mux/rail-layout.ts +244 -0
  97. package/src/mux/live-mux/render-trace-analysis.ts +213 -0
  98. package/src/mux/live-mux/render-trace-state.ts +84 -0
  99. package/src/mux/live-mux/repository-folding.ts +207 -0
  100. package/src/mux/live-mux/runtime-shutdown.ts +51 -0
  101. package/src/mux/live-mux/selection.ts +411 -0
  102. package/src/mux/live-mux/startup-utils.ts +187 -0
  103. package/src/mux/live-mux/status-timeline-state.ts +82 -0
  104. package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
  105. package/src/mux/live-mux/terminal-palette.ts +79 -0
  106. package/src/mux/new-thread-prompt.ts +165 -0
  107. package/src/mux/project-tree.ts +295 -0
  108. package/src/mux/render-frame.ts +113 -0
  109. package/src/mux/runtime-wiring.ts +185 -0
  110. package/src/mux/selector-index.ts +160 -0
  111. package/src/mux/startup-sequencer.ts +238 -0
  112. package/src/mux/task-composer.ts +289 -0
  113. package/src/mux/task-focused-pane.ts +417 -0
  114. package/src/mux/task-screen-keybindings.ts +539 -0
  115. package/src/mux/terminal-input-modes.ts +35 -0
  116. package/src/mux/workspace-path.ts +55 -0
  117. package/src/mux/workspace-rail-model.ts +701 -0
  118. package/src/mux/workspace-rail.ts +247 -0
  119. package/src/perf/perf-core.ts +307 -0
  120. package/src/pty/pty_host.ts +217 -0
  121. package/src/pty/session-broker.ts +158 -0
  122. package/src/recording/terminal-recording.ts +383 -0
  123. package/src/services/control-plane.ts +567 -0
  124. package/src/services/conversation-lifecycle.ts +176 -0
  125. package/src/services/conversation-startup-hydration.ts +47 -0
  126. package/src/services/directory-hydration.ts +49 -0
  127. package/src/services/event-persistence.ts +104 -0
  128. package/src/services/mux-ui-state-persistence.ts +82 -0
  129. package/src/services/output-load-sampler.ts +231 -0
  130. package/src/services/process-usage-refresh.ts +88 -0
  131. package/src/services/recording.ts +75 -0
  132. package/src/services/render-trace-recorder.ts +177 -0
  133. package/src/services/runtime-control-actions.ts +123 -0
  134. package/src/services/runtime-control-plane-ops.ts +131 -0
  135. package/src/services/runtime-conversation-actions.ts +113 -0
  136. package/src/services/runtime-conversation-activation.ts +78 -0
  137. package/src/services/runtime-conversation-starter.ts +171 -0
  138. package/src/services/runtime-conversation-title-edit.ts +149 -0
  139. package/src/services/runtime-directory-actions.ts +164 -0
  140. package/src/services/runtime-envelope-handler.ts +198 -0
  141. package/src/services/runtime-git-state.ts +92 -0
  142. package/src/services/runtime-input-pipeline.ts +50 -0
  143. package/src/services/runtime-input-router.ts +202 -0
  144. package/src/services/runtime-layout-resize.ts +236 -0
  145. package/src/services/runtime-left-rail-render.ts +159 -0
  146. package/src/services/runtime-main-pane-input.ts +230 -0
  147. package/src/services/runtime-modal-input.ts +119 -0
  148. package/src/services/runtime-navigation-input.ts +207 -0
  149. package/src/services/runtime-process-wiring.ts +68 -0
  150. package/src/services/runtime-rail-input.ts +287 -0
  151. package/src/services/runtime-render-flush.ts +146 -0
  152. package/src/services/runtime-render-lifecycle.ts +104 -0
  153. package/src/services/runtime-render-orchestrator.ts +108 -0
  154. package/src/services/runtime-render-pipeline.ts +167 -0
  155. package/src/services/runtime-render-state.ts +72 -0
  156. package/src/services/runtime-repository-actions.ts +197 -0
  157. package/src/services/runtime-right-pane-render.ts +132 -0
  158. package/src/services/runtime-shutdown.ts +79 -0
  159. package/src/services/runtime-stream-subscriptions.ts +56 -0
  160. package/src/services/runtime-task-composer-persistence.ts +139 -0
  161. package/src/services/runtime-task-editor-actions.ts +83 -0
  162. package/src/services/runtime-task-pane-actions.ts +198 -0
  163. package/src/services/runtime-task-pane-shortcuts.ts +189 -0
  164. package/src/services/runtime-task-pane.ts +62 -0
  165. package/src/services/runtime-workspace-actions.ts +153 -0
  166. package/src/services/runtime-workspace-observed-events.ts +190 -0
  167. package/src/services/session-projection-instrumentation.ts +190 -0
  168. package/src/services/startup-background-probe.ts +91 -0
  169. package/src/services/startup-background-resume.ts +65 -0
  170. package/src/services/startup-orchestrator.ts +166 -0
  171. package/src/services/startup-output-tracker.ts +54 -0
  172. package/src/services/startup-paint-tracker.ts +115 -0
  173. package/src/services/startup-persisted-conversation-queue.ts +45 -0
  174. package/src/services/startup-settled-gate.ts +67 -0
  175. package/src/services/startup-shutdown.ts +53 -0
  176. package/src/services/startup-span-tracker.ts +77 -0
  177. package/src/services/startup-state-hydration.ts +94 -0
  178. package/src/services/startup-visibility.ts +35 -0
  179. package/src/services/status-timeline-recorder.ts +144 -0
  180. package/src/services/task-pane-selection-actions.ts +153 -0
  181. package/src/services/task-planning-hydration.ts +58 -0
  182. package/src/services/task-planning-observed-events.ts +89 -0
  183. package/src/services/workspace-observed-events.ts +113 -0
  184. package/src/store/control-plane-store-normalize.ts +760 -0
  185. package/src/store/control-plane-store-types.ts +224 -0
  186. package/src/store/control-plane-store.ts +2951 -0
  187. package/src/store/event-store.ts +253 -0
  188. package/src/store/sqlite.ts +81 -0
  189. package/src/terminal/compat-matrix.ts +345 -0
  190. package/src/terminal/differential-checkpoints.ts +132 -0
  191. package/src/terminal/parity-suite.ts +441 -0
  192. package/src/terminal/snapshot-oracle.ts +1840 -0
  193. package/src/ui/conversation-input-forwarder.ts +114 -0
  194. package/src/ui/conversation-selection-input.ts +103 -0
  195. package/src/ui/debug-footer-notice.ts +39 -0
  196. package/src/ui/global-shortcut-input.ts +126 -0
  197. package/src/ui/input-preflight.ts +68 -0
  198. package/src/ui/input-token-router.ts +312 -0
  199. package/src/ui/input.ts +238 -0
  200. package/src/ui/kit.ts +509 -0
  201. package/src/ui/left-nav-input.ts +80 -0
  202. package/src/ui/left-rail-pointer-input.ts +148 -0
  203. package/src/ui/main-pane-pointer-input.ts +150 -0
  204. package/src/ui/modals/manager.ts +192 -0
  205. package/src/ui/mux-theme.ts +529 -0
  206. package/src/ui/panes/conversation.ts +19 -0
  207. package/src/ui/panes/home-gridfire.ts +302 -0
  208. package/src/ui/panes/home.ts +109 -0
  209. package/src/ui/panes/left-rail.ts +12 -0
  210. package/src/ui/panes/project.ts +44 -0
  211. package/src/ui/pointer-routing-input.ts +158 -0
  212. package/src/ui/repository-fold-input.ts +91 -0
  213. package/src/ui/screen.ts +210 -0
  214. package/src/ui/surface.ts +224 -0
@@ -0,0 +1,383 @@
1
+ import { closeSync, createWriteStream, openSync, readSync, type WriteStream } from 'node:fs';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { TextDecoder } from 'node:util';
4
+ import type { TerminalSnapshotFrame } from '../terminal/snapshot-oracle.ts';
5
+
6
+ interface TerminalRecordingHeader {
7
+ schemaVersion: '1';
8
+ source: string;
9
+ createdAt: string;
10
+ defaultForegroundHex: string;
11
+ defaultBackgroundHex: string;
12
+ ansiPaletteIndexedHex?: Record<string, string>;
13
+ }
14
+
15
+ interface TerminalRecordingFrameSample {
16
+ atMs: number;
17
+ frame: TerminalSnapshotFrame;
18
+ }
19
+
20
+ interface TerminalRecording {
21
+ header: TerminalRecordingHeader;
22
+ frames: TerminalRecordingFrameSample[];
23
+ finishedAtMs: number | null;
24
+ }
25
+
26
+ interface HeaderLineRecord {
27
+ kind: 'header';
28
+ header: TerminalRecordingHeader;
29
+ }
30
+
31
+ interface FrameLineRecord {
32
+ kind: 'frame';
33
+ atMs: number;
34
+ frame: TerminalSnapshotFrame;
35
+ }
36
+
37
+ interface FooterLineRecord {
38
+ kind: 'footer';
39
+ finishedAtMs: number;
40
+ }
41
+
42
+ type RecordingLineRecord = HeaderLineRecord | FrameLineRecord | FooterLineRecord;
43
+
44
+ interface RecordingWriteStream {
45
+ write(chunk: string): boolean;
46
+ end(callback?: () => void): void;
47
+ once(event: 'error', listener: (error: Error) => void): this;
48
+ once(event: 'finish', listener: () => void): this;
49
+ }
50
+
51
+ interface CreateTerminalRecordingWriterOptions {
52
+ filePath: string;
53
+ source: string;
54
+ defaultForegroundHex: string;
55
+ defaultBackgroundHex: string;
56
+ ansiPaletteIndexedHex?: Readonly<Record<number, string>>;
57
+ minFrameIntervalMs?: number;
58
+ nowMs?: () => number;
59
+ nowIso?: () => string;
60
+ createStream?: (path: string) => RecordingWriteStream;
61
+ }
62
+
63
+ interface TerminalRecordingWriter {
64
+ capture(frame: TerminalSnapshotFrame): boolean;
65
+ close(): Promise<void>;
66
+ }
67
+
68
+ function isRecord(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === 'object' && value !== null;
70
+ }
71
+
72
+ function normalizeHex6(value: string, fallback: string): string {
73
+ const normalized = value.trim().replace(/^#/, '').toLowerCase();
74
+ if (/^[0-9a-f]{6}$/.test(normalized)) {
75
+ return normalized;
76
+ }
77
+ return fallback;
78
+ }
79
+
80
+ function parseOptionalAnsiPaletteIndexedHex(value: unknown): Record<string, string> | undefined {
81
+ if (value === undefined) {
82
+ return undefined;
83
+ }
84
+ if (!isRecord(value)) {
85
+ return undefined;
86
+ }
87
+
88
+ const normalized: Record<string, string> = {};
89
+ for (const [key, entryValue] of Object.entries(value)) {
90
+ const parsedKey = Number.parseInt(key, 10);
91
+ if (!Number.isInteger(parsedKey) || parsedKey < 0 || parsedKey > 255) {
92
+ continue;
93
+ }
94
+ if (typeof entryValue !== 'string') {
95
+ continue;
96
+ }
97
+ normalized[String(parsedKey)] = normalizeHex6(entryValue, '');
98
+ }
99
+
100
+ if (Object.keys(normalized).length === 0) {
101
+ return undefined;
102
+ }
103
+
104
+ return normalized;
105
+ }
106
+
107
+ function parseHeader(value: unknown): TerminalRecordingHeader {
108
+ if (!isRecord(value)) {
109
+ throw new Error('recording header is not an object');
110
+ }
111
+
112
+ if (value['schemaVersion'] !== '1') {
113
+ throw new Error('recording header schemaVersion must be "1"');
114
+ }
115
+
116
+ const source = value['source'];
117
+ const createdAt = value['createdAt'];
118
+ const defaultForegroundHex = value['defaultForegroundHex'];
119
+ const defaultBackgroundHex = value['defaultBackgroundHex'];
120
+ const ansiPaletteIndexedHex = parseOptionalAnsiPaletteIndexedHex(value['ansiPaletteIndexedHex']);
121
+ if (
122
+ typeof source !== 'string' ||
123
+ source.length === 0 ||
124
+ typeof createdAt !== 'string' ||
125
+ createdAt.length === 0 ||
126
+ typeof defaultForegroundHex !== 'string' ||
127
+ typeof defaultBackgroundHex !== 'string'
128
+ ) {
129
+ throw new Error('recording header is missing required fields');
130
+ }
131
+
132
+ return {
133
+ schemaVersion: '1',
134
+ source,
135
+ createdAt,
136
+ defaultForegroundHex: normalizeHex6(defaultForegroundHex, 'd0d7de'),
137
+ defaultBackgroundHex: normalizeHex6(defaultBackgroundHex, '0f1419'),
138
+ ...(ansiPaletteIndexedHex !== undefined
139
+ ? {
140
+ ansiPaletteIndexedHex,
141
+ }
142
+ : {}),
143
+ };
144
+ }
145
+
146
+ function parseFrame(value: unknown): TerminalSnapshotFrame {
147
+ if (!isRecord(value)) {
148
+ throw new Error('recording frame is not an object');
149
+ }
150
+
151
+ const rows = value['rows'];
152
+ const cols = value['cols'];
153
+ const lines = value['lines'];
154
+ const richLines = value['richLines'];
155
+ if (
156
+ typeof rows !== 'number' ||
157
+ !Number.isInteger(rows) ||
158
+ rows <= 0 ||
159
+ typeof cols !== 'number' ||
160
+ !Number.isInteger(cols) ||
161
+ cols <= 0 ||
162
+ !Array.isArray(lines) ||
163
+ !Array.isArray(richLines)
164
+ ) {
165
+ throw new Error('recording frame shape is invalid');
166
+ }
167
+
168
+ return value as unknown as TerminalSnapshotFrame;
169
+ }
170
+
171
+ function parseLineRecord(value: unknown): RecordingLineRecord {
172
+ if (!isRecord(value)) {
173
+ throw new Error('recording line is not an object');
174
+ }
175
+
176
+ const kind = value['kind'];
177
+ if (kind === 'header') {
178
+ return {
179
+ kind: 'header',
180
+ header: parseHeader(value['header']),
181
+ };
182
+ }
183
+ if (kind === 'frame') {
184
+ const atMs = value['atMs'];
185
+ if (typeof atMs !== 'number' || !Number.isFinite(atMs) || atMs < 0) {
186
+ throw new Error('recording frame atMs must be a non-negative number');
187
+ }
188
+ return {
189
+ kind: 'frame',
190
+ atMs,
191
+ frame: parseFrame(value['frame']),
192
+ };
193
+ }
194
+ if (kind === 'footer') {
195
+ const finishedAtMs = value['finishedAtMs'];
196
+ if (typeof finishedAtMs !== 'number' || !Number.isFinite(finishedAtMs) || finishedAtMs < 0) {
197
+ throw new Error('recording footer finishedAtMs must be a non-negative number');
198
+ }
199
+ return {
200
+ kind: 'footer',
201
+ finishedAtMs,
202
+ };
203
+ }
204
+
205
+ throw new Error('recording line kind is invalid');
206
+ }
207
+
208
+ export function readTerminalRecording(filePath: string): TerminalRecording {
209
+ const CHUNK_BYTES = 64 * 1024;
210
+ const fd = openSync(filePath, 'r');
211
+ const decoder = new TextDecoder();
212
+ const readBuffer = Buffer.allocUnsafe(CHUNK_BYTES);
213
+ let remainder = '';
214
+ let sawNonEmptyLine = false;
215
+ let header: TerminalRecordingHeader | null = null;
216
+ const frames: TerminalRecordingFrameSample[] = [];
217
+ let finishedAtMs: number | null = null;
218
+
219
+ const consumeLine = (line: string): void => {
220
+ if (line.trim().length === 0) {
221
+ return;
222
+ }
223
+ sawNonEmptyLine = true;
224
+ const parsedJson = JSON.parse(line) as unknown;
225
+ const parsedLine = parseLineRecord(parsedJson);
226
+ if (header === null) {
227
+ if (parsedLine.kind !== 'header') {
228
+ throw new Error('recording file must start with a header line');
229
+ }
230
+ header = parsedLine.header;
231
+ return;
232
+ }
233
+ if (parsedLine.kind === 'frame') {
234
+ frames.push({
235
+ atMs: parsedLine.atMs,
236
+ frame: parsedLine.frame,
237
+ });
238
+ return;
239
+ }
240
+ if (parsedLine.kind === 'footer') {
241
+ finishedAtMs = parsedLine.finishedAtMs;
242
+ return;
243
+ }
244
+ throw new Error('recording file contains a non-frame line after header');
245
+ };
246
+
247
+ try {
248
+ while (true) {
249
+ const bytesRead = readSync(fd, readBuffer, 0, CHUNK_BYTES, null);
250
+ if (bytesRead <= 0) {
251
+ break;
252
+ }
253
+ const decodedChunk = decoder.decode(readBuffer.subarray(0, bytesRead), {
254
+ stream: true,
255
+ });
256
+ let text = remainder + decodedChunk;
257
+ let newlineIndex = text.indexOf('\n');
258
+ while (newlineIndex !== -1) {
259
+ consumeLine(text.slice(0, newlineIndex));
260
+ text = text.slice(newlineIndex + 1);
261
+ newlineIndex = text.indexOf('\n');
262
+ }
263
+ remainder = text;
264
+ }
265
+
266
+ remainder += decoder.decode();
267
+ if (remainder.length > 0) {
268
+ consumeLine(remainder);
269
+ }
270
+ } finally {
271
+ closeSync(fd);
272
+ }
273
+
274
+ if (!sawNonEmptyLine) {
275
+ throw new Error('recording file is empty');
276
+ }
277
+
278
+ return {
279
+ header: header!,
280
+ frames,
281
+ finishedAtMs,
282
+ };
283
+ }
284
+
285
+ function writeLine(stream: RecordingWriteStream, line: RecordingLineRecord): void {
286
+ stream.write(`${JSON.stringify(line)}\n`);
287
+ }
288
+
289
+ export function createTerminalRecordingWriter(
290
+ options: CreateTerminalRecordingWriterOptions,
291
+ ): TerminalRecordingWriter {
292
+ const minFrameIntervalMs = Math.max(0, Math.floor(options.minFrameIntervalMs ?? 0));
293
+ const nowMs = options.nowMs ?? (() => performance.now());
294
+ const nowIso = options.nowIso ?? (() => new Date().toISOString());
295
+ const createStream =
296
+ options.createStream ?? ((filePath: string): WriteStream => createWriteStream(filePath));
297
+ const stream = createStream(options.filePath);
298
+ const startedAtMs = nowMs();
299
+ const ansiPaletteIndexedHex = (() => {
300
+ const sourcePalette = options.ansiPaletteIndexedHex;
301
+ if (sourcePalette === undefined) {
302
+ return undefined;
303
+ }
304
+ const normalized: Record<string, string> = {};
305
+ for (const [key, entryValue] of Object.entries(sourcePalette)) {
306
+ const parsedKey = Number.parseInt(key, 10);
307
+ if (!Number.isInteger(parsedKey) || parsedKey < 0 || parsedKey > 255) {
308
+ continue;
309
+ }
310
+ normalized[String(parsedKey)] = normalizeHex6(entryValue, '');
311
+ }
312
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
313
+ })();
314
+
315
+ const header: TerminalRecordingHeader = {
316
+ schemaVersion: '1',
317
+ source: options.source,
318
+ createdAt: nowIso(),
319
+ defaultForegroundHex: normalizeHex6(options.defaultForegroundHex, 'd0d7de'),
320
+ defaultBackgroundHex: normalizeHex6(options.defaultBackgroundHex, '0f1419'),
321
+ ...(ansiPaletteIndexedHex !== undefined
322
+ ? {
323
+ ansiPaletteIndexedHex,
324
+ }
325
+ : {}),
326
+ };
327
+ writeLine(stream, {
328
+ kind: 'header',
329
+ header,
330
+ });
331
+
332
+ let closed = false;
333
+ let fatalError: Error | null = null;
334
+ let lastRecordedAtMs: number | null = null;
335
+ let lastFrameHash: string | null = null;
336
+
337
+ stream.once('error', (error: Error) => {
338
+ fatalError = error;
339
+ });
340
+
341
+ return {
342
+ capture(frame): boolean {
343
+ if (closed || fatalError !== null) {
344
+ return false;
345
+ }
346
+
347
+ const atMs = Math.max(0, nowMs() - startedAtMs);
348
+ if (lastFrameHash === frame.frameHash) {
349
+ return false;
350
+ }
351
+ if (lastRecordedAtMs !== null && atMs - lastRecordedAtMs < minFrameIntervalMs) {
352
+ return false;
353
+ }
354
+
355
+ writeLine(stream, {
356
+ kind: 'frame',
357
+ atMs,
358
+ frame,
359
+ });
360
+ lastRecordedAtMs = atMs;
361
+ lastFrameHash = frame.frameHash;
362
+ return true;
363
+ },
364
+ async close(): Promise<void> {
365
+ if (closed) {
366
+ return;
367
+ }
368
+ closed = true;
369
+ writeLine(stream, {
370
+ kind: 'footer',
371
+ finishedAtMs: Math.max(0, nowMs() - startedAtMs),
372
+ });
373
+ await new Promise<void>((resolve, reject) => {
374
+ stream.once('finish', resolve);
375
+ stream.once('error', reject);
376
+ stream.end();
377
+ });
378
+ if (fatalError !== null) {
379
+ throw fatalError;
380
+ }
381
+ },
382
+ };
383
+ }