@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,2493 @@
1
+ import { once } from 'node:events';
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { execFileSync, spawn } from 'node:child_process';
12
+ import { createServer } from 'node:net';
13
+ import { dirname, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { setTimeout as delay } from 'node:timers/promises';
17
+ import { connectControlPlaneStreamClient } from '../src/control-plane/stream-client.ts';
18
+ import { parseStreamCommand } from '../src/control-plane/stream-command-parser.ts';
19
+ import type { StreamCommand } from '../src/control-plane/stream-protocol.ts';
20
+ import { runHarnessAnimate } from './harness-animate.ts';
21
+ import {
22
+ GATEWAY_RECORD_VERSION,
23
+ DEFAULT_GATEWAY_DB_PATH,
24
+ isLoopbackHost,
25
+ normalizeGatewayHost,
26
+ normalizeGatewayPort,
27
+ normalizeGatewayStateDbPath,
28
+ parseGatewayRecordText,
29
+ resolveGatewayLogPath,
30
+ resolveGatewayRecordPath,
31
+ resolveInvocationDirectory,
32
+ serializeGatewayRecord,
33
+ type GatewayRecord,
34
+ } from '../src/cli/gateway-record.ts';
35
+ import { loadHarnessConfig } from '../src/config/config-core.ts';
36
+ import { loadHarnessSecrets } from '../src/config/secrets-core.ts';
37
+ import {
38
+ buildCursorManagedHookRelayCommand,
39
+ ensureManagedCursorHooksInstalled,
40
+ uninstallManagedCursorHooks,
41
+ } from '../src/cursor/managed-hooks.ts';
42
+ import {
43
+ buildInspectorProfileStartExpression,
44
+ buildInspectorProfileStopExpression,
45
+ connectGatewayInspector,
46
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
47
+ evaluateInspectorExpression,
48
+ InspectorWebSocketClient,
49
+ type InspectorProfileState,
50
+ readInspectorProfileState,
51
+ } from './harness-inspector.ts';
52
+ import {
53
+ parseActiveStatusTimelineState,
54
+ resolveDefaultStatusTimelineOutputPath,
55
+ resolveStatusTimelineStatePath,
56
+ STATUS_TIMELINE_MODE,
57
+ STATUS_TIMELINE_STATE_VERSION,
58
+ } from '../src/mux/live-mux/status-timeline-state.ts';
59
+ import {
60
+ parseActiveRenderTraceState,
61
+ resolveDefaultRenderTraceOutputPath,
62
+ resolveRenderTraceStatePath,
63
+ RENDER_TRACE_MODE,
64
+ RENDER_TRACE_STATE_VERSION,
65
+ } from '../src/mux/live-mux/render-trace-state.ts';
66
+
67
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
68
+ const DEFAULT_DAEMON_SCRIPT_PATH = resolve(SCRIPT_DIR, 'control-plane-daemon.ts');
69
+ const DEFAULT_MUX_SCRIPT_PATH = resolve(SCRIPT_DIR, 'harness-core.ts');
70
+ const DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH = resolve(SCRIPT_DIR, 'cursor-hook-relay.ts');
71
+ const DEFAULT_GATEWAY_START_RETRY_WINDOW_MS = 6000;
72
+ const DEFAULT_GATEWAY_START_RETRY_DELAY_MS = 40;
73
+ const DEFAULT_GATEWAY_STOP_TIMEOUT_MS = 5000;
74
+ const DEFAULT_GATEWAY_STOP_POLL_MS = 50;
75
+ const DEFAULT_PROFILE_ROOT_PATH = '.harness/profiles';
76
+ const DEFAULT_SESSION_ROOT_PATH = '.harness/sessions';
77
+ const PROFILE_STATE_FILE_NAME = 'active-profile.json';
78
+ const PROFILE_CLIENT_FILE_NAME = 'client.cpuprofile';
79
+ const PROFILE_GATEWAY_FILE_NAME = 'gateway.cpuprofile';
80
+ const PROFILE_STATE_VERSION = 2;
81
+ const PROFILE_LIVE_INSPECT_MODE = 'live-inspector';
82
+ const SESSION_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
83
+
84
+ interface GatewayStartOptions {
85
+ host?: string;
86
+ port?: number;
87
+ authToken?: string;
88
+ stateDbPath?: string;
89
+ }
90
+
91
+ interface GatewayStopOptions {
92
+ force: boolean;
93
+ timeoutMs: number;
94
+ cleanupOrphans: boolean;
95
+ }
96
+
97
+ interface ParsedGatewayCommand {
98
+ type: 'start' | 'stop' | 'status' | 'restart' | 'run' | 'call';
99
+ startOptions?: GatewayStartOptions;
100
+ stopOptions?: GatewayStopOptions;
101
+ callJson?: string;
102
+ }
103
+
104
+ interface ParsedProfileRunCommand {
105
+ type: 'run';
106
+ profileDir: string | null;
107
+ muxArgs: readonly string[];
108
+ }
109
+
110
+ interface ParsedProfileStartCommand {
111
+ type: 'start';
112
+ profileDir: string | null;
113
+ }
114
+
115
+ interface ProfileStopOptions {
116
+ timeoutMs: number;
117
+ }
118
+
119
+ interface ParsedProfileStopCommand {
120
+ type: 'stop';
121
+ stopOptions: ProfileStopOptions;
122
+ }
123
+
124
+ type ParsedProfileCommand =
125
+ | ParsedProfileRunCommand
126
+ | ParsedProfileStartCommand
127
+ | ParsedProfileStopCommand;
128
+
129
+ interface ParsedStatusTimelineStartCommand {
130
+ type: 'start';
131
+ outputPath: string | null;
132
+ }
133
+
134
+ interface ParsedStatusTimelineStopCommand {
135
+ type: 'stop';
136
+ }
137
+
138
+ type ParsedStatusTimelineCommand =
139
+ | ParsedStatusTimelineStartCommand
140
+ | ParsedStatusTimelineStopCommand;
141
+
142
+ interface ParsedRenderTraceStartCommand {
143
+ type: 'start';
144
+ outputPath: string | null;
145
+ conversationId: string | null;
146
+ }
147
+
148
+ interface ParsedRenderTraceStopCommand {
149
+ type: 'stop';
150
+ }
151
+
152
+ type ParsedRenderTraceCommand = ParsedRenderTraceStartCommand | ParsedRenderTraceStopCommand;
153
+
154
+ interface ParsedCursorHooksCommand {
155
+ type: 'install' | 'uninstall';
156
+ hooksFilePath: string | null;
157
+ }
158
+
159
+ interface RuntimeCpuProfileOptions {
160
+ cpuProfileDir: string;
161
+ cpuProfileName: string;
162
+ }
163
+
164
+ interface RuntimeInspectOptions {
165
+ readonly gatewayRuntimeArgs: readonly string[];
166
+ readonly clientRuntimeArgs: readonly string[];
167
+ }
168
+
169
+ interface SessionPaths {
170
+ recordPath: string;
171
+ logPath: string;
172
+ defaultStateDbPath: string;
173
+ profileDir: string;
174
+ profileStatePath: string;
175
+ statusTimelineStatePath: string;
176
+ defaultStatusTimelineOutputPath: string;
177
+ renderTraceStatePath: string;
178
+ defaultRenderTraceOutputPath: string;
179
+ }
180
+
181
+ interface ParsedGlobalCliOptions {
182
+ sessionName: string | null;
183
+ argv: readonly string[];
184
+ }
185
+
186
+ interface ResolvedGatewaySettings {
187
+ host: string;
188
+ port: number;
189
+ authToken: string | null;
190
+ stateDbPath: string;
191
+ }
192
+
193
+ interface GatewayProbeResult {
194
+ connected: boolean;
195
+ sessionCount: number;
196
+ liveSessionCount: number;
197
+ error: string | null;
198
+ }
199
+
200
+ interface EnsureGatewayResult {
201
+ record: GatewayRecord;
202
+ started: boolean;
203
+ }
204
+
205
+ interface ProcessTableEntry {
206
+ pid: number;
207
+ ppid: number;
208
+ command: string;
209
+ }
210
+
211
+ interface OrphanProcessCleanupResult {
212
+ matchedPids: readonly number[];
213
+ terminatedPids: readonly number[];
214
+ failedPids: readonly number[];
215
+ errorMessage: string | null;
216
+ }
217
+
218
+ interface ActiveProfileState {
219
+ version: number;
220
+ mode: typeof PROFILE_LIVE_INSPECT_MODE;
221
+ pid: number;
222
+ host: string;
223
+ port: number;
224
+ stateDbPath: string;
225
+ profileDir: string;
226
+ gatewayProfilePath: string;
227
+ inspectWebSocketUrl: string;
228
+ startedAt: string;
229
+ }
230
+
231
+ interface ActiveStatusTimelineState {
232
+ version: number;
233
+ mode: typeof STATUS_TIMELINE_MODE;
234
+ outputPath: string;
235
+ sessionName: string | null;
236
+ startedAt: string;
237
+ }
238
+
239
+ interface ActiveRenderTraceState {
240
+ version: number;
241
+ mode: typeof RENDER_TRACE_MODE;
242
+ outputPath: string;
243
+ sessionName: string | null;
244
+ conversationId: string | null;
245
+ startedAt: string;
246
+ }
247
+
248
+ function normalizeSignalExitCode(signal: NodeJS.Signals | null): number {
249
+ if (signal === null) {
250
+ return 1;
251
+ }
252
+ if (signal === 'SIGINT') {
253
+ return 130;
254
+ }
255
+ if (signal === 'SIGTERM') {
256
+ return 143;
257
+ }
258
+ return 1;
259
+ }
260
+
261
+ function tsRuntimeArgs(
262
+ scriptPath: string,
263
+ args: readonly string[] = [],
264
+ runtimeArgs: readonly string[] = [],
265
+ ): string[] {
266
+ return [...runtimeArgs, scriptPath, ...args];
267
+ }
268
+
269
+ function readCliValue(argv: readonly string[], index: number, flag: string): string {
270
+ const value = argv[index + 1];
271
+ if (value === undefined) {
272
+ throw new Error(`missing value for ${flag}`);
273
+ }
274
+ return value;
275
+ }
276
+
277
+ function parsePortFlag(value: string, flag: string): number {
278
+ const parsed = Number.parseInt(value, 10);
279
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
280
+ throw new Error(`invalid ${flag} value: ${value}`);
281
+ }
282
+ return parsed;
283
+ }
284
+
285
+ function parsePositiveIntFlag(value: string, flag: string): number {
286
+ const parsed = Number.parseInt(value, 10);
287
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
288
+ throw new Error(`invalid ${flag} value: ${value}`);
289
+ }
290
+ return parsed;
291
+ }
292
+
293
+ function parseSessionName(rawValue: string): string {
294
+ const trimmed = rawValue.trim();
295
+ if (!SESSION_NAME_PATTERN.test(trimmed)) {
296
+ throw new Error(`invalid --session value: ${rawValue}`);
297
+ }
298
+ return trimmed;
299
+ }
300
+
301
+ function parseGlobalCliOptions(argv: readonly string[]): ParsedGlobalCliOptions {
302
+ if (argv[0] !== '--session') {
303
+ return {
304
+ sessionName: null,
305
+ argv,
306
+ };
307
+ }
308
+ const sessionName = parseSessionName(readCliValue(argv, 0, '--session'));
309
+ return {
310
+ sessionName,
311
+ argv: argv.slice(2),
312
+ };
313
+ }
314
+
315
+ function resolveSessionPaths(
316
+ invocationDirectory: string,
317
+ sessionName: string | null,
318
+ ): SessionPaths {
319
+ const statusTimelineStatePath = resolveStatusTimelineStatePath(invocationDirectory, sessionName);
320
+ const defaultStatusTimelineOutputPath = resolveDefaultStatusTimelineOutputPath(
321
+ invocationDirectory,
322
+ sessionName,
323
+ );
324
+ const renderTraceStatePath = resolveRenderTraceStatePath(invocationDirectory, sessionName);
325
+ const defaultRenderTraceOutputPath = resolveDefaultRenderTraceOutputPath(
326
+ invocationDirectory,
327
+ sessionName,
328
+ );
329
+ if (sessionName === null) {
330
+ return {
331
+ recordPath: resolveGatewayRecordPath(invocationDirectory),
332
+ logPath: resolveGatewayLogPath(invocationDirectory),
333
+ defaultStateDbPath: resolve(invocationDirectory, DEFAULT_GATEWAY_DB_PATH),
334
+ profileDir: resolve(invocationDirectory, DEFAULT_PROFILE_ROOT_PATH),
335
+ profileStatePath: resolve(invocationDirectory, '.harness', PROFILE_STATE_FILE_NAME),
336
+ statusTimelineStatePath,
337
+ defaultStatusTimelineOutputPath,
338
+ renderTraceStatePath,
339
+ defaultRenderTraceOutputPath,
340
+ };
341
+ }
342
+ const sessionRoot = resolve(invocationDirectory, DEFAULT_SESSION_ROOT_PATH, sessionName);
343
+ return {
344
+ recordPath: resolve(sessionRoot, 'gateway.json'),
345
+ logPath: resolve(sessionRoot, 'gateway.log'),
346
+ defaultStateDbPath: resolve(sessionRoot, 'control-plane.sqlite'),
347
+ profileDir: resolve(invocationDirectory, DEFAULT_PROFILE_ROOT_PATH, sessionName),
348
+ profileStatePath: resolve(sessionRoot, PROFILE_STATE_FILE_NAME),
349
+ statusTimelineStatePath,
350
+ defaultStatusTimelineOutputPath,
351
+ renderTraceStatePath,
352
+ defaultRenderTraceOutputPath,
353
+ };
354
+ }
355
+
356
+ function parseProfileRunCommand(argv: readonly string[]): ParsedProfileRunCommand {
357
+ let profileDir: string | null = null;
358
+ const muxArgs: string[] = [];
359
+ for (let index = 0; index < argv.length; index += 1) {
360
+ const arg = argv[index]!;
361
+ if (arg === '--profile-dir') {
362
+ profileDir = readCliValue(argv, index, '--profile-dir');
363
+ index += 1;
364
+ continue;
365
+ }
366
+ muxArgs.push(arg);
367
+ }
368
+ return {
369
+ type: 'run',
370
+ profileDir,
371
+ muxArgs,
372
+ };
373
+ }
374
+
375
+ function parseProfileStartCommand(argv: readonly string[]): ParsedProfileStartCommand {
376
+ let profileDir: string | null = null;
377
+ for (let index = 0; index < argv.length; index += 1) {
378
+ const arg = argv[index]!;
379
+ if (arg === '--profile-dir') {
380
+ profileDir = readCliValue(argv, index, '--profile-dir');
381
+ index += 1;
382
+ continue;
383
+ }
384
+ throw new Error(`unknown profile option: ${arg}`);
385
+ }
386
+ return {
387
+ type: 'start',
388
+ profileDir,
389
+ };
390
+ }
391
+
392
+ function parseProfileStopOptions(argv: readonly string[]): ProfileStopOptions {
393
+ const options: ProfileStopOptions = {
394
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
395
+ };
396
+ for (let index = 0; index < argv.length; index += 1) {
397
+ const arg = argv[index]!;
398
+ if (arg === '--timeout-ms') {
399
+ options.timeoutMs = parsePositiveIntFlag(
400
+ readCliValue(argv, index, '--timeout-ms'),
401
+ '--timeout-ms',
402
+ );
403
+ index += 1;
404
+ continue;
405
+ }
406
+ throw new Error(`unknown profile option: ${arg}`);
407
+ }
408
+ return options;
409
+ }
410
+
411
+ function parseProfileStopCommand(argv: readonly string[]): ParsedProfileStopCommand {
412
+ return {
413
+ type: 'stop',
414
+ stopOptions: parseProfileStopOptions(argv),
415
+ };
416
+ }
417
+
418
+ function parseProfileCommand(argv: readonly string[]): ParsedProfileCommand {
419
+ if (argv.length === 0) {
420
+ return parseProfileRunCommand(argv);
421
+ }
422
+ const subcommand = argv[0]!;
423
+ const rest = argv.slice(1);
424
+ if (subcommand === 'start') {
425
+ return parseProfileStartCommand(rest);
426
+ }
427
+ if (subcommand === 'stop') {
428
+ return parseProfileStopCommand(rest);
429
+ }
430
+ if (subcommand === 'run') {
431
+ return parseProfileRunCommand(rest);
432
+ }
433
+ if (subcommand.startsWith('-')) {
434
+ return parseProfileRunCommand(argv);
435
+ }
436
+ return parseProfileRunCommand(argv);
437
+ }
438
+
439
+ function parseStatusTimelineStartCommand(
440
+ argv: readonly string[],
441
+ ): ParsedStatusTimelineStartCommand {
442
+ let outputPath: string | null = null;
443
+ for (let index = 0; index < argv.length; index += 1) {
444
+ const arg = argv[index]!;
445
+ if (arg === '--output-path') {
446
+ outputPath = readCliValue(argv, index, '--output-path');
447
+ index += 1;
448
+ continue;
449
+ }
450
+ throw new Error(`unknown status-timeline option: ${arg}`);
451
+ }
452
+ return {
453
+ type: 'start',
454
+ outputPath,
455
+ };
456
+ }
457
+
458
+ function parseStatusTimelineStopCommand(argv: readonly string[]): ParsedStatusTimelineStopCommand {
459
+ if (argv.length > 0) {
460
+ throw new Error(`unknown status-timeline option: ${argv[0]}`);
461
+ }
462
+ return {
463
+ type: 'stop',
464
+ };
465
+ }
466
+
467
+ function parseStatusTimelineCommand(argv: readonly string[]): ParsedStatusTimelineCommand {
468
+ if (argv.length === 0) {
469
+ return parseStatusTimelineStartCommand(argv);
470
+ }
471
+ const subcommand = argv[0]!;
472
+ const rest = argv.slice(1);
473
+ if (subcommand === 'start') {
474
+ return parseStatusTimelineStartCommand(rest);
475
+ }
476
+ if (subcommand === 'stop') {
477
+ return parseStatusTimelineStopCommand(rest);
478
+ }
479
+ if (subcommand.startsWith('-')) {
480
+ return parseStatusTimelineStartCommand(argv);
481
+ }
482
+ throw new Error(`unknown status-timeline subcommand: ${subcommand}`);
483
+ }
484
+
485
+ function parseRenderTraceStartCommand(argv: readonly string[]): ParsedRenderTraceStartCommand {
486
+ let outputPath: string | null = null;
487
+ let conversationId: string | null = null;
488
+ for (let index = 0; index < argv.length; index += 1) {
489
+ const arg = argv[index]!;
490
+ if (arg === '--output-path') {
491
+ outputPath = readCliValue(argv, index, '--output-path');
492
+ index += 1;
493
+ continue;
494
+ }
495
+ if (arg === '--conversation-id') {
496
+ const value = readCliValue(argv, index, '--conversation-id').trim();
497
+ if (value.length === 0) {
498
+ throw new Error('invalid --conversation-id value: empty string');
499
+ }
500
+ conversationId = value;
501
+ index += 1;
502
+ continue;
503
+ }
504
+ throw new Error(`unknown render-trace option: ${arg}`);
505
+ }
506
+ return {
507
+ type: 'start',
508
+ outputPath,
509
+ conversationId,
510
+ };
511
+ }
512
+
513
+ function parseRenderTraceStopCommand(argv: readonly string[]): ParsedRenderTraceStopCommand {
514
+ if (argv.length > 0) {
515
+ throw new Error(`unknown render-trace option: ${argv[0]}`);
516
+ }
517
+ return {
518
+ type: 'stop',
519
+ };
520
+ }
521
+
522
+ function parseRenderTraceCommand(argv: readonly string[]): ParsedRenderTraceCommand {
523
+ if (argv.length === 0) {
524
+ return parseRenderTraceStartCommand(argv);
525
+ }
526
+ const subcommand = argv[0]!;
527
+ const rest = argv.slice(1);
528
+ if (subcommand === 'start') {
529
+ return parseRenderTraceStartCommand(rest);
530
+ }
531
+ if (subcommand === 'stop') {
532
+ return parseRenderTraceStopCommand(rest);
533
+ }
534
+ if (subcommand.startsWith('-')) {
535
+ return parseRenderTraceStartCommand(argv);
536
+ }
537
+ throw new Error(`unknown render-trace subcommand: ${subcommand}`);
538
+ }
539
+
540
+ function parseCursorHooksOptions(argv: readonly string[]): { hooksFilePath: string | null } {
541
+ const options = {
542
+ hooksFilePath: null as string | null,
543
+ };
544
+ for (let index = 0; index < argv.length; index += 1) {
545
+ const arg = argv[index]!;
546
+ if (arg === '--hooks-file') {
547
+ options.hooksFilePath = readCliValue(argv, index, '--hooks-file');
548
+ index += 1;
549
+ continue;
550
+ }
551
+ throw new Error(`unknown cursor-hooks option: ${arg}`);
552
+ }
553
+ return options;
554
+ }
555
+
556
+ function parseCursorHooksCommand(argv: readonly string[]): ParsedCursorHooksCommand {
557
+ if (argv.length === 0) {
558
+ throw new Error('missing cursor-hooks subcommand');
559
+ }
560
+ const subcommand = argv[0]!;
561
+ const options = parseCursorHooksOptions(argv.slice(1));
562
+ if (subcommand === 'install') {
563
+ return {
564
+ type: 'install',
565
+ hooksFilePath: options.hooksFilePath,
566
+ };
567
+ }
568
+ if (subcommand === 'uninstall') {
569
+ return {
570
+ type: 'uninstall',
571
+ hooksFilePath: options.hooksFilePath,
572
+ };
573
+ }
574
+ throw new Error(`unknown cursor-hooks subcommand: ${subcommand}`);
575
+ }
576
+
577
+ function buildCpuProfileRuntimeArgs(options: RuntimeCpuProfileOptions): readonly string[] {
578
+ return [
579
+ '--cpu-prof',
580
+ '--cpu-prof-dir',
581
+ options.cpuProfileDir,
582
+ '--cpu-prof-name',
583
+ options.cpuProfileName,
584
+ ];
585
+ }
586
+
587
+ function resolveInspectRuntimeOptions(invocationDirectory: string): RuntimeInspectOptions {
588
+ const loadedConfig = loadHarnessConfig({ cwd: invocationDirectory });
589
+ const debugConfig = loadedConfig.config.debug;
590
+ if (!debugConfig.enabled || !debugConfig.inspect.enabled) {
591
+ return {
592
+ gatewayRuntimeArgs: [],
593
+ clientRuntimeArgs: [],
594
+ };
595
+ }
596
+ return {
597
+ gatewayRuntimeArgs: [
598
+ `--inspect=localhost:${String(debugConfig.inspect.gatewayPort)}/harness-gateway`,
599
+ ],
600
+ clientRuntimeArgs: [
601
+ `--inspect=localhost:${String(debugConfig.inspect.clientPort)}/harness-client`,
602
+ ],
603
+ };
604
+ }
605
+
606
+ function removeFileIfExists(filePath: string): void {
607
+ try {
608
+ unlinkSync(filePath);
609
+ } catch (error: unknown) {
610
+ const code = (error as NodeJS.ErrnoException).code;
611
+ if (code !== 'ENOENT') {
612
+ throw error;
613
+ }
614
+ }
615
+ }
616
+
617
+ async function reservePort(host: string): Promise<number> {
618
+ return await new Promise<number>((resolvePort, reject) => {
619
+ const server = createServer();
620
+ server.unref();
621
+ server.once('error', reject);
622
+ server.listen(0, host, () => {
623
+ const address = server.address();
624
+ if (address === null || typeof address === 'string') {
625
+ server.close(() => {
626
+ reject(new Error('failed to reserve local port'));
627
+ });
628
+ return;
629
+ }
630
+ const port = address.port;
631
+ server.close((error) => {
632
+ if (error !== undefined) {
633
+ reject(error);
634
+ return;
635
+ }
636
+ resolvePort(port);
637
+ });
638
+ });
639
+ });
640
+ }
641
+
642
+ function parseGatewayStartOptions(argv: readonly string[]): GatewayStartOptions {
643
+ const options: GatewayStartOptions = {};
644
+ for (let index = 0; index < argv.length; index += 1) {
645
+ const arg = argv[index]!;
646
+ if (arg === '--host') {
647
+ options.host = readCliValue(argv, index, '--host');
648
+ index += 1;
649
+ continue;
650
+ }
651
+ if (arg === '--port') {
652
+ options.port = parsePortFlag(readCliValue(argv, index, '--port'), '--port');
653
+ index += 1;
654
+ continue;
655
+ }
656
+ if (arg === '--auth-token') {
657
+ options.authToken = readCliValue(argv, index, '--auth-token');
658
+ index += 1;
659
+ continue;
660
+ }
661
+ if (arg === '--state-db-path') {
662
+ options.stateDbPath = readCliValue(argv, index, '--state-db-path');
663
+ index += 1;
664
+ continue;
665
+ }
666
+ throw new Error(`unknown gateway option: ${arg}`);
667
+ }
668
+ return options;
669
+ }
670
+
671
+ function parseGatewayStopOptions(argv: readonly string[]): GatewayStopOptions {
672
+ const options: GatewayStopOptions = {
673
+ force: false,
674
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
675
+ cleanupOrphans: true,
676
+ };
677
+ for (let index = 0; index < argv.length; index += 1) {
678
+ const arg = argv[index]!;
679
+ if (arg === '--force') {
680
+ options.force = true;
681
+ continue;
682
+ }
683
+ if (arg === '--cleanup-orphans') {
684
+ options.cleanupOrphans = true;
685
+ continue;
686
+ }
687
+ if (arg === '--no-cleanup-orphans') {
688
+ options.cleanupOrphans = false;
689
+ continue;
690
+ }
691
+ if (arg === '--timeout-ms') {
692
+ options.timeoutMs = parsePositiveIntFlag(
693
+ readCliValue(argv, index, '--timeout-ms'),
694
+ '--timeout-ms',
695
+ );
696
+ index += 1;
697
+ continue;
698
+ }
699
+ throw new Error(`unknown gateway option: ${arg}`);
700
+ }
701
+ return options;
702
+ }
703
+
704
+ function parseGatewayCallOptions(argv: readonly string[]): { json: string } {
705
+ let json: string | null = null;
706
+ for (let index = 0; index < argv.length; index += 1) {
707
+ const arg = argv[index]!;
708
+ if (arg === '--json') {
709
+ json = readCliValue(argv, index, '--json');
710
+ index += 1;
711
+ continue;
712
+ }
713
+ if (json === null) {
714
+ json = arg;
715
+ continue;
716
+ }
717
+ throw new Error(`unknown gateway option: ${arg}`);
718
+ }
719
+ if (json === null) {
720
+ throw new Error(
721
+ 'missing command json; use `harness gateway call --json \'{"type":"session.list"}\'`',
722
+ );
723
+ }
724
+ return { json };
725
+ }
726
+
727
+ function parseGatewayCommand(argv: readonly string[]): ParsedGatewayCommand {
728
+ if (argv.length === 0) {
729
+ throw new Error('missing gateway subcommand');
730
+ }
731
+ const subcommand = argv[0]!;
732
+ const rest = argv.slice(1);
733
+ if (subcommand === 'start') {
734
+ return {
735
+ type: 'start',
736
+ startOptions: parseGatewayStartOptions(rest),
737
+ };
738
+ }
739
+ if (subcommand === 'run') {
740
+ return {
741
+ type: 'run',
742
+ startOptions: parseGatewayStartOptions(rest),
743
+ };
744
+ }
745
+ if (subcommand === 'restart') {
746
+ return {
747
+ type: 'restart',
748
+ startOptions: parseGatewayStartOptions(rest),
749
+ };
750
+ }
751
+ if (subcommand === 'stop') {
752
+ return {
753
+ type: 'stop',
754
+ stopOptions: parseGatewayStopOptions(rest),
755
+ };
756
+ }
757
+ if (subcommand === 'status') {
758
+ if (rest.length > 0) {
759
+ throw new Error(`unknown gateway option: ${rest[0]}`);
760
+ }
761
+ return {
762
+ type: 'status',
763
+ };
764
+ }
765
+ if (subcommand === 'call') {
766
+ const parsed = parseGatewayCallOptions(rest);
767
+ return {
768
+ type: 'call',
769
+ callJson: parsed.json,
770
+ };
771
+ }
772
+ throw new Error(`unknown gateway subcommand: ${subcommand}`);
773
+ }
774
+
775
+ function printUsage(): void {
776
+ process.stdout.write(
777
+ [
778
+ 'usage:',
779
+ ' harness [--session <name>] [mux-args...]',
780
+ ' harness [--session <name>] gateway start [--host <host>] [--port <port>] [--auth-token <token>] [--state-db-path <path>]',
781
+ ' harness [--session <name>] gateway run [--host <host>] [--port <port>] [--auth-token <token>] [--state-db-path <path>]',
782
+ ' harness [--session <name>] gateway stop [--force] [--timeout-ms <ms>] [--cleanup-orphans|--no-cleanup-orphans]',
783
+ ' harness [--session <name>] gateway status',
784
+ ' harness [--session <name>] gateway restart [--host <host>] [--port <port>] [--auth-token <token>] [--state-db-path <path>]',
785
+ ' harness [--session <name>] gateway call --json \'{"type":"session.list"}\'',
786
+ ' harness [--session <name>] profile start [--profile-dir <path>]',
787
+ ' harness [--session <name>] profile stop [--timeout-ms <ms>]',
788
+ ' harness [--session <name>] profile run [--profile-dir <path>] [mux-args...]',
789
+ ' harness [--session <name>] profile [--profile-dir <path>] [mux-args...]',
790
+ ' harness [--session <name>] status-timeline start [--output-path <path>]',
791
+ ' harness [--session <name>] status-timeline stop',
792
+ ' harness [--session <name>] status-timeline [--output-path <path>]',
793
+ ' harness [--session <name>] render-trace start [--output-path <path>] [--conversation-id <id>]',
794
+ ' harness [--session <name>] render-trace stop',
795
+ ' harness [--session <name>] render-trace [--output-path <path>] [--conversation-id <id>]',
796
+ ' harness cursor-hooks install [--hooks-file <path>]',
797
+ ' harness cursor-hooks uninstall [--hooks-file <path>]',
798
+ ' harness animate [--fps <fps>] [--frames <count>] [--duration-ms <ms>] [--seed <seed>] [--no-color]',
799
+ '',
800
+ 'session naming:',
801
+ ' --session accepts [A-Za-z0-9][A-Za-z0-9._-]{0,63} and isolates gateway record/log/db paths.',
802
+ ].join('\n') + '\n',
803
+ );
804
+ }
805
+
806
+ function resolveScriptPath(
807
+ envValue: string | undefined,
808
+ fallback: string,
809
+ invocationDirectory: string,
810
+ ): string {
811
+ if (typeof envValue !== 'string' || envValue.trim().length === 0) {
812
+ return fallback;
813
+ }
814
+ const trimmed = envValue.trim();
815
+ if (trimmed.startsWith('/')) {
816
+ return trimmed;
817
+ }
818
+ return resolve(invocationDirectory, trimmed);
819
+ }
820
+
821
+ function readGatewayRecord(recordPath: string): GatewayRecord | null {
822
+ if (!existsSync(recordPath)) {
823
+ return null;
824
+ }
825
+ try {
826
+ const raw = readFileSync(recordPath, 'utf8');
827
+ return parseGatewayRecordText(raw);
828
+ } catch {
829
+ return null;
830
+ }
831
+ }
832
+
833
+ function writeGatewayRecord(recordPath: string, record: GatewayRecord): void {
834
+ mkdirSync(dirname(recordPath), { recursive: true });
835
+ writeFileSync(recordPath, serializeGatewayRecord(record), 'utf8');
836
+ }
837
+
838
+ function removeGatewayRecord(recordPath: string): void {
839
+ try {
840
+ unlinkSync(recordPath);
841
+ } catch (error: unknown) {
842
+ const code = (error as NodeJS.ErrnoException).code;
843
+ if (code !== 'ENOENT') {
844
+ throw error;
845
+ }
846
+ }
847
+ }
848
+
849
+ function parseActiveProfileState(raw: unknown): ActiveProfileState | null {
850
+ if (typeof raw !== 'object' || raw === null) {
851
+ return null;
852
+ }
853
+ const candidate = raw as Record<string, unknown>;
854
+ if (candidate['version'] !== PROFILE_STATE_VERSION) {
855
+ return null;
856
+ }
857
+ if (candidate['mode'] !== PROFILE_LIVE_INSPECT_MODE) {
858
+ return null;
859
+ }
860
+ const pid = candidate['pid'];
861
+ const host = candidate['host'];
862
+ const port = candidate['port'];
863
+ const stateDbPath = candidate['stateDbPath'];
864
+ const profileDir = candidate['profileDir'];
865
+ const gatewayProfilePath = candidate['gatewayProfilePath'];
866
+ const inspectWebSocketUrl = candidate['inspectWebSocketUrl'];
867
+ const startedAt = candidate['startedAt'];
868
+ if (!Number.isInteger(pid) || (pid as number) <= 0) {
869
+ return null;
870
+ }
871
+ if (typeof host !== 'string' || host.length === 0) {
872
+ return null;
873
+ }
874
+ if (!Number.isInteger(port) || (port as number) <= 0 || (port as number) > 65535) {
875
+ return null;
876
+ }
877
+ if (typeof stateDbPath !== 'string' || stateDbPath.length === 0) {
878
+ return null;
879
+ }
880
+ if (typeof profileDir !== 'string' || profileDir.length === 0) {
881
+ return null;
882
+ }
883
+ if (typeof gatewayProfilePath !== 'string' || gatewayProfilePath.length === 0) {
884
+ return null;
885
+ }
886
+ if (typeof inspectWebSocketUrl !== 'string' || inspectWebSocketUrl.length === 0) {
887
+ return null;
888
+ }
889
+ if (typeof startedAt !== 'string' || startedAt.length === 0) {
890
+ return null;
891
+ }
892
+ return {
893
+ version: PROFILE_STATE_VERSION,
894
+ mode: PROFILE_LIVE_INSPECT_MODE,
895
+ pid: pid as number,
896
+ host,
897
+ port: port as number,
898
+ stateDbPath,
899
+ profileDir,
900
+ gatewayProfilePath,
901
+ inspectWebSocketUrl,
902
+ startedAt,
903
+ };
904
+ }
905
+
906
+ function readActiveProfileState(profileStatePath: string): ActiveProfileState | null {
907
+ if (!existsSync(profileStatePath)) {
908
+ return null;
909
+ }
910
+ try {
911
+ const raw = JSON.parse(readFileSync(profileStatePath, 'utf8')) as unknown;
912
+ return parseActiveProfileState(raw);
913
+ } catch {
914
+ return null;
915
+ }
916
+ }
917
+
918
+ function writeActiveProfileState(profileStatePath: string, state: ActiveProfileState): void {
919
+ mkdirSync(dirname(profileStatePath), { recursive: true });
920
+ writeFileSync(profileStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
921
+ }
922
+
923
+ function removeActiveProfileState(profileStatePath: string): void {
924
+ removeFileIfExists(profileStatePath);
925
+ }
926
+
927
+ function readActiveStatusTimelineState(statePath: string): ActiveStatusTimelineState | null {
928
+ if (!existsSync(statePath)) {
929
+ return null;
930
+ }
931
+ try {
932
+ const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
933
+ const parsed = parseActiveStatusTimelineState(raw);
934
+ if (parsed === null) {
935
+ return null;
936
+ }
937
+ return {
938
+ version: parsed.version,
939
+ mode: parsed.mode,
940
+ outputPath: parsed.outputPath,
941
+ sessionName: parsed.sessionName,
942
+ startedAt: parsed.startedAt,
943
+ };
944
+ } catch {
945
+ return null;
946
+ }
947
+ }
948
+
949
+ function writeActiveStatusTimelineState(statePath: string, state: ActiveStatusTimelineState): void {
950
+ mkdirSync(dirname(statePath), { recursive: true });
951
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
952
+ }
953
+
954
+ function removeActiveStatusTimelineState(statePath: string): void {
955
+ removeFileIfExists(statePath);
956
+ }
957
+
958
+ function readActiveRenderTraceState(statePath: string): ActiveRenderTraceState | null {
959
+ if (!existsSync(statePath)) {
960
+ return null;
961
+ }
962
+ try {
963
+ const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
964
+ const parsed = parseActiveRenderTraceState(raw);
965
+ if (parsed === null) {
966
+ return null;
967
+ }
968
+ return {
969
+ version: parsed.version,
970
+ mode: parsed.mode,
971
+ outputPath: parsed.outputPath,
972
+ sessionName: parsed.sessionName,
973
+ conversationId: parsed.conversationId,
974
+ startedAt: parsed.startedAt,
975
+ };
976
+ } catch {
977
+ return null;
978
+ }
979
+ }
980
+
981
+ function writeActiveRenderTraceState(statePath: string, state: ActiveRenderTraceState): void {
982
+ mkdirSync(dirname(statePath), { recursive: true });
983
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
984
+ }
985
+
986
+ function removeActiveRenderTraceState(statePath: string): void {
987
+ removeFileIfExists(statePath);
988
+ }
989
+
990
+ function isPidRunning(pid: number): boolean {
991
+ if (!Number.isInteger(pid) || pid <= 0) {
992
+ return false;
993
+ }
994
+ try {
995
+ process.kill(pid, 0);
996
+ return true;
997
+ } catch (error: unknown) {
998
+ const code = (error as NodeJS.ErrnoException).code;
999
+ if (code === 'ESRCH') {
1000
+ return false;
1001
+ }
1002
+ return true;
1003
+ }
1004
+ }
1005
+
1006
+ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
1007
+ const startedAt = Date.now();
1008
+ while (Date.now() - startedAt < timeoutMs) {
1009
+ if (!isPidRunning(pid)) {
1010
+ return true;
1011
+ }
1012
+ await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
1013
+ }
1014
+ return !isPidRunning(pid);
1015
+ }
1016
+
1017
+ async function waitForFileExists(filePath: string, timeoutMs: number): Promise<boolean> {
1018
+ const startedAt = Date.now();
1019
+ while (Date.now() - startedAt < timeoutMs) {
1020
+ if (existsSync(filePath)) {
1021
+ return true;
1022
+ }
1023
+ await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
1024
+ }
1025
+ return existsSync(filePath);
1026
+ }
1027
+
1028
+ function signalPidWithOptionalProcessGroup(
1029
+ pid: number,
1030
+ signal: NodeJS.Signals,
1031
+ includeProcessGroup: boolean,
1032
+ ): boolean {
1033
+ let sent = false;
1034
+ if (includeProcessGroup && pid > 1) {
1035
+ try {
1036
+ process.kill(-pid, signal);
1037
+ sent = true;
1038
+ } catch (error: unknown) {
1039
+ const code = (error as NodeJS.ErrnoException).code;
1040
+ if (code !== 'ESRCH') {
1041
+ throw error;
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ try {
1047
+ process.kill(pid, signal);
1048
+ sent = true;
1049
+ } catch (error: unknown) {
1050
+ const code = (error as NodeJS.ErrnoException).code;
1051
+ if (code !== 'ESRCH') {
1052
+ throw error;
1053
+ }
1054
+ }
1055
+
1056
+ return sent;
1057
+ }
1058
+
1059
+ function readProcessTable(): readonly ProcessTableEntry[] {
1060
+ const output = execFileSync('ps', ['-axww', '-o', 'pid=,ppid=,command='], {
1061
+ encoding: 'utf8',
1062
+ });
1063
+ const lines = output.split('\n');
1064
+ const entries: ProcessTableEntry[] = [];
1065
+ for (const line of lines) {
1066
+ const trimmed = line.trim();
1067
+ if (trimmed.length === 0) {
1068
+ continue;
1069
+ }
1070
+ const match = /^(\d+)\s+(\d+)\s+(.*)$/u.exec(trimmed);
1071
+ if (match === null) {
1072
+ continue;
1073
+ }
1074
+ const pid = Number.parseInt(match[1] ?? '', 10);
1075
+ const ppid = Number.parseInt(match[2] ?? '', 10);
1076
+ const command = match[3] ?? '';
1077
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid < 0) {
1078
+ continue;
1079
+ }
1080
+ entries.push({
1081
+ pid,
1082
+ ppid,
1083
+ command,
1084
+ });
1085
+ }
1086
+ return entries;
1087
+ }
1088
+
1089
+ function findOrphanSqlitePidsForDbPath(stateDbPath: string): readonly number[] {
1090
+ const normalizedDbPath = resolve(stateDbPath);
1091
+ return readProcessTable()
1092
+ .filter((entry) => entry.ppid === 1)
1093
+ .filter((entry) => entry.pid !== process.pid)
1094
+ .filter((entry) => /\bsqlite3\b/u.test(entry.command))
1095
+ .filter((entry) => entry.command.includes(normalizedDbPath))
1096
+ .map((entry) => entry.pid);
1097
+ }
1098
+
1099
+ function dedupePids(pids: readonly number[]): readonly number[] {
1100
+ return [...new Set(pids)];
1101
+ }
1102
+
1103
+ function resolvePtyHelperPathCandidates(invocationDirectory: string): readonly string[] {
1104
+ return [
1105
+ resolve(invocationDirectory, 'native/ptyd/target/release/ptyd'),
1106
+ resolve(invocationDirectory, 'bin/ptyd'),
1107
+ ];
1108
+ }
1109
+
1110
+ function findOrphanGatewayDaemonPids(
1111
+ stateDbPath: string,
1112
+ daemonScriptPath: string,
1113
+ ): readonly number[] {
1114
+ const normalizedDbPath = resolve(stateDbPath);
1115
+ const normalizedDaemonScriptPath = resolve(daemonScriptPath);
1116
+ return dedupePids(
1117
+ readProcessTable()
1118
+ .filter((entry) => entry.ppid === 1)
1119
+ .filter((entry) => entry.pid !== process.pid)
1120
+ .filter((entry) => entry.command.includes('--state-db-path'))
1121
+ .filter((entry) => {
1122
+ if (entry.command.includes(normalizedDaemonScriptPath)) {
1123
+ return true;
1124
+ }
1125
+ return (
1126
+ /\bcontrol-plane-daemon\.(?:ts|js)\b/u.test(entry.command) &&
1127
+ entry.command.includes(normalizedDbPath)
1128
+ );
1129
+ })
1130
+ .map((entry) => entry.pid),
1131
+ );
1132
+ }
1133
+
1134
+ function findOrphanPtyHelperPidsForWorkspace(invocationDirectory: string): readonly number[] {
1135
+ const helperPathCandidates = resolvePtyHelperPathCandidates(invocationDirectory);
1136
+ return readProcessTable()
1137
+ .filter((entry) => entry.ppid === 1)
1138
+ .filter((entry) => entry.pid !== process.pid)
1139
+ .filter((entry) => helperPathCandidates.some((candidate) => entry.command.includes(candidate)))
1140
+ .map((entry) => entry.pid);
1141
+ }
1142
+
1143
+ function findOrphanRelayLinkedAgentPidsForWorkspace(
1144
+ invocationDirectory: string,
1145
+ ): readonly number[] {
1146
+ const relayScriptPath = resolve(invocationDirectory, 'scripts/codex-notify-relay.ts');
1147
+ return readProcessTable()
1148
+ .filter((entry) => entry.ppid === 1)
1149
+ .filter((entry) => entry.pid !== process.pid)
1150
+ .filter((entry) => entry.command.includes(relayScriptPath))
1151
+ .map((entry) => entry.pid);
1152
+ }
1153
+
1154
+ function formatOrphanProcessCleanupResult(
1155
+ label: string,
1156
+ result: OrphanProcessCleanupResult,
1157
+ ): string {
1158
+ if (result.errorMessage !== null) {
1159
+ return `${label} cleanup error: ${result.errorMessage}`;
1160
+ }
1161
+ if (result.matchedPids.length === 0) {
1162
+ return `${label} cleanup: none found`;
1163
+ }
1164
+ if (result.failedPids.length === 0) {
1165
+ return `${label} cleanup: terminated ${String(result.terminatedPids.length)} process(es)`;
1166
+ }
1167
+ return [
1168
+ `${label} cleanup:`,
1169
+ `matched=${String(result.matchedPids.length)}`,
1170
+ `terminated=${String(result.terminatedPids.length)}`,
1171
+ `failed=${String(result.failedPids.length)}`,
1172
+ ].join(' ');
1173
+ }
1174
+
1175
+ async function cleanupOrphanPids(
1176
+ matchedPids: readonly number[],
1177
+ options: GatewayStopOptions,
1178
+ killProcessGroup = false,
1179
+ ): Promise<OrphanProcessCleanupResult> {
1180
+ const terminatedPids: number[] = [];
1181
+ const failedPids: number[] = [];
1182
+
1183
+ for (const pid of matchedPids) {
1184
+ if (!isPidRunning(pid)) {
1185
+ continue;
1186
+ }
1187
+ const signaledTerm = signalPidWithOptionalProcessGroup(pid, 'SIGTERM', killProcessGroup);
1188
+ if (!signaledTerm) {
1189
+ terminatedPids.push(pid);
1190
+ continue;
1191
+ }
1192
+
1193
+ const exitedAfterTerm = await waitForPidExit(pid, options.timeoutMs);
1194
+ if (exitedAfterTerm) {
1195
+ terminatedPids.push(pid);
1196
+ continue;
1197
+ }
1198
+
1199
+ if (!options.force) {
1200
+ failedPids.push(pid);
1201
+ continue;
1202
+ }
1203
+
1204
+ const signaledKill = signalPidWithOptionalProcessGroup(pid, 'SIGKILL', killProcessGroup);
1205
+ if (!signaledKill) {
1206
+ terminatedPids.push(pid);
1207
+ continue;
1208
+ }
1209
+
1210
+ if (await waitForPidExit(pid, options.timeoutMs)) {
1211
+ terminatedPids.push(pid);
1212
+ } else {
1213
+ failedPids.push(pid);
1214
+ }
1215
+ }
1216
+
1217
+ return {
1218
+ matchedPids,
1219
+ terminatedPids,
1220
+ failedPids,
1221
+ errorMessage: null,
1222
+ };
1223
+ }
1224
+
1225
+ async function cleanupOrphanSqliteProcessesForDbPath(
1226
+ stateDbPath: string,
1227
+ options: GatewayStopOptions,
1228
+ ): Promise<OrphanProcessCleanupResult> {
1229
+ let matchedPids: readonly number[] = [];
1230
+ try {
1231
+ matchedPids = findOrphanSqlitePidsForDbPath(stateDbPath);
1232
+ } catch (error: unknown) {
1233
+ return {
1234
+ matchedPids: [],
1235
+ terminatedPids: [],
1236
+ failedPids: [],
1237
+ errorMessage: error instanceof Error ? error.message : String(error),
1238
+ };
1239
+ }
1240
+ return await cleanupOrphanPids(matchedPids, options, false);
1241
+ }
1242
+
1243
+ async function cleanupOrphanGatewayDaemons(
1244
+ stateDbPath: string,
1245
+ daemonScriptPath: string,
1246
+ options: GatewayStopOptions,
1247
+ ): Promise<OrphanProcessCleanupResult> {
1248
+ let matchedPids: readonly number[] = [];
1249
+ try {
1250
+ matchedPids = findOrphanGatewayDaemonPids(stateDbPath, daemonScriptPath);
1251
+ } catch (error: unknown) {
1252
+ return {
1253
+ matchedPids: [],
1254
+ terminatedPids: [],
1255
+ failedPids: [],
1256
+ errorMessage: error instanceof Error ? error.message : String(error),
1257
+ };
1258
+ }
1259
+ return await cleanupOrphanPids(matchedPids, options, true);
1260
+ }
1261
+
1262
+ async function cleanupOrphanPtyHelpersForWorkspace(
1263
+ invocationDirectory: string,
1264
+ options: GatewayStopOptions,
1265
+ ): Promise<OrphanProcessCleanupResult> {
1266
+ let matchedPids: readonly number[] = [];
1267
+ try {
1268
+ matchedPids = findOrphanPtyHelperPidsForWorkspace(invocationDirectory);
1269
+ } catch (error: unknown) {
1270
+ return {
1271
+ matchedPids: [],
1272
+ terminatedPids: [],
1273
+ failedPids: [],
1274
+ errorMessage: error instanceof Error ? error.message : String(error),
1275
+ };
1276
+ }
1277
+ return await cleanupOrphanPids(matchedPids, options, false);
1278
+ }
1279
+
1280
+ async function cleanupOrphanRelayLinkedAgentsForWorkspace(
1281
+ invocationDirectory: string,
1282
+ options: GatewayStopOptions,
1283
+ ): Promise<OrphanProcessCleanupResult> {
1284
+ let matchedPids: readonly number[] = [];
1285
+ try {
1286
+ matchedPids = findOrphanRelayLinkedAgentPidsForWorkspace(invocationDirectory);
1287
+ } catch (error: unknown) {
1288
+ return {
1289
+ matchedPids: [],
1290
+ terminatedPids: [],
1291
+ failedPids: [],
1292
+ errorMessage: error instanceof Error ? error.message : String(error),
1293
+ };
1294
+ }
1295
+ return await cleanupOrphanPids(matchedPids, options, false);
1296
+ }
1297
+
1298
+ function resolveGatewaySettings(
1299
+ invocationDirectory: string,
1300
+ record: GatewayRecord | null,
1301
+ overrides: GatewayStartOptions,
1302
+ env: NodeJS.ProcessEnv,
1303
+ defaultStateDbPath: string,
1304
+ ): ResolvedGatewaySettings {
1305
+ const host = normalizeGatewayHost(
1306
+ overrides.host ?? record?.host ?? env.HARNESS_CONTROL_PLANE_HOST,
1307
+ );
1308
+ const port = normalizeGatewayPort(
1309
+ overrides.port ?? record?.port ?? env.HARNESS_CONTROL_PLANE_PORT,
1310
+ );
1311
+ const stateDbPathRaw = normalizeGatewayStateDbPath(
1312
+ overrides.stateDbPath ?? record?.stateDbPath ?? env.HARNESS_CONTROL_PLANE_DB_PATH,
1313
+ defaultStateDbPath,
1314
+ );
1315
+ const stateDbPath = resolve(invocationDirectory, stateDbPathRaw);
1316
+
1317
+ const envToken =
1318
+ typeof env.HARNESS_CONTROL_PLANE_AUTH_TOKEN === 'string' &&
1319
+ env.HARNESS_CONTROL_PLANE_AUTH_TOKEN.trim().length > 0
1320
+ ? env.HARNESS_CONTROL_PLANE_AUTH_TOKEN.trim()
1321
+ : null;
1322
+ const explicitToken = overrides.authToken ?? record?.authToken ?? envToken;
1323
+ const authToken = explicitToken ?? (isLoopbackHost(host) ? `gateway-${randomUUID()}` : null);
1324
+
1325
+ if (!isLoopbackHost(host) && authToken === null) {
1326
+ throw new Error('non-loopback hosts require --auth-token or HARNESS_CONTROL_PLANE_AUTH_TOKEN');
1327
+ }
1328
+
1329
+ return {
1330
+ host,
1331
+ port,
1332
+ authToken,
1333
+ stateDbPath,
1334
+ };
1335
+ }
1336
+
1337
+ async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult> {
1338
+ try {
1339
+ const client = await connectControlPlaneStreamClient({
1340
+ host: record.host,
1341
+ port: record.port,
1342
+ ...(record.authToken !== null
1343
+ ? {
1344
+ authToken: record.authToken,
1345
+ }
1346
+ : {}),
1347
+ });
1348
+ try {
1349
+ const result = await client.sendCommand({
1350
+ type: 'session.list',
1351
+ });
1352
+ const sessionsRaw = result['sessions'];
1353
+ if (!Array.isArray(sessionsRaw)) {
1354
+ return {
1355
+ connected: true,
1356
+ sessionCount: 0,
1357
+ liveSessionCount: 0,
1358
+ error: null,
1359
+ };
1360
+ }
1361
+ let liveCount = 0;
1362
+ for (const session of sessionsRaw) {
1363
+ if (
1364
+ typeof session === 'object' &&
1365
+ session !== null &&
1366
+ (session as Record<string, unknown>)['live'] === true
1367
+ ) {
1368
+ liveCount += 1;
1369
+ }
1370
+ }
1371
+ return {
1372
+ connected: true,
1373
+ sessionCount: sessionsRaw.length,
1374
+ liveSessionCount: liveCount,
1375
+ error: null,
1376
+ };
1377
+ } finally {
1378
+ client.close();
1379
+ }
1380
+ } catch (error: unknown) {
1381
+ return {
1382
+ connected: false,
1383
+ sessionCount: 0,
1384
+ liveSessionCount: 0,
1385
+ error: error instanceof Error ? error.message : String(error),
1386
+ };
1387
+ }
1388
+ }
1389
+
1390
+ async function waitForGatewayReady(record: GatewayRecord): Promise<void> {
1391
+ const client = await connectControlPlaneStreamClient({
1392
+ host: record.host,
1393
+ port: record.port,
1394
+ ...(record.authToken !== null
1395
+ ? {
1396
+ authToken: record.authToken,
1397
+ }
1398
+ : {}),
1399
+ connectRetryWindowMs: DEFAULT_GATEWAY_START_RETRY_WINDOW_MS,
1400
+ connectRetryDelayMs: DEFAULT_GATEWAY_START_RETRY_DELAY_MS,
1401
+ });
1402
+ try {
1403
+ await client.sendCommand({
1404
+ type: 'session.list',
1405
+ limit: 1,
1406
+ });
1407
+ } finally {
1408
+ client.close();
1409
+ }
1410
+ }
1411
+
1412
+ async function startDetachedGateway(
1413
+ invocationDirectory: string,
1414
+ recordPath: string,
1415
+ logPath: string,
1416
+ settings: ResolvedGatewaySettings,
1417
+ daemonScriptPath: string,
1418
+ runtimeArgs: readonly string[] = [],
1419
+ ): Promise<GatewayRecord> {
1420
+ mkdirSync(dirname(logPath), { recursive: true });
1421
+ const logFd = openSync(logPath, 'a');
1422
+ const daemonArgs = tsRuntimeArgs(
1423
+ daemonScriptPath,
1424
+ [
1425
+ '--host',
1426
+ settings.host,
1427
+ '--port',
1428
+ String(settings.port),
1429
+ '--state-db-path',
1430
+ settings.stateDbPath,
1431
+ ],
1432
+ runtimeArgs,
1433
+ );
1434
+ if (settings.authToken !== null) {
1435
+ daemonArgs.push('--auth-token', settings.authToken);
1436
+ }
1437
+ const child = spawn(process.execPath, daemonArgs, {
1438
+ detached: true,
1439
+ stdio: ['ignore', logFd, logFd],
1440
+ env: {
1441
+ ...process.env,
1442
+ HARNESS_INVOKE_CWD: invocationDirectory,
1443
+ },
1444
+ });
1445
+ closeSync(logFd);
1446
+
1447
+ if (child.pid === undefined) {
1448
+ throw new Error('failed to start gateway daemon (missing pid)');
1449
+ }
1450
+
1451
+ const record: GatewayRecord = {
1452
+ version: GATEWAY_RECORD_VERSION,
1453
+ pid: child.pid,
1454
+ host: settings.host,
1455
+ port: settings.port,
1456
+ authToken: settings.authToken,
1457
+ stateDbPath: settings.stateDbPath,
1458
+ startedAt: new Date().toISOString(),
1459
+ workspaceRoot: invocationDirectory,
1460
+ };
1461
+
1462
+ try {
1463
+ await waitForGatewayReady(record);
1464
+ } catch (error: unknown) {
1465
+ try {
1466
+ process.kill(child.pid, 'SIGTERM');
1467
+ } catch {
1468
+ // Best-effort cleanup only.
1469
+ }
1470
+ throw error;
1471
+ }
1472
+
1473
+ writeGatewayRecord(recordPath, record);
1474
+ child.unref();
1475
+ return record;
1476
+ }
1477
+
1478
+ async function ensureGatewayRunning(
1479
+ invocationDirectory: string,
1480
+ recordPath: string,
1481
+ logPath: string,
1482
+ daemonScriptPath: string,
1483
+ defaultStateDbPath: string,
1484
+ overrides: GatewayStartOptions = {},
1485
+ daemonRuntimeArgs: readonly string[] = [],
1486
+ ): Promise<EnsureGatewayResult> {
1487
+ const existingRecord = readGatewayRecord(recordPath);
1488
+ if (existingRecord !== null) {
1489
+ const probe = await probeGateway(existingRecord);
1490
+ if (probe.connected) {
1491
+ return {
1492
+ record: existingRecord,
1493
+ started: false,
1494
+ };
1495
+ }
1496
+ if (isPidRunning(existingRecord.pid)) {
1497
+ throw new Error(
1498
+ `gateway record is present but unreachable (pid=${String(existingRecord.pid)} still running): ${probe.error ?? 'unknown error'}`,
1499
+ );
1500
+ }
1501
+ removeGatewayRecord(recordPath);
1502
+ }
1503
+
1504
+ const settings = resolveGatewaySettings(
1505
+ invocationDirectory,
1506
+ existingRecord,
1507
+ overrides,
1508
+ process.env,
1509
+ defaultStateDbPath,
1510
+ );
1511
+ const record = await startDetachedGateway(
1512
+ invocationDirectory,
1513
+ recordPath,
1514
+ logPath,
1515
+ settings,
1516
+ daemonScriptPath,
1517
+ daemonRuntimeArgs,
1518
+ );
1519
+ return {
1520
+ record,
1521
+ started: true,
1522
+ };
1523
+ }
1524
+
1525
+ async function stopGateway(
1526
+ invocationDirectory: string,
1527
+ daemonScriptPath: string,
1528
+ recordPath: string,
1529
+ defaultStateDbPath: string,
1530
+ options: GatewayStopOptions,
1531
+ ): Promise<{ stopped: boolean; message: string }> {
1532
+ const appendCleanupSummary = async (
1533
+ baseMessage: string,
1534
+ stateDbPath: string,
1535
+ ): Promise<string> => {
1536
+ if (!options.cleanupOrphans) {
1537
+ return baseMessage;
1538
+ }
1539
+ const [gatewayCleanupResult, ptyCleanupResult, relayCleanupResult, sqliteCleanupResult] =
1540
+ await Promise.all([
1541
+ cleanupOrphanGatewayDaemons(stateDbPath, daemonScriptPath, options),
1542
+ cleanupOrphanPtyHelpersForWorkspace(invocationDirectory, options),
1543
+ cleanupOrphanRelayLinkedAgentsForWorkspace(invocationDirectory, options),
1544
+ cleanupOrphanSqliteProcessesForDbPath(stateDbPath, options),
1545
+ ]);
1546
+ return [
1547
+ baseMessage,
1548
+ formatOrphanProcessCleanupResult('orphan gateway daemon', gatewayCleanupResult),
1549
+ formatOrphanProcessCleanupResult('orphan pty helper', ptyCleanupResult),
1550
+ formatOrphanProcessCleanupResult('orphan relay-linked agent', relayCleanupResult),
1551
+ formatOrphanProcessCleanupResult('orphan sqlite', sqliteCleanupResult),
1552
+ ].join('; ');
1553
+ };
1554
+
1555
+ const record = readGatewayRecord(recordPath);
1556
+ if (record === null) {
1557
+ return {
1558
+ stopped: false,
1559
+ message: await appendCleanupSummary('gateway not running (no record)', defaultStateDbPath),
1560
+ };
1561
+ }
1562
+
1563
+ const probe = await probeGateway(record);
1564
+ const pidRunning = isPidRunning(record.pid);
1565
+
1566
+ if (!probe.connected && pidRunning && !options.force) {
1567
+ return {
1568
+ stopped: false,
1569
+ message: `gateway record points to a running but unreachable process (pid=${String(record.pid)}); re-run with --force`,
1570
+ };
1571
+ }
1572
+
1573
+ if (!pidRunning) {
1574
+ removeGatewayRecord(recordPath);
1575
+ return {
1576
+ stopped: true,
1577
+ message: await appendCleanupSummary('removed stale gateway record', record.stateDbPath),
1578
+ };
1579
+ }
1580
+
1581
+ const signaledTerm = signalPidWithOptionalProcessGroup(record.pid, 'SIGTERM', true);
1582
+ if (!signaledTerm) {
1583
+ removeGatewayRecord(recordPath);
1584
+ return {
1585
+ stopped: true,
1586
+ message: await appendCleanupSummary('gateway already exited', record.stateDbPath),
1587
+ };
1588
+ }
1589
+
1590
+ const exitedAfterTerm = await waitForPidExit(record.pid, options.timeoutMs);
1591
+ if (!exitedAfterTerm && options.force) {
1592
+ signalPidWithOptionalProcessGroup(record.pid, 'SIGKILL', true);
1593
+ const exitedAfterKill = await waitForPidExit(record.pid, options.timeoutMs);
1594
+ if (!exitedAfterKill) {
1595
+ return {
1596
+ stopped: false,
1597
+ message: `gateway did not exit after SIGKILL (pid=${String(record.pid)})`,
1598
+ };
1599
+ }
1600
+ } else if (!exitedAfterTerm) {
1601
+ return {
1602
+ stopped: false,
1603
+ message: `gateway did not exit after ${String(options.timeoutMs)}ms; retry with --force`,
1604
+ };
1605
+ }
1606
+
1607
+ removeGatewayRecord(recordPath);
1608
+ return {
1609
+ stopped: true,
1610
+ message: await appendCleanupSummary(
1611
+ `gateway stopped (pid=${String(record.pid)})`,
1612
+ record.stateDbPath,
1613
+ ),
1614
+ };
1615
+ }
1616
+
1617
+ async function runMuxClient(
1618
+ muxScriptPath: string,
1619
+ invocationDirectory: string,
1620
+ gateway: GatewayRecord,
1621
+ passthroughArgs: readonly string[],
1622
+ sessionName: string | null,
1623
+ runtimeArgs: readonly string[] = [],
1624
+ ): Promise<number> {
1625
+ const args = tsRuntimeArgs(
1626
+ muxScriptPath,
1627
+ [
1628
+ '--harness-server-host',
1629
+ gateway.host,
1630
+ '--harness-server-port',
1631
+ String(gateway.port),
1632
+ ...(gateway.authToken === null ? [] : ['--harness-server-token', gateway.authToken]),
1633
+ ...passthroughArgs,
1634
+ ],
1635
+ runtimeArgs,
1636
+ );
1637
+
1638
+ const child = spawn(process.execPath, args, {
1639
+ stdio: 'inherit',
1640
+ env: {
1641
+ ...process.env,
1642
+ HARNESS_INVOKE_CWD: invocationDirectory,
1643
+ ...(sessionName === null ? {} : { HARNESS_SESSION_NAME: sessionName }),
1644
+ },
1645
+ });
1646
+ const exit = await once(child, 'exit');
1647
+ const code = (exit[0] as number | null) ?? null;
1648
+ const signal = (exit[1] as NodeJS.Signals | null) ?? null;
1649
+ if (code !== null) {
1650
+ return code;
1651
+ }
1652
+ return normalizeSignalExitCode(signal);
1653
+ }
1654
+
1655
+ async function runGatewayForeground(
1656
+ daemonScriptPath: string,
1657
+ invocationDirectory: string,
1658
+ recordPath: string,
1659
+ settings: ResolvedGatewaySettings,
1660
+ runtimeArgs: readonly string[] = [],
1661
+ ): Promise<number> {
1662
+ const existingRecord = readGatewayRecord(recordPath);
1663
+ if (existingRecord !== null) {
1664
+ const probe = await probeGateway(existingRecord);
1665
+ if (probe.connected || isPidRunning(existingRecord.pid)) {
1666
+ throw new Error('gateway is already running; stop it first or use `harness gateway start`');
1667
+ }
1668
+ removeGatewayRecord(recordPath);
1669
+ }
1670
+
1671
+ const daemonArgs = tsRuntimeArgs(
1672
+ daemonScriptPath,
1673
+ [
1674
+ '--host',
1675
+ settings.host,
1676
+ '--port',
1677
+ String(settings.port),
1678
+ '--state-db-path',
1679
+ settings.stateDbPath,
1680
+ ],
1681
+ runtimeArgs,
1682
+ );
1683
+ if (settings.authToken !== null) {
1684
+ daemonArgs.push('--auth-token', settings.authToken);
1685
+ }
1686
+
1687
+ const child = spawn(process.execPath, daemonArgs, {
1688
+ stdio: 'inherit',
1689
+ env: {
1690
+ ...process.env,
1691
+ HARNESS_INVOKE_CWD: invocationDirectory,
1692
+ },
1693
+ });
1694
+ if (child.pid !== undefined) {
1695
+ writeGatewayRecord(recordPath, {
1696
+ version: GATEWAY_RECORD_VERSION,
1697
+ pid: child.pid,
1698
+ host: settings.host,
1699
+ port: settings.port,
1700
+ authToken: settings.authToken,
1701
+ stateDbPath: settings.stateDbPath,
1702
+ startedAt: new Date().toISOString(),
1703
+ workspaceRoot: invocationDirectory,
1704
+ });
1705
+ }
1706
+
1707
+ const exit = await once(child, 'exit');
1708
+ const code = (exit[0] as number | null) ?? null;
1709
+ const signal = (exit[1] as NodeJS.Signals | null) ?? null;
1710
+ const record = readGatewayRecord(recordPath);
1711
+ if (record !== null && child.pid !== undefined && record.pid === child.pid) {
1712
+ removeGatewayRecord(recordPath);
1713
+ }
1714
+ if (code !== null) {
1715
+ return code;
1716
+ }
1717
+ return normalizeSignalExitCode(signal);
1718
+ }
1719
+
1720
+ function parseCallCommand(raw: string): StreamCommand {
1721
+ let parsed: unknown;
1722
+ try {
1723
+ parsed = JSON.parse(raw);
1724
+ } catch (error: unknown) {
1725
+ throw new Error(
1726
+ `invalid JSON command: ${error instanceof Error ? error.message : String(error)}`,
1727
+ );
1728
+ }
1729
+ const command = parseStreamCommand(parsed);
1730
+ if (command === null) {
1731
+ throw new Error('invalid stream command payload');
1732
+ }
1733
+ return command;
1734
+ }
1735
+
1736
+ async function executeGatewayCall(record: GatewayRecord, rawCommand: string): Promise<number> {
1737
+ const command = parseCallCommand(rawCommand);
1738
+ const client = await connectControlPlaneStreamClient({
1739
+ host: record.host,
1740
+ port: record.port,
1741
+ ...(record.authToken === null
1742
+ ? {}
1743
+ : {
1744
+ authToken: record.authToken,
1745
+ }),
1746
+ });
1747
+ try {
1748
+ const result = await client.sendCommand(command);
1749
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1750
+ } finally {
1751
+ client.close();
1752
+ }
1753
+ return 0;
1754
+ }
1755
+
1756
+ async function runGatewayCommandEntry(
1757
+ command: ParsedGatewayCommand,
1758
+ invocationDirectory: string,
1759
+ daemonScriptPath: string,
1760
+ recordPath: string,
1761
+ logPath: string,
1762
+ defaultStateDbPath: string,
1763
+ runtimeOptions: RuntimeInspectOptions,
1764
+ ): Promise<number> {
1765
+ if (command.type === 'status') {
1766
+ const record = readGatewayRecord(recordPath);
1767
+ if (record === null) {
1768
+ process.stdout.write('gateway status: stopped\n');
1769
+ return 0;
1770
+ }
1771
+ const pidRunning = isPidRunning(record.pid);
1772
+ const probe = await probeGateway(record);
1773
+ process.stdout.write(`gateway status: ${probe.connected ? 'running' : 'unreachable'}\n`);
1774
+ process.stdout.write(`record: ${recordPath}\n`);
1775
+ process.stdout.write(
1776
+ `pid: ${String(record.pid)} (${pidRunning ? 'running' : 'not-running'})\n`,
1777
+ );
1778
+ process.stdout.write(`host: ${record.host}\n`);
1779
+ process.stdout.write(`port: ${String(record.port)}\n`);
1780
+ process.stdout.write(`auth: ${record.authToken === null ? 'off' : 'on'}\n`);
1781
+ process.stdout.write(`db: ${record.stateDbPath}\n`);
1782
+ process.stdout.write(`startedAt: ${record.startedAt}\n`);
1783
+ process.stdout.write(
1784
+ `sessions: total=${String(probe.sessionCount)} live=${String(probe.liveSessionCount)}\n`,
1785
+ );
1786
+ if (!probe.connected) {
1787
+ process.stdout.write(`lastError: ${probe.error ?? 'unknown'}\n`);
1788
+ return 1;
1789
+ }
1790
+ return 0;
1791
+ }
1792
+
1793
+ if (command.type === 'stop') {
1794
+ const stopOptions = command.stopOptions ?? {
1795
+ force: false,
1796
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
1797
+ cleanupOrphans: true,
1798
+ };
1799
+ const stopped = await stopGateway(
1800
+ invocationDirectory,
1801
+ daemonScriptPath,
1802
+ recordPath,
1803
+ defaultStateDbPath,
1804
+ stopOptions,
1805
+ );
1806
+ process.stdout.write(`${stopped.message}\n`);
1807
+ return stopped.stopped ? 0 : 1;
1808
+ }
1809
+
1810
+ if (command.type === 'start') {
1811
+ const ensured = await ensureGatewayRunning(
1812
+ invocationDirectory,
1813
+ recordPath,
1814
+ logPath,
1815
+ daemonScriptPath,
1816
+ defaultStateDbPath,
1817
+ command.startOptions ?? {},
1818
+ runtimeOptions.gatewayRuntimeArgs,
1819
+ );
1820
+ if (ensured.started) {
1821
+ process.stdout.write(
1822
+ `gateway started pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
1823
+ );
1824
+ } else {
1825
+ process.stdout.write(
1826
+ `gateway already running pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
1827
+ );
1828
+ }
1829
+ process.stdout.write(`record: ${recordPath}\n`);
1830
+ process.stdout.write(`log: ${logPath}\n`);
1831
+ return 0;
1832
+ }
1833
+
1834
+ if (command.type === 'restart') {
1835
+ const stopResult = await stopGateway(
1836
+ invocationDirectory,
1837
+ daemonScriptPath,
1838
+ recordPath,
1839
+ defaultStateDbPath,
1840
+ {
1841
+ force: true,
1842
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
1843
+ cleanupOrphans: true,
1844
+ },
1845
+ );
1846
+ process.stdout.write(`${stopResult.message}\n`);
1847
+ const ensured = await ensureGatewayRunning(
1848
+ invocationDirectory,
1849
+ recordPath,
1850
+ logPath,
1851
+ daemonScriptPath,
1852
+ defaultStateDbPath,
1853
+ command.startOptions ?? {},
1854
+ runtimeOptions.gatewayRuntimeArgs,
1855
+ );
1856
+ process.stdout.write(
1857
+ `gateway restarted pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
1858
+ );
1859
+ process.stdout.write(`record: ${recordPath}\n`);
1860
+ process.stdout.write(`log: ${logPath}\n`);
1861
+ return 0;
1862
+ }
1863
+
1864
+ if (command.type === 'run') {
1865
+ const existingRecord = readGatewayRecord(recordPath);
1866
+ const settings = resolveGatewaySettings(
1867
+ invocationDirectory,
1868
+ existingRecord,
1869
+ command.startOptions ?? {},
1870
+ process.env,
1871
+ defaultStateDbPath,
1872
+ );
1873
+ process.stdout.write(
1874
+ `gateway foreground run host=${settings.host} port=${String(settings.port)} db=${settings.stateDbPath}\n`,
1875
+ );
1876
+ return await runGatewayForeground(
1877
+ daemonScriptPath,
1878
+ invocationDirectory,
1879
+ recordPath,
1880
+ settings,
1881
+ runtimeOptions.gatewayRuntimeArgs,
1882
+ );
1883
+ }
1884
+
1885
+ const record = readGatewayRecord(recordPath);
1886
+ if (record === null) {
1887
+ throw new Error('gateway not running; start it first');
1888
+ }
1889
+ if (command.callJson === undefined) {
1890
+ throw new Error('missing gateway call json');
1891
+ }
1892
+ return await executeGatewayCall(record, command.callJson);
1893
+ }
1894
+
1895
+ async function runDefaultClient(
1896
+ invocationDirectory: string,
1897
+ daemonScriptPath: string,
1898
+ muxScriptPath: string,
1899
+ recordPath: string,
1900
+ logPath: string,
1901
+ defaultStateDbPath: string,
1902
+ args: readonly string[],
1903
+ sessionName: string | null,
1904
+ runtimeOptions: RuntimeInspectOptions,
1905
+ ): Promise<number> {
1906
+ const ensured = await ensureGatewayRunning(
1907
+ invocationDirectory,
1908
+ recordPath,
1909
+ logPath,
1910
+ daemonScriptPath,
1911
+ defaultStateDbPath,
1912
+ {},
1913
+ runtimeOptions.gatewayRuntimeArgs,
1914
+ );
1915
+ if (ensured.started) {
1916
+ process.stdout.write(
1917
+ `gateway started pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
1918
+ );
1919
+ }
1920
+ return await runMuxClient(
1921
+ muxScriptPath,
1922
+ invocationDirectory,
1923
+ ensured.record,
1924
+ args,
1925
+ sessionName,
1926
+ runtimeOptions.clientRuntimeArgs,
1927
+ );
1928
+ }
1929
+
1930
+ async function runProfileRun(
1931
+ invocationDirectory: string,
1932
+ daemonScriptPath: string,
1933
+ muxScriptPath: string,
1934
+ sessionPaths: SessionPaths,
1935
+ command: ParsedProfileRunCommand,
1936
+ sessionName: string | null,
1937
+ runtimeOptions: RuntimeInspectOptions,
1938
+ ): Promise<number> {
1939
+ const profileDir =
1940
+ command.profileDir === null
1941
+ ? sessionPaths.profileDir
1942
+ : resolve(invocationDirectory, command.profileDir);
1943
+ mkdirSync(profileDir, { recursive: true });
1944
+
1945
+ const clientProfilePath = resolve(profileDir, PROFILE_CLIENT_FILE_NAME);
1946
+ const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
1947
+ removeFileIfExists(clientProfilePath);
1948
+ removeFileIfExists(gatewayProfilePath);
1949
+
1950
+ const existingProfileState = readActiveProfileState(sessionPaths.profileStatePath);
1951
+ if (existingProfileState !== null) {
1952
+ if (isPidRunning(existingProfileState.pid)) {
1953
+ throw new Error(
1954
+ 'profile run requires no active profile session; stop it first with `harness profile stop`',
1955
+ );
1956
+ }
1957
+ removeActiveProfileState(sessionPaths.profileStatePath);
1958
+ }
1959
+
1960
+ const existingRecord = readGatewayRecord(sessionPaths.recordPath);
1961
+ if (existingRecord !== null) {
1962
+ const existingProbe = await probeGateway(existingRecord);
1963
+ if (existingProbe.connected || isPidRunning(existingRecord.pid)) {
1964
+ throw new Error('profile command requires the target session gateway to be stopped first');
1965
+ }
1966
+ removeGatewayRecord(sessionPaths.recordPath);
1967
+ }
1968
+
1969
+ const host = normalizeGatewayHost(process.env.HARNESS_CONTROL_PLANE_HOST);
1970
+ const reservedPort = await reservePort(host);
1971
+ const settings = resolveGatewaySettings(
1972
+ invocationDirectory,
1973
+ null,
1974
+ {
1975
+ port: reservedPort,
1976
+ stateDbPath: sessionPaths.defaultStateDbPath,
1977
+ },
1978
+ process.env,
1979
+ sessionPaths.defaultStateDbPath,
1980
+ );
1981
+
1982
+ const gateway = await startDetachedGateway(
1983
+ invocationDirectory,
1984
+ sessionPaths.recordPath,
1985
+ sessionPaths.logPath,
1986
+ settings,
1987
+ daemonScriptPath,
1988
+ [
1989
+ ...runtimeOptions.gatewayRuntimeArgs,
1990
+ ...buildCpuProfileRuntimeArgs({
1991
+ cpuProfileDir: profileDir,
1992
+ cpuProfileName: PROFILE_GATEWAY_FILE_NAME,
1993
+ }),
1994
+ ],
1995
+ );
1996
+
1997
+ let clientExitCode = 1;
1998
+ let clientError: Error | null = null;
1999
+ try {
2000
+ clientExitCode = await runMuxClient(
2001
+ muxScriptPath,
2002
+ invocationDirectory,
2003
+ gateway,
2004
+ command.muxArgs,
2005
+ sessionName,
2006
+ [
2007
+ ...runtimeOptions.clientRuntimeArgs,
2008
+ ...buildCpuProfileRuntimeArgs({
2009
+ cpuProfileDir: profileDir,
2010
+ cpuProfileName: PROFILE_CLIENT_FILE_NAME,
2011
+ }),
2012
+ ],
2013
+ );
2014
+ } catch (error: unknown) {
2015
+ clientError = error instanceof Error ? error : new Error(String(error));
2016
+ }
2017
+
2018
+ const stopped = await stopGateway(
2019
+ invocationDirectory,
2020
+ daemonScriptPath,
2021
+ sessionPaths.recordPath,
2022
+ sessionPaths.defaultStateDbPath,
2023
+ {
2024
+ force: true,
2025
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2026
+ cleanupOrphans: true,
2027
+ },
2028
+ );
2029
+ process.stdout.write(`${stopped.message}\n`);
2030
+ if (!stopped.stopped) {
2031
+ throw new Error(`failed to stop profile gateway: ${stopped.message}`);
2032
+ }
2033
+ if (clientError !== null) {
2034
+ throw clientError;
2035
+ }
2036
+ if (!existsSync(clientProfilePath)) {
2037
+ throw new Error(`missing client CPU profile: ${clientProfilePath}`);
2038
+ }
2039
+ if (!existsSync(gatewayProfilePath)) {
2040
+ throw new Error(`missing gateway CPU profile: ${gatewayProfilePath}`);
2041
+ }
2042
+
2043
+ process.stdout.write(`profiles: client=${clientProfilePath} gateway=${gatewayProfilePath}\n`);
2044
+ return clientExitCode;
2045
+ }
2046
+
2047
+ async function runProfileStart(
2048
+ invocationDirectory: string,
2049
+ sessionPaths: SessionPaths,
2050
+ command: ParsedProfileStartCommand,
2051
+ ): Promise<number> {
2052
+ const profileDir =
2053
+ command.profileDir === null
2054
+ ? sessionPaths.profileDir
2055
+ : resolve(invocationDirectory, command.profileDir);
2056
+ mkdirSync(profileDir, { recursive: true });
2057
+ const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
2058
+ removeFileIfExists(gatewayProfilePath);
2059
+
2060
+ const existingProfileState = readActiveProfileState(sessionPaths.profileStatePath);
2061
+ if (existingProfileState !== null) {
2062
+ if (isPidRunning(existingProfileState.pid)) {
2063
+ throw new Error('profile already running; stop it first with `harness profile stop`');
2064
+ }
2065
+ removeActiveProfileState(sessionPaths.profileStatePath);
2066
+ }
2067
+
2068
+ const existingRecord = readGatewayRecord(sessionPaths.recordPath);
2069
+ if (existingRecord === null) {
2070
+ throw new Error('profile start requires the target session gateway to be running');
2071
+ }
2072
+ const existingProbe = await probeGateway(existingRecord);
2073
+ if (!existingProbe.connected || !isPidRunning(existingRecord.pid)) {
2074
+ throw new Error('profile start requires the target session gateway to be running');
2075
+ }
2076
+ const inspector = await connectGatewayInspector(
2077
+ invocationDirectory,
2078
+ sessionPaths.logPath,
2079
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
2080
+ );
2081
+ try {
2082
+ const startCommandRaw = await evaluateInspectorExpression(
2083
+ inspector.client,
2084
+ buildInspectorProfileStartExpression(),
2085
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
2086
+ );
2087
+ if (typeof startCommandRaw !== 'string') {
2088
+ throw new Error('failed to start gateway profiler (invalid inspector response)');
2089
+ }
2090
+ const startCommandResult = JSON.parse(startCommandRaw) as Record<string, unknown>;
2091
+ if (startCommandResult['ok'] !== true) {
2092
+ const reason = startCommandResult['reason'];
2093
+ throw new Error(
2094
+ `failed to start gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
2095
+ );
2096
+ }
2097
+
2098
+ const startDeadline = Date.now() + DEFAULT_PROFILE_INSPECT_TIMEOUT_MS;
2099
+ let runningState: InspectorProfileState | null = null;
2100
+ while (Date.now() < startDeadline) {
2101
+ const state = await readInspectorProfileState(
2102
+ inspector.client,
2103
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
2104
+ );
2105
+ if (state !== null && state.status === 'running') {
2106
+ runningState = state;
2107
+ break;
2108
+ }
2109
+ if (state !== null && state.status === 'failed') {
2110
+ throw new Error(`failed to start gateway profiler (${state.error ?? 'unknown error'})`);
2111
+ }
2112
+ await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
2113
+ }
2114
+ if (runningState === null) {
2115
+ throw new Error('failed to start gateway profiler (inspector runtime timeout)');
2116
+ }
2117
+ } finally {
2118
+ inspector.client.close();
2119
+ }
2120
+
2121
+ writeActiveProfileState(sessionPaths.profileStatePath, {
2122
+ version: PROFILE_STATE_VERSION,
2123
+ mode: PROFILE_LIVE_INSPECT_MODE,
2124
+ pid: existingRecord.pid,
2125
+ host: existingRecord.host,
2126
+ port: existingRecord.port,
2127
+ stateDbPath: existingRecord.stateDbPath,
2128
+ profileDir,
2129
+ gatewayProfilePath,
2130
+ inspectWebSocketUrl: inspector.endpoint,
2131
+ startedAt: new Date().toISOString(),
2132
+ });
2133
+
2134
+ process.stdout.write(
2135
+ `profile started pid=${String(existingRecord.pid)} host=${existingRecord.host} port=${String(existingRecord.port)}\n`,
2136
+ );
2137
+ process.stdout.write(`record: ${sessionPaths.recordPath}\n`);
2138
+ process.stdout.write(`log: ${sessionPaths.logPath}\n`);
2139
+ process.stdout.write(`profile-state: ${sessionPaths.profileStatePath}\n`);
2140
+ process.stdout.write(`profile-target: ${gatewayProfilePath}\n`);
2141
+ process.stdout.write('stop with: harness profile stop\n');
2142
+ return 0;
2143
+ }
2144
+
2145
+ async function runProfileStop(
2146
+ sessionPaths: SessionPaths,
2147
+ command: ParsedProfileStopCommand,
2148
+ ): Promise<number> {
2149
+ const profileState = readActiveProfileState(sessionPaths.profileStatePath);
2150
+ if (profileState === null) {
2151
+ throw new Error(
2152
+ 'no active profile run for this session; start one with `harness profile start`',
2153
+ );
2154
+ }
2155
+ if (profileState.mode !== PROFILE_LIVE_INSPECT_MODE) {
2156
+ throw new Error('active profile run is incompatible with this harness version');
2157
+ }
2158
+ const inspector = await InspectorWebSocketClient.connect(
2159
+ profileState.inspectWebSocketUrl,
2160
+ command.stopOptions.timeoutMs,
2161
+ );
2162
+ try {
2163
+ await inspector.sendCommand('Runtime.enable', {}, command.stopOptions.timeoutMs);
2164
+ const stopCommandRaw = await evaluateInspectorExpression(
2165
+ inspector,
2166
+ buildInspectorProfileStopExpression(profileState.gatewayProfilePath, profileState.profileDir),
2167
+ command.stopOptions.timeoutMs,
2168
+ );
2169
+ if (typeof stopCommandRaw !== 'string') {
2170
+ throw new Error('failed to stop gateway profiler (invalid inspector response)');
2171
+ }
2172
+ const stopCommandResult = JSON.parse(stopCommandRaw) as Record<string, unknown>;
2173
+ if (stopCommandResult['ok'] !== true) {
2174
+ const reason = stopCommandResult['reason'];
2175
+ throw new Error(
2176
+ `failed to stop gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
2177
+ );
2178
+ }
2179
+
2180
+ const startedAt = Date.now();
2181
+ while (Date.now() - startedAt < command.stopOptions.timeoutMs) {
2182
+ const state = await readInspectorProfileState(inspector, command.stopOptions.timeoutMs);
2183
+ if (state !== null && state.status === 'failed') {
2184
+ throw new Error(`failed to stop gateway profiler (${state.error ?? 'unknown error'})`);
2185
+ }
2186
+ if (state !== null && state.status === 'stopped' && state.written) {
2187
+ break;
2188
+ }
2189
+ await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
2190
+ }
2191
+ } finally {
2192
+ inspector.close();
2193
+ }
2194
+
2195
+ const profileFlushed = await waitForFileExists(
2196
+ profileState.gatewayProfilePath,
2197
+ command.stopOptions.timeoutMs,
2198
+ );
2199
+ if (!profileFlushed) {
2200
+ throw new Error(`missing gateway CPU profile: ${profileState.gatewayProfilePath}`);
2201
+ }
2202
+
2203
+ removeActiveProfileState(sessionPaths.profileStatePath);
2204
+ process.stdout.write(`profile: gateway=${profileState.gatewayProfilePath}\n`);
2205
+ return 0;
2206
+ }
2207
+
2208
+ async function runProfileCommandEntry(
2209
+ invocationDirectory: string,
2210
+ daemonScriptPath: string,
2211
+ muxScriptPath: string,
2212
+ sessionPaths: SessionPaths,
2213
+ args: readonly string[],
2214
+ sessionName: string | null,
2215
+ runtimeOptions: RuntimeInspectOptions,
2216
+ ): Promise<number> {
2217
+ if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
2218
+ printUsage();
2219
+ return 0;
2220
+ }
2221
+ const command = parseProfileCommand(args);
2222
+ if (command.type === 'start') {
2223
+ return await runProfileStart(invocationDirectory, sessionPaths, command);
2224
+ }
2225
+ if (command.type === 'stop') {
2226
+ return await runProfileStop(sessionPaths, command);
2227
+ }
2228
+ return await runProfileRun(
2229
+ invocationDirectory,
2230
+ daemonScriptPath,
2231
+ muxScriptPath,
2232
+ sessionPaths,
2233
+ command,
2234
+ sessionName,
2235
+ runtimeOptions,
2236
+ );
2237
+ }
2238
+
2239
+ async function runStatusTimelineStart(
2240
+ invocationDirectory: string,
2241
+ sessionPaths: SessionPaths,
2242
+ sessionName: string | null,
2243
+ command: ParsedStatusTimelineStartCommand,
2244
+ ): Promise<number> {
2245
+ const outputPath =
2246
+ command.outputPath === null
2247
+ ? sessionPaths.defaultStatusTimelineOutputPath
2248
+ : resolve(invocationDirectory, command.outputPath);
2249
+ const existingState = readActiveStatusTimelineState(sessionPaths.statusTimelineStatePath);
2250
+ if (existingState !== null) {
2251
+ throw new Error(
2252
+ 'status timeline already running; stop it first with `harness status-timeline stop`',
2253
+ );
2254
+ }
2255
+ mkdirSync(dirname(outputPath), { recursive: true });
2256
+ writeFileSync(outputPath, '', 'utf8');
2257
+ writeActiveStatusTimelineState(sessionPaths.statusTimelineStatePath, {
2258
+ version: STATUS_TIMELINE_STATE_VERSION,
2259
+ mode: STATUS_TIMELINE_MODE,
2260
+ outputPath,
2261
+ sessionName,
2262
+ startedAt: new Date().toISOString(),
2263
+ });
2264
+ process.stdout.write('status timeline started\n');
2265
+ process.stdout.write(`status-timeline-state: ${sessionPaths.statusTimelineStatePath}\n`);
2266
+ process.stdout.write(`status-timeline-target: ${outputPath}\n`);
2267
+ process.stdout.write('stop with: harness status-timeline stop\n');
2268
+ return 0;
2269
+ }
2270
+
2271
+ async function runStatusTimelineStop(sessionPaths: SessionPaths): Promise<number> {
2272
+ const state = readActiveStatusTimelineState(sessionPaths.statusTimelineStatePath);
2273
+ if (state === null) {
2274
+ throw new Error(
2275
+ 'no active status timeline run for this session; start one with `harness status-timeline start`',
2276
+ );
2277
+ }
2278
+ removeActiveStatusTimelineState(sessionPaths.statusTimelineStatePath);
2279
+ process.stdout.write(`status timeline stopped: ${state.outputPath}\n`);
2280
+ return 0;
2281
+ }
2282
+
2283
+ async function runStatusTimelineCommandEntry(
2284
+ invocationDirectory: string,
2285
+ sessionPaths: SessionPaths,
2286
+ args: readonly string[],
2287
+ sessionName: string | null,
2288
+ ): Promise<number> {
2289
+ if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
2290
+ printUsage();
2291
+ return 0;
2292
+ }
2293
+ const command = parseStatusTimelineCommand(args);
2294
+ if (command.type === 'stop') {
2295
+ return await runStatusTimelineStop(sessionPaths);
2296
+ }
2297
+ return await runStatusTimelineStart(invocationDirectory, sessionPaths, sessionName, command);
2298
+ }
2299
+
2300
+ async function runRenderTraceStart(
2301
+ invocationDirectory: string,
2302
+ sessionPaths: SessionPaths,
2303
+ sessionName: string | null,
2304
+ command: ParsedRenderTraceStartCommand,
2305
+ ): Promise<number> {
2306
+ const outputPath =
2307
+ command.outputPath === null
2308
+ ? sessionPaths.defaultRenderTraceOutputPath
2309
+ : resolve(invocationDirectory, command.outputPath);
2310
+ const existingState = readActiveRenderTraceState(sessionPaths.renderTraceStatePath);
2311
+ if (existingState !== null) {
2312
+ throw new Error('render trace already running; stop it first with `harness render-trace stop`');
2313
+ }
2314
+ mkdirSync(dirname(outputPath), { recursive: true });
2315
+ writeFileSync(outputPath, '', 'utf8');
2316
+ writeActiveRenderTraceState(sessionPaths.renderTraceStatePath, {
2317
+ version: RENDER_TRACE_STATE_VERSION,
2318
+ mode: RENDER_TRACE_MODE,
2319
+ outputPath,
2320
+ sessionName,
2321
+ conversationId: command.conversationId,
2322
+ startedAt: new Date().toISOString(),
2323
+ });
2324
+ process.stdout.write('render trace started\n');
2325
+ process.stdout.write(`render-trace-state: ${sessionPaths.renderTraceStatePath}\n`);
2326
+ process.stdout.write(`render-trace-target: ${outputPath}\n`);
2327
+ if (command.conversationId !== null) {
2328
+ process.stdout.write(`render-trace-conversation-id: ${command.conversationId}\n`);
2329
+ }
2330
+ process.stdout.write('stop with: harness render-trace stop\n');
2331
+ return 0;
2332
+ }
2333
+
2334
+ async function runRenderTraceStop(sessionPaths: SessionPaths): Promise<number> {
2335
+ const state = readActiveRenderTraceState(sessionPaths.renderTraceStatePath);
2336
+ if (state === null) {
2337
+ throw new Error(
2338
+ 'no active render trace run for this session; start one with `harness render-trace start`',
2339
+ );
2340
+ }
2341
+ removeActiveRenderTraceState(sessionPaths.renderTraceStatePath);
2342
+ process.stdout.write(`render trace stopped: ${state.outputPath}\n`);
2343
+ return 0;
2344
+ }
2345
+
2346
+ async function runRenderTraceCommandEntry(
2347
+ invocationDirectory: string,
2348
+ sessionPaths: SessionPaths,
2349
+ args: readonly string[],
2350
+ sessionName: string | null,
2351
+ ): Promise<number> {
2352
+ if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
2353
+ printUsage();
2354
+ return 0;
2355
+ }
2356
+ const command = parseRenderTraceCommand(args);
2357
+ if (command.type === 'stop') {
2358
+ return await runRenderTraceStop(sessionPaths);
2359
+ }
2360
+ return await runRenderTraceStart(invocationDirectory, sessionPaths, sessionName, command);
2361
+ }
2362
+
2363
+ async function runCursorHooksCommandEntry(
2364
+ invocationDirectory: string,
2365
+ command: ParsedCursorHooksCommand,
2366
+ ): Promise<number> {
2367
+ const hooksFilePath =
2368
+ command.hooksFilePath === null
2369
+ ? undefined
2370
+ : resolve(invocationDirectory, command.hooksFilePath);
2371
+ if (command.type === 'install') {
2372
+ const relayScriptPath = resolveScriptPath(
2373
+ process.env.HARNESS_CURSOR_HOOK_RELAY_SCRIPT_PATH,
2374
+ DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH,
2375
+ invocationDirectory,
2376
+ );
2377
+ const result = ensureManagedCursorHooksInstalled({
2378
+ relayCommand: buildCursorManagedHookRelayCommand(relayScriptPath),
2379
+ ...(hooksFilePath === undefined ? {} : { hooksFilePath }),
2380
+ });
2381
+ process.stdout.write(
2382
+ `cursor hooks install: ${result.changed ? 'updated' : 'already up-to-date'} file=${result.filePath} removed=${String(result.removedCount)} added=${String(result.addedCount)}\n`,
2383
+ );
2384
+ return 0;
2385
+ }
2386
+ const result = uninstallManagedCursorHooks(hooksFilePath === undefined ? {} : { hooksFilePath });
2387
+ process.stdout.write(
2388
+ `cursor hooks uninstall: ${result.changed ? 'updated' : 'no changes'} file=${result.filePath} removed=${String(result.removedCount)}\n`,
2389
+ );
2390
+ return 0;
2391
+ }
2392
+
2393
+ async function main(): Promise<number> {
2394
+ const invocationDirectory = resolveInvocationDirectory(process.env, process.cwd());
2395
+ loadHarnessSecrets({ cwd: invocationDirectory });
2396
+ const runtimeOptions = resolveInspectRuntimeOptions(invocationDirectory);
2397
+ const daemonScriptPath = resolveScriptPath(
2398
+ process.env.HARNESS_DAEMON_SCRIPT_PATH,
2399
+ DEFAULT_DAEMON_SCRIPT_PATH,
2400
+ invocationDirectory,
2401
+ );
2402
+ const muxScriptPath = resolveScriptPath(
2403
+ process.env.HARNESS_MUX_SCRIPT_PATH,
2404
+ DEFAULT_MUX_SCRIPT_PATH,
2405
+ invocationDirectory,
2406
+ );
2407
+
2408
+ const parsedGlobals = parseGlobalCliOptions(process.argv.slice(2));
2409
+ const sessionPaths = resolveSessionPaths(invocationDirectory, parsedGlobals.sessionName);
2410
+ const argv = parsedGlobals.argv;
2411
+ if (argv.length > 0 && (argv[0] === '--help' || argv[0] === '-h')) {
2412
+ printUsage();
2413
+ return 0;
2414
+ }
2415
+
2416
+ if (argv.length > 0 && argv[0] === 'gateway') {
2417
+ if (argv.length === 1) {
2418
+ printUsage();
2419
+ return 2;
2420
+ }
2421
+ const command = parseGatewayCommand(argv.slice(1));
2422
+ return await runGatewayCommandEntry(
2423
+ command,
2424
+ invocationDirectory,
2425
+ daemonScriptPath,
2426
+ sessionPaths.recordPath,
2427
+ sessionPaths.logPath,
2428
+ sessionPaths.defaultStateDbPath,
2429
+ runtimeOptions,
2430
+ );
2431
+ }
2432
+
2433
+ if (argv.length > 0 && argv[0] === 'profile') {
2434
+ return await runProfileCommandEntry(
2435
+ invocationDirectory,
2436
+ daemonScriptPath,
2437
+ muxScriptPath,
2438
+ sessionPaths,
2439
+ argv.slice(1),
2440
+ parsedGlobals.sessionName,
2441
+ runtimeOptions,
2442
+ );
2443
+ }
2444
+
2445
+ if (argv.length > 0 && argv[0] === 'status-timeline') {
2446
+ return await runStatusTimelineCommandEntry(
2447
+ invocationDirectory,
2448
+ sessionPaths,
2449
+ argv.slice(1),
2450
+ parsedGlobals.sessionName,
2451
+ );
2452
+ }
2453
+
2454
+ if (argv.length > 0 && argv[0] === 'render-trace') {
2455
+ return await runRenderTraceCommandEntry(
2456
+ invocationDirectory,
2457
+ sessionPaths,
2458
+ argv.slice(1),
2459
+ parsedGlobals.sessionName,
2460
+ );
2461
+ }
2462
+
2463
+ if (argv.length > 0 && argv[0] === 'cursor-hooks') {
2464
+ const command = parseCursorHooksCommand(argv.slice(1));
2465
+ return await runCursorHooksCommandEntry(invocationDirectory, command);
2466
+ }
2467
+
2468
+ if (argv.length > 0 && argv[0] === 'animate') {
2469
+ return await runHarnessAnimate(argv.slice(1));
2470
+ }
2471
+
2472
+ const passthroughArgs = argv[0] === 'client' ? argv.slice(1) : argv;
2473
+ return await runDefaultClient(
2474
+ invocationDirectory,
2475
+ daemonScriptPath,
2476
+ muxScriptPath,
2477
+ sessionPaths.recordPath,
2478
+ sessionPaths.logPath,
2479
+ sessionPaths.defaultStateDbPath,
2480
+ passthroughArgs,
2481
+ parsedGlobals.sessionName,
2482
+ runtimeOptions,
2483
+ );
2484
+ }
2485
+
2486
+ try {
2487
+ process.exitCode = await main();
2488
+ } catch (error: unknown) {
2489
+ process.stderr.write(
2490
+ `harness fatal error: ${error instanceof Error ? error.message : String(error)}\n`,
2491
+ );
2492
+ process.exitCode = 1;
2493
+ }