@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,872 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { open as openFileAsync } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ startSingleSessionBroker,
8
+ type BrokerAttachmentHandlers,
9
+ type BrokerDataEvent,
10
+ } from '../pty/session-broker.ts';
11
+ import type { PtyExit } from '../pty/pty_host.ts';
12
+ import {
13
+ TerminalSnapshotOracle,
14
+ type TerminalBufferTail,
15
+ type TerminalSnapshotFrame,
16
+ type TerminalQueryState,
17
+ } from '../terminal/snapshot-oracle.ts';
18
+ import { recordPerfEvent } from '../perf/perf-core.ts';
19
+
20
+ interface StartPtySessionOptions {
21
+ command?: string;
22
+ commandArgs?: string[];
23
+ env?: NodeJS.ProcessEnv;
24
+ cwd?: string;
25
+ initialCols?: number;
26
+ initialRows?: number;
27
+ }
28
+
29
+ interface SessionBrokerLike {
30
+ attach(handlers: BrokerAttachmentHandlers, sinceCursor?: number): string;
31
+ detach(attachmentId: string): void;
32
+ latestCursorValue(): number;
33
+ write(data: string | Uint8Array): void;
34
+ resize(cols: number, rows: number): void;
35
+ close(): void;
36
+ processId(): number | null;
37
+ }
38
+
39
+ export type LiveSessionNotifyMode = 'codex' | 'external';
40
+
41
+ interface StartCodexLiveSessionOptions {
42
+ command?: string;
43
+ args?: string[];
44
+ env?: NodeJS.ProcessEnv;
45
+ cwd?: string;
46
+ baseArgs?: string[];
47
+ useNotifyHook?: boolean;
48
+ notifyMode?: LiveSessionNotifyMode;
49
+ notifyFilePath?: string;
50
+ notifyPollMs?: number;
51
+ relayScriptPath?: string;
52
+ maxBacklogBytes?: number;
53
+ initialCols?: number;
54
+ initialRows?: number;
55
+ terminalForegroundHex?: string;
56
+ terminalBackgroundHex?: string;
57
+ enableSnapshotModel?: boolean;
58
+ }
59
+
60
+ interface NotifyRecord {
61
+ ts: string;
62
+ payload: NotifyPayload;
63
+ }
64
+
65
+ interface NotifyPayload {
66
+ [key: string]: unknown;
67
+ }
68
+
69
+ export type CodexLiveEvent =
70
+ | {
71
+ type: 'terminal-output';
72
+ cursor: number;
73
+ chunk: Buffer;
74
+ }
75
+ | {
76
+ type: 'notify';
77
+ record: NotifyRecord;
78
+ }
79
+ | {
80
+ type: 'session-exit';
81
+ exit: PtyExit;
82
+ };
83
+
84
+ interface LiveSessionDependencies {
85
+ startBroker?: (options?: StartPtySessionOptions, maxBacklogBytes?: number) => SessionBrokerLike;
86
+ readFile?: (path: string) => string | Promise<string>;
87
+ setIntervalFn?: (callback: () => void, intervalMs: number) => NodeJS.Timeout;
88
+ clearIntervalFn?: (handle: NodeJS.Timeout) => void;
89
+ }
90
+
91
+ const DEFAULT_COMMAND = 'codex';
92
+ const DEFAULT_BASE_ARGS = ['--no-alt-screen'];
93
+ const DEFAULT_NOTIFY_POLL_MS = 100;
94
+ const DEFAULT_NOTIFY_POLL_MAX_BACKOFF_MS = 2000;
95
+ const DEFAULT_RELAY_SCRIPT_PATH = fileURLToPath(
96
+ new URL('../../scripts/codex-notify-relay.ts', import.meta.url),
97
+ );
98
+ const DEFAULT_TERMINAL_FOREGROUND_HEX = 'd0d7de';
99
+ const DEFAULT_TERMINAL_BACKGROUND_HEX = '0f1419';
100
+ const DEFAULT_INDEXED_TERMINAL_HEX_BY_CODE: Readonly<Record<number, string>> = {
101
+ 0: '0f1419',
102
+ 1: 'f47067',
103
+ 2: '8ccf7e',
104
+ 3: 'e6c07b',
105
+ 4: '6cb6ff',
106
+ 5: 'd38aea',
107
+ 6: '39c5cf',
108
+ 7: 'd0d7de',
109
+ 8: '5c6370',
110
+ 9: 'ff938a',
111
+ 10: 'a4e98c',
112
+ 11: 'f4d399',
113
+ 12: '8bc5ff',
114
+ 13: 'e2a7f3',
115
+ 14: '56d4dd',
116
+ 15: 'f5f7fa',
117
+ };
118
+
119
+ interface TerminalPalette {
120
+ foregroundOsc: string;
121
+ backgroundOsc: string;
122
+ indexedOscByCode: Readonly<Record<number, string>>;
123
+ }
124
+
125
+ export function normalizeTerminalColorHex(value: string | undefined, fallbackHex: string): string {
126
+ if (typeof value !== 'string') {
127
+ return fallbackHex;
128
+ }
129
+
130
+ const normalized = value.trim().replace(/^#/, '');
131
+ if (/^[0-9a-fA-F]{6}$/.test(normalized)) {
132
+ return normalized.toLowerCase();
133
+ }
134
+ return fallbackHex;
135
+ }
136
+
137
+ export function terminalHexToOscColor(hexColor: string): string {
138
+ const normalized = normalizeTerminalColorHex(hexColor, DEFAULT_TERMINAL_FOREGROUND_HEX);
139
+ const red = normalized.slice(0, 2);
140
+ const green = normalized.slice(2, 4);
141
+ const blue = normalized.slice(4, 6);
142
+ return `rgb:${red}${red}/${green}${green}/${blue}${blue}`;
143
+ }
144
+
145
+ function buildTerminalPalette(options: StartCodexLiveSessionOptions): TerminalPalette {
146
+ const fallbackForeground = normalizeTerminalColorHex(
147
+ options.env?.HARNESS_TERM_FG,
148
+ DEFAULT_TERMINAL_FOREGROUND_HEX,
149
+ );
150
+ const fallbackBackground = normalizeTerminalColorHex(
151
+ options.env?.HARNESS_TERM_BG,
152
+ DEFAULT_TERMINAL_BACKGROUND_HEX,
153
+ );
154
+ const foreground = normalizeTerminalColorHex(options.terminalForegroundHex, fallbackForeground);
155
+ const background = normalizeTerminalColorHex(options.terminalBackgroundHex, fallbackBackground);
156
+ const indexedOscByCode: Record<number, string> = {};
157
+ for (const [codeText, defaultHex] of Object.entries(DEFAULT_INDEXED_TERMINAL_HEX_BY_CODE)) {
158
+ const code = Number(codeText);
159
+ indexedOscByCode[code] = terminalHexToOscColor(defaultHex);
160
+ }
161
+ return {
162
+ foregroundOsc: terminalHexToOscColor(foreground),
163
+ backgroundOsc: terminalHexToOscColor(background),
164
+ indexedOscByCode,
165
+ };
166
+ }
167
+
168
+ type TerminalQueryParserMode = 'normal' | 'esc' | 'csi' | 'osc' | 'osc-esc' | 'dcs' | 'dcs-esc';
169
+
170
+ const DEFAULT_DA1_REPLY = '\u001b[?62;4;6;22c';
171
+ const DEFAULT_DA2_REPLY = '\u001b[>1;10;0c';
172
+ const CELL_PIXEL_HEIGHT = 16;
173
+ const CELL_PIXEL_WIDTH = 8;
174
+
175
+ function tsRuntimeArgs(scriptPath: string, args: readonly string[] = []): string[] {
176
+ return [scriptPath, ...args];
177
+ }
178
+
179
+ export function buildTomlStringArray(values: string[]): string {
180
+ const escaped = values.map((value) => {
181
+ const withBackslashEscaped = value.replaceAll('\\', '\\\\');
182
+ const withQuoteEscaped = withBackslashEscaped.replaceAll('"', '\\"');
183
+ return `"${withQuoteEscaped}"`;
184
+ });
185
+ return `[${escaped.join(',')}]`;
186
+ }
187
+
188
+ export function parseNotifyRecordLine(line: string): NotifyRecord | null {
189
+ let parsed: unknown;
190
+ try {
191
+ parsed = JSON.parse(line);
192
+ } catch {
193
+ return null;
194
+ }
195
+ if (typeof parsed !== 'object' || parsed === null) {
196
+ return null;
197
+ }
198
+ const record = parsed as { ts?: unknown; payload?: unknown };
199
+ if (typeof record.ts !== 'string') {
200
+ return null;
201
+ }
202
+ if (
203
+ typeof record.payload !== 'object' ||
204
+ record.payload === null ||
205
+ Array.isArray(record.payload)
206
+ ) {
207
+ return null;
208
+ }
209
+ return {
210
+ ts: record.ts,
211
+ payload: record.payload as NotifyPayload,
212
+ };
213
+ }
214
+
215
+ class TerminalQueryResponder {
216
+ private mode: TerminalQueryParserMode = 'normal';
217
+ private oscPayload = '';
218
+ private csiPayload = '';
219
+ private dcsPayload = '';
220
+ private modifyOtherKeysLevel = 0;
221
+ private kittyKeyboardFlags = 0;
222
+ private readonly palette: TerminalPalette;
223
+ private readonly readQueryState: () => TerminalQueryState;
224
+ private readonly writeReply: (reply: string) => void;
225
+
226
+ constructor(
227
+ palette: TerminalPalette,
228
+ readQueryState: () => TerminalQueryState,
229
+ writeReply: (reply: string) => void,
230
+ ) {
231
+ this.palette = palette;
232
+ this.readQueryState = readQueryState;
233
+ this.writeReply = writeReply;
234
+ }
235
+
236
+ onCsiQuery(payload: string, readState: () => TerminalQueryState): void {
237
+ this.respondToCsiQuery(payload, readState);
238
+ }
239
+
240
+ onOscQuery(payload: string, useBellTerminator: boolean): void {
241
+ this.respondToOscQuery(payload, useBellTerminator);
242
+ }
243
+
244
+ onDcsQuery(payload: string): void {
245
+ this.observeDcsQuery(payload);
246
+ }
247
+
248
+ ingest(chunk: Uint8Array): void {
249
+ const text = Buffer.from(chunk).toString('utf8');
250
+ for (const char of text) {
251
+ this.processChar(char);
252
+ }
253
+ }
254
+
255
+ private processChar(char: string): void {
256
+ if (this.mode === 'normal') {
257
+ if (char === '\u001b') {
258
+ this.mode = 'esc';
259
+ }
260
+ return;
261
+ }
262
+
263
+ if (this.mode === 'esc') {
264
+ if (char === ']') {
265
+ this.mode = 'osc';
266
+ this.oscPayload = '';
267
+ } else if (char === '[') {
268
+ this.mode = 'csi';
269
+ this.csiPayload = '';
270
+ } else if (char === 'P') {
271
+ this.mode = 'dcs';
272
+ this.dcsPayload = '';
273
+ } else {
274
+ this.mode = 'normal';
275
+ }
276
+ return;
277
+ }
278
+
279
+ if (this.mode === 'csi') {
280
+ const code = char.charCodeAt(0);
281
+ if (code >= 0x40 && code <= 0x7e) {
282
+ this.respondToCsiQuery(`${this.csiPayload}${char}`, this.readQueryState);
283
+ this.mode = 'normal';
284
+ this.csiPayload = '';
285
+ return;
286
+ }
287
+ if (char === '\u001b') {
288
+ this.mode = 'esc';
289
+ this.csiPayload = '';
290
+ return;
291
+ }
292
+ this.csiPayload += char;
293
+ return;
294
+ }
295
+
296
+ if (this.mode === 'osc') {
297
+ if (char === '\u0007') {
298
+ this.respondToOscQuery(this.oscPayload, true);
299
+ this.mode = 'normal';
300
+ return;
301
+ }
302
+ if (char === '\u001b') {
303
+ this.mode = 'osc-esc';
304
+ return;
305
+ }
306
+ this.oscPayload += char;
307
+ return;
308
+ }
309
+
310
+ if (this.mode === 'dcs') {
311
+ if (char === '\u001b') {
312
+ this.mode = 'dcs-esc';
313
+ return;
314
+ }
315
+ this.dcsPayload += char;
316
+ return;
317
+ }
318
+
319
+ if (this.mode === 'dcs-esc') {
320
+ if (char === '\\') {
321
+ this.observeDcsQuery(this.dcsPayload);
322
+ this.mode = 'normal';
323
+ this.dcsPayload = '';
324
+ return;
325
+ }
326
+ this.dcsPayload += '\u001b';
327
+ this.dcsPayload += char;
328
+ this.mode = 'dcs';
329
+ return;
330
+ }
331
+
332
+ if (char === '\\') {
333
+ this.respondToOscQuery(this.oscPayload, false);
334
+ this.mode = 'normal';
335
+ return;
336
+ }
337
+
338
+ this.oscPayload += '\u001b';
339
+ this.oscPayload += char;
340
+ this.mode = 'osc';
341
+ }
342
+
343
+ private respondToOscQuery(payload: string, useBellTerminator: boolean): void {
344
+ const trimmedPayload = payload.trim();
345
+ const terminator = useBellTerminator ? '\u0007' : '\u001b\\';
346
+ let handled = false;
347
+
348
+ if (trimmedPayload === '10;?') {
349
+ this.writeReply(`\u001b]10;${this.palette.foregroundOsc}${terminator}`);
350
+ handled = true;
351
+ }
352
+
353
+ if (!handled && trimmedPayload === '11;?') {
354
+ this.writeReply(`\u001b]11;${this.palette.backgroundOsc}${terminator}`);
355
+ handled = true;
356
+ }
357
+
358
+ if (!handled && trimmedPayload.startsWith('4;') && trimmedPayload.endsWith(';?')) {
359
+ const parts = trimmedPayload.split(';');
360
+ if (parts.length === 3) {
361
+ const code = Number.parseInt(parts[1]!, 10);
362
+ if (Number.isInteger(code)) {
363
+ const color = this.palette.indexedOscByCode[code];
364
+ if (typeof color === 'string') {
365
+ this.writeReply(`\u001b]4;${String(code)};${color}${terminator}`);
366
+ handled = true;
367
+ }
368
+ }
369
+ }
370
+ }
371
+ this.recordQueryObservation('osc', trimmedPayload, handled);
372
+ }
373
+
374
+ private respondToCsiQuery(payload: string, readState: () => TerminalQueryState): void {
375
+ let state: TerminalQueryState | null = null;
376
+ const ensureState = (): TerminalQueryState => {
377
+ if (state === null) {
378
+ state = readState();
379
+ }
380
+ return state;
381
+ };
382
+ let handled = false;
383
+
384
+ const modifyOtherKeysQueryMatch = payload.match(/^>4;\?m$/u);
385
+ if (!handled && modifyOtherKeysQueryMatch !== null) {
386
+ this.writeReply(`\u001b[>4;${String(this.modifyOtherKeysLevel)}m`);
387
+ handled = true;
388
+ }
389
+
390
+ if (payload === 'c' || payload === '0c') {
391
+ this.writeReply(DEFAULT_DA1_REPLY);
392
+ handled = true;
393
+ }
394
+
395
+ if (!handled && (payload === '>c' || payload === '>0c')) {
396
+ this.writeReply(DEFAULT_DA2_REPLY);
397
+ handled = true;
398
+ }
399
+
400
+ if (!handled && payload === '5n') {
401
+ this.writeReply('\u001b[0n');
402
+ handled = true;
403
+ }
404
+
405
+ if (!handled && payload === '6n') {
406
+ const queryState = ensureState();
407
+ const row = Math.max(1, Math.floor(queryState.cursor.row + 1));
408
+ const col = Math.max(1, Math.floor(queryState.cursor.col + 1));
409
+ this.writeReply(`\u001b[${String(row)};${String(col)}R`);
410
+ handled = true;
411
+ }
412
+
413
+ if (!handled && payload === '14t') {
414
+ const queryState = ensureState();
415
+ const pixelHeight = Math.max(1, queryState.rows * CELL_PIXEL_HEIGHT);
416
+ const pixelWidth = Math.max(1, queryState.cols * CELL_PIXEL_WIDTH);
417
+ this.writeReply(`\u001b[4;${String(pixelHeight)};${String(pixelWidth)}t`);
418
+ handled = true;
419
+ }
420
+
421
+ if (!handled && payload === '16t') {
422
+ this.writeReply(`\u001b[6;${String(CELL_PIXEL_HEIGHT)};${String(CELL_PIXEL_WIDTH)}t`);
423
+ handled = true;
424
+ }
425
+
426
+ if (!handled && payload === '18t') {
427
+ const queryState = ensureState();
428
+ this.writeReply(`\u001b[8;${String(queryState.rows)};${String(queryState.cols)}t`);
429
+ handled = true;
430
+ }
431
+
432
+ if (!handled && payload === '?u') {
433
+ this.writeReply(`\u001b[?${String(this.kittyKeyboardFlags)}u`);
434
+ handled = true;
435
+ }
436
+
437
+ if (!handled) {
438
+ this.observeCsiCommand(payload);
439
+ }
440
+ this.recordQueryObservation('csi', payload, handled);
441
+ }
442
+
443
+ private observeDcsQuery(payload: string): void {
444
+ const trimmedPayload = payload.trim();
445
+ this.recordQueryObservation('dcs', trimmedPayload, false);
446
+ }
447
+
448
+ private observeCsiCommand(payload: string): void {
449
+ const kittyKeyboardMatch = payload.match(/^>(\d+)u$/u);
450
+ if (kittyKeyboardMatch !== null) {
451
+ const nextFlags = Number.parseInt(kittyKeyboardMatch[1]!, 10);
452
+ if (Number.isFinite(nextFlags)) {
453
+ this.kittyKeyboardFlags = Math.max(0, nextFlags);
454
+ }
455
+ return;
456
+ }
457
+
458
+ const modifyOtherKeysMatch = payload.match(/^>4(?:;(\d+))?m$/u);
459
+ if (modifyOtherKeysMatch !== null) {
460
+ const rawLevel = modifyOtherKeysMatch[1];
461
+ if (rawLevel === undefined) {
462
+ this.modifyOtherKeysLevel = 0;
463
+ return;
464
+ }
465
+
466
+ const parsedLevel = Number.parseInt(rawLevel, 10);
467
+ if (!Number.isFinite(parsedLevel)) {
468
+ return;
469
+ }
470
+
471
+ this.modifyOtherKeysLevel = Math.max(0, Math.min(2, parsedLevel));
472
+ }
473
+ }
474
+
475
+ private recordQueryObservation(
476
+ kind: 'csi' | 'osc' | 'dcs',
477
+ payload: string,
478
+ handled: boolean,
479
+ ): void {
480
+ if (kind === 'csi' && !this.isLikelyCsiQueryPayload(payload)) {
481
+ return;
482
+ }
483
+ if (kind === 'osc' && !payload.includes('?')) {
484
+ return;
485
+ }
486
+ recordPerfEvent('codex.terminal-query', {
487
+ kind,
488
+ payload: payload.slice(0, 120),
489
+ handled,
490
+ });
491
+ }
492
+
493
+ private isLikelyCsiQueryPayload(payload: string): boolean {
494
+ if (/^(?:c|0c|>c|>0c)$/.test(payload)) {
495
+ return true;
496
+ }
497
+ if (/^[0-9]*n$/.test(payload)) {
498
+ return true;
499
+ }
500
+ if (/^(?:14|16|18)t$/.test(payload)) {
501
+ return true;
502
+ }
503
+ if (/^>0q$/.test(payload)) {
504
+ return true;
505
+ }
506
+ if (/^>4;\?m$/.test(payload)) {
507
+ return true;
508
+ }
509
+ if (/^\?[0-9;]*\$p$/.test(payload)) {
510
+ return true;
511
+ }
512
+ if (payload === '?u') {
513
+ return true;
514
+ }
515
+ return false;
516
+ }
517
+ }
518
+
519
+ class CodexLiveSession {
520
+ private readonly broker: SessionBrokerLike;
521
+ private readonly readFile: ((path: string) => string | Promise<string>) | null;
522
+ private readonly clearIntervalFn: (handle: NodeJS.Timeout) => void;
523
+ private readonly notifyFilePath: string;
524
+ private readonly listeners = new Set<(event: CodexLiveEvent) => void>();
525
+ private readonly snapshotOracle: TerminalSnapshotOracle;
526
+ private readonly snapshotModelEnabled: boolean;
527
+ private readonly terminalQueryResponder: TerminalQueryResponder;
528
+ private readonly brokerAttachmentId: string;
529
+ private readonly notifyTimer: NodeJS.Timeout | null;
530
+ private readonly notifyPollMs: number;
531
+ private notifyPollInFlight = false;
532
+ private notifyIdleStreak = 0;
533
+ private notifyNextAllowedPollAtMs = 0;
534
+ private notifyOffset = 0;
535
+ private notifyRemainder = '';
536
+ private closed = false;
537
+
538
+ constructor(
539
+ options: StartCodexLiveSessionOptions = {},
540
+ dependencies: LiveSessionDependencies = {},
541
+ ) {
542
+ const initialCols = options.initialCols ?? 80;
543
+ const initialRows = options.initialRows ?? 24;
544
+ this.snapshotModelEnabled = options.enableSnapshotModel ?? true;
545
+ this.snapshotOracle = new TerminalSnapshotOracle(initialCols, initialRows);
546
+
547
+ const command = options.command ?? DEFAULT_COMMAND;
548
+ const useNotifyHook = options.useNotifyHook ?? false;
549
+ this.notifyPollMs = Math.max(25, options.notifyPollMs ?? DEFAULT_NOTIFY_POLL_MS);
550
+ const notifyMode = options.notifyMode ?? 'codex';
551
+ this.notifyFilePath =
552
+ options.notifyFilePath ??
553
+ join(tmpdir(), `harness-codex-notify-${process.pid}-${randomUUID()}.jsonl`);
554
+ const relayScriptPath = resolve(options.relayScriptPath ?? DEFAULT_RELAY_SCRIPT_PATH);
555
+ const notifyCommand = [
556
+ '/usr/bin/env',
557
+ process.execPath,
558
+ ...tsRuntimeArgs(relayScriptPath, [this.notifyFilePath]),
559
+ ];
560
+ const shouldInjectCodexNotifyConfig = useNotifyHook && notifyMode === 'codex';
561
+ const commandArgs = [
562
+ ...(options.baseArgs ?? DEFAULT_BASE_ARGS),
563
+ ...(shouldInjectCodexNotifyConfig
564
+ ? ['-c', `notify=${buildTomlStringArray(notifyCommand)}`]
565
+ : []),
566
+ ...(options.args ?? []),
567
+ ];
568
+
569
+ const startBroker = dependencies.startBroker ?? startSingleSessionBroker;
570
+ this.readFile = dependencies.readFile ?? null;
571
+ const setIntervalFn = dependencies.setIntervalFn ?? setInterval;
572
+ this.clearIntervalFn = dependencies.clearIntervalFn ?? clearInterval;
573
+
574
+ const startOptions: StartPtySessionOptions = {
575
+ command,
576
+ commandArgs,
577
+ initialCols,
578
+ initialRows,
579
+ };
580
+ if (options.env !== undefined) {
581
+ startOptions.env = options.env;
582
+ }
583
+ if (options.cwd !== undefined) {
584
+ startOptions.cwd = options.cwd;
585
+ }
586
+
587
+ this.broker = startBroker(startOptions, options.maxBacklogBytes);
588
+ this.terminalQueryResponder = new TerminalQueryResponder(
589
+ buildTerminalPalette(options),
590
+ () => this.snapshotOracle.queryState(),
591
+ (reply) => {
592
+ this.broker.write(reply);
593
+ },
594
+ );
595
+ if (this.snapshotModelEnabled) {
596
+ this.snapshotOracle.setQueryHooks({
597
+ onCsiQuery: (payload, readState) => {
598
+ this.terminalQueryResponder.onCsiQuery(payload, readState);
599
+ },
600
+ onOscQuery: (payload, useBellTerminator) => {
601
+ this.terminalQueryResponder.onOscQuery(payload, useBellTerminator);
602
+ },
603
+ onDcsQuery: (payload) => {
604
+ this.terminalQueryResponder.onDcsQuery(payload);
605
+ },
606
+ });
607
+ }
608
+
609
+ this.brokerAttachmentId = this.broker.attach({
610
+ onData: (event: BrokerDataEvent) => {
611
+ if (this.snapshotModelEnabled) {
612
+ this.snapshotOracle.ingest(event.chunk);
613
+ } else {
614
+ this.terminalQueryResponder.ingest(event.chunk);
615
+ }
616
+ this.emit({
617
+ type: 'terminal-output',
618
+ cursor: event.cursor,
619
+ chunk: Buffer.from(event.chunk),
620
+ });
621
+ },
622
+ onExit: (exit: PtyExit) => {
623
+ this.emit({
624
+ type: 'session-exit',
625
+ exit,
626
+ });
627
+ },
628
+ });
629
+
630
+ if (useNotifyHook) {
631
+ this.notifyTimer = setIntervalFn(() => {
632
+ this.pollNotifyFile();
633
+ }, this.notifyPollMs);
634
+ this.notifyTimer.unref?.();
635
+ } else {
636
+ this.notifyTimer = null;
637
+ }
638
+ }
639
+
640
+ onEvent(listener: (event: CodexLiveEvent) => void): () => void {
641
+ this.listeners.add(listener);
642
+ return () => {
643
+ this.listeners.delete(listener);
644
+ };
645
+ }
646
+
647
+ attach(handlers: BrokerAttachmentHandlers, sinceCursor = 0): string {
648
+ return this.broker.attach(handlers, sinceCursor);
649
+ }
650
+
651
+ detach(attachmentId: string): void {
652
+ this.broker.detach(attachmentId);
653
+ }
654
+
655
+ latestCursorValue(): number {
656
+ return this.broker.latestCursorValue();
657
+ }
658
+
659
+ processId(): number | null {
660
+ return this.broker.processId();
661
+ }
662
+
663
+ write(data: string | Uint8Array): void {
664
+ this.broker.write(data);
665
+ }
666
+
667
+ resize(cols: number, rows: number): void {
668
+ this.broker.resize(cols, rows);
669
+ this.snapshotOracle.resize(cols, rows);
670
+ }
671
+
672
+ scrollViewport(deltaRows: number): void {
673
+ this.snapshotOracle.scrollViewport(deltaRows);
674
+ }
675
+
676
+ setFollowOutput(followOutput: boolean): void {
677
+ this.snapshotOracle.setFollowOutput(followOutput);
678
+ }
679
+
680
+ snapshot(): TerminalSnapshotFrame {
681
+ return this.snapshotOracle.snapshot();
682
+ }
683
+
684
+ bufferTail(tailLines?: number): TerminalBufferTail {
685
+ return this.snapshotOracle.bufferTail(tailLines);
686
+ }
687
+
688
+ close(): void {
689
+ if (this.closed) {
690
+ return;
691
+ }
692
+
693
+ this.closed = true;
694
+ if (this.notifyTimer !== null) {
695
+ this.clearIntervalFn(this.notifyTimer);
696
+ }
697
+ this.broker.detach(this.brokerAttachmentId);
698
+ this.broker.close();
699
+ }
700
+
701
+ private pollNotifyFile(): void {
702
+ const nowMs = Date.now();
703
+ if (this.notifyPollInFlight || this.closed || nowMs < this.notifyNextAllowedPollAtMs) {
704
+ return;
705
+ }
706
+ this.notifyPollInFlight = true;
707
+ if (this.readFile !== null) {
708
+ let contentOrPromise: string | Promise<string>;
709
+ try {
710
+ contentOrPromise = this.readFile(this.notifyFilePath);
711
+ } catch (error: unknown) {
712
+ this.notifyPollInFlight = false;
713
+ this.handleNotifyReadError(error);
714
+ return;
715
+ }
716
+ void Promise.resolve(contentOrPromise)
717
+ .then((content) => {
718
+ const consumedNewBytes = this.consumeNotifyFileContent(content);
719
+ this.scheduleNextNotifyPoll(consumedNewBytes);
720
+ })
721
+ .catch((error: unknown) => {
722
+ this.handleNotifyReadError(error);
723
+ this.scheduleNextNotifyPoll(false);
724
+ })
725
+ .finally(() => {
726
+ this.notifyPollInFlight = false;
727
+ });
728
+ return;
729
+ }
730
+
731
+ void this.readNotifyDeltaFromFile()
732
+ .then((delta) => {
733
+ const consumedNewBytes = this.consumeNotifyDelta(delta);
734
+ this.scheduleNextNotifyPoll(consumedNewBytes);
735
+ })
736
+ .catch((error: unknown) => {
737
+ this.handleNotifyReadError(error);
738
+ this.scheduleNextNotifyPoll(false);
739
+ })
740
+ .finally(() => {
741
+ this.notifyPollInFlight = false;
742
+ });
743
+ }
744
+
745
+ private scheduleNextNotifyPoll(consumedNewBytes: boolean): void {
746
+ if (consumedNewBytes) {
747
+ this.notifyIdleStreak = 0;
748
+ this.notifyNextAllowedPollAtMs = Date.now() + this.notifyPollMs;
749
+ return;
750
+ }
751
+ this.notifyIdleStreak = Math.min(this.notifyIdleStreak + 1, 4);
752
+ const backoffMs = Math.min(
753
+ DEFAULT_NOTIFY_POLL_MAX_BACKOFF_MS,
754
+ this.notifyPollMs * (1 << this.notifyIdleStreak),
755
+ );
756
+ this.notifyNextAllowedPollAtMs = Date.now() + backoffMs;
757
+ }
758
+
759
+ private handleNotifyReadError(error: unknown): void {
760
+ const withCode = error as { code?: unknown };
761
+ if (withCode.code === 'ENOENT') {
762
+ return;
763
+ }
764
+ const code =
765
+ typeof withCode.code === 'string' || typeof withCode.code === 'number'
766
+ ? String(withCode.code)
767
+ : '';
768
+ recordPerfEvent('codex.notify.poll.error', {
769
+ code,
770
+ message: error instanceof Error ? error.message : String(error),
771
+ });
772
+ }
773
+
774
+ private consumeNotifyFileContent(content: string): boolean {
775
+ if (content.length < this.notifyOffset) {
776
+ this.notifyOffset = 0;
777
+ this.notifyRemainder = '';
778
+ }
779
+ const delta = content.slice(this.notifyOffset);
780
+ if (delta.length === 0) {
781
+ return false;
782
+ }
783
+ this.notifyOffset = content.length;
784
+ return this.consumeNotifyDelta(delta);
785
+ }
786
+
787
+ private async readNotifyDeltaFromFile(): Promise<string> {
788
+ let handle: Awaited<ReturnType<typeof openFileAsync>>;
789
+ try {
790
+ handle = await openFileAsync(this.notifyFilePath, 'r');
791
+ } catch (error: unknown) {
792
+ const withCode = error as { code?: unknown };
793
+ if (withCode.code === 'ENOENT') {
794
+ return '';
795
+ }
796
+ throw error;
797
+ }
798
+ try {
799
+ const stats = await handle.stat();
800
+ const fileSize = Number(stats.size);
801
+ if (!Number.isFinite(fileSize)) {
802
+ return '';
803
+ }
804
+ if (fileSize < this.notifyOffset) {
805
+ this.notifyOffset = 0;
806
+ this.notifyRemainder = '';
807
+ }
808
+ const remainingBytes = fileSize - this.notifyOffset;
809
+ if (remainingBytes <= 0) {
810
+ return '';
811
+ }
812
+ const buffer = Buffer.allocUnsafe(remainingBytes);
813
+ let bytesReadTotal = 0;
814
+ while (bytesReadTotal < remainingBytes) {
815
+ const { bytesRead } = await handle.read(
816
+ buffer,
817
+ bytesReadTotal,
818
+ remainingBytes - bytesReadTotal,
819
+ this.notifyOffset + bytesReadTotal,
820
+ );
821
+ if (bytesRead <= 0) {
822
+ break;
823
+ }
824
+ bytesReadTotal += bytesRead;
825
+ }
826
+ if (bytesReadTotal <= 0) {
827
+ return '';
828
+ }
829
+ this.notifyOffset += bytesReadTotal;
830
+ return buffer.toString('utf8', 0, bytesReadTotal);
831
+ } finally {
832
+ await handle.close();
833
+ }
834
+ }
835
+
836
+ private consumeNotifyDelta(delta: string): boolean {
837
+ if (delta.length === 0) {
838
+ return false;
839
+ }
840
+ const buffered = `${this.notifyRemainder}${delta}`;
841
+ const lines = buffered.split('\n');
842
+ this.notifyRemainder = lines.pop() as string;
843
+ for (const line of lines) {
844
+ const trimmed = line.trim();
845
+ if (trimmed.length === 0) {
846
+ continue;
847
+ }
848
+ const record = parseNotifyRecordLine(trimmed);
849
+ if (record === null) {
850
+ continue;
851
+ }
852
+ this.emit({
853
+ type: 'notify',
854
+ record,
855
+ });
856
+ }
857
+ return true;
858
+ }
859
+
860
+ private emit(event: CodexLiveEvent): void {
861
+ for (const listener of this.listeners) {
862
+ listener(event);
863
+ }
864
+ }
865
+ }
866
+
867
+ export function startCodexLiveSession(
868
+ options: StartCodexLiveSessionOptions = {},
869
+ dependencies: LiveSessionDependencies = {},
870
+ ): CodexLiveSession {
871
+ return new CodexLiveSession(options, dependencies);
872
+ }