@jmoyers/harness 0.1.11 → 0.1.20

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 (232) hide show
  1. package/README.md +31 -39
  2. package/package.json +31 -11
  3. package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
  4. package/packages/harness-ai/src/stream-text.ts +13 -91
  5. package/packages/harness-ui/src/frame-primitives.ts +158 -0
  6. package/packages/harness-ui/src/index.ts +18 -0
  7. package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
  8. package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
  9. package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
  10. package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
  11. package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
  12. package/packages/harness-ui/src/interaction/input.ts +420 -0
  13. package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
  14. package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
  15. package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
  16. package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
  17. package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
  18. package/packages/harness-ui/src/kit.ts +476 -0
  19. package/packages/harness-ui/src/layout.ts +238 -0
  20. package/packages/harness-ui/src/modal-manager.ts +222 -0
  21. package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
  22. package/packages/harness-ui/src/surface.ts +252 -0
  23. package/packages/harness-ui/src/text-layout.ts +210 -0
  24. package/packages/nim-core/src/contracts.ts +239 -0
  25. package/packages/nim-core/src/event-store.ts +299 -0
  26. package/packages/nim-core/src/events.ts +53 -0
  27. package/packages/nim-core/src/index.ts +9 -0
  28. package/packages/nim-core/src/provider-router.ts +129 -0
  29. package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
  30. package/packages/nim-core/src/runtime-factory.ts +49 -0
  31. package/packages/nim-core/src/runtime.ts +1797 -0
  32. package/packages/nim-core/src/session-store.ts +516 -0
  33. package/packages/nim-core/src/telemetry.ts +48 -0
  34. package/packages/nim-test-tui/src/index.ts +150 -0
  35. package/packages/nim-ui-core/src/index.ts +1 -0
  36. package/packages/nim-ui-core/src/projection.ts +87 -0
  37. package/scripts/codex-live-mux-runtime.ts +2 -3872
  38. package/scripts/control-plane-daemon.ts +11 -0
  39. package/scripts/harness-bin.js +5 -0
  40. package/scripts/harness-commands.ts +300 -0
  41. package/scripts/harness-runtime.ts +82 -0
  42. package/scripts/harness.ts +33 -3019
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/gateway/runtime.ts +1872 -0
  46. package/src/cli/parsing/flags.ts +23 -0
  47. package/src/cli/parsing/session.ts +42 -0
  48. package/src/cli/runtime/context.ts +193 -0
  49. package/src/cli/runtime-app/application.ts +392 -0
  50. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  51. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  52. package/src/cli/workflows/runtime.ts +965 -0
  53. package/src/clients/tui/left-rail-interactions.ts +519 -0
  54. package/src/clients/tui/main-pane-interactions.ts +509 -0
  55. package/src/clients/tui/modal-input-routing.ts +71 -0
  56. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  57. package/src/clients/web/synced-selectors.ts +132 -0
  58. package/src/codex/live-session.ts +82 -29
  59. package/src/config/config-core.ts +348 -8
  60. package/src/config/harness.config.template.jsonc +33 -0
  61. package/src/control-plane/agent-realtime-api.ts +82 -427
  62. package/src/control-plane/session-summary.ts +10 -81
  63. package/src/control-plane/status/reducer-base.ts +12 -12
  64. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  65. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  66. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  67. package/src/control-plane/stream-client.ts +12 -2
  68. package/src/control-plane/stream-command-parser.ts +83 -143
  69. package/src/control-plane/stream-protocol.ts +53 -37
  70. package/src/control-plane/stream-server-command.ts +376 -69
  71. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  72. package/src/control-plane/stream-server.ts +864 -70
  73. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  74. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  75. package/src/core/state/observed-stream-cursor.ts +43 -0
  76. package/src/core/state/synced-observed-state.ts +273 -0
  77. package/src/core/store/harness-synced-store.ts +81 -0
  78. package/src/diff/budget.ts +136 -0
  79. package/src/diff/build.ts +289 -0
  80. package/src/diff/chunker.ts +146 -0
  81. package/src/diff/git-invoke.ts +315 -0
  82. package/src/diff/git-parse.ts +472 -0
  83. package/src/diff/hash.ts +70 -0
  84. package/src/diff/index.ts +24 -0
  85. package/src/diff/normalize.ts +134 -0
  86. package/src/diff/types.ts +178 -0
  87. package/src/diff-ui/args.ts +346 -0
  88. package/src/diff-ui/commands.ts +123 -0
  89. package/src/diff-ui/finder.ts +94 -0
  90. package/src/diff-ui/highlight.ts +127 -0
  91. package/src/diff-ui/index.ts +2 -0
  92. package/src/diff-ui/model.ts +141 -0
  93. package/src/diff-ui/pager.ts +412 -0
  94. package/src/diff-ui/render.ts +337 -0
  95. package/src/diff-ui/runtime.ts +379 -0
  96. package/src/diff-ui/state.ts +224 -0
  97. package/src/diff-ui/types.ts +236 -0
  98. package/src/domain/workspace.ts +68 -5
  99. package/src/mux/control-plane-op-queue.ts +93 -7
  100. package/src/mux/conversation-rail.ts +28 -71
  101. package/src/mux/dual-pane-core.ts +13 -13
  102. package/src/mux/harness-core-ui.ts +313 -42
  103. package/src/mux/input-shortcuts.ts +13 -131
  104. package/src/mux/keybinding-catalog.ts +340 -0
  105. package/src/mux/keybinding-registry.ts +103 -0
  106. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  107. package/src/mux/live-mux/command-menu.ts +167 -4
  108. package/src/mux/live-mux/conversation-state.ts +13 -0
  109. package/src/mux/live-mux/directory-resolution.ts +1 -1
  110. package/src/mux/live-mux/git-snapshot.ts +33 -2
  111. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  112. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  113. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  114. package/src/mux/live-mux/input-forwarding.ts +59 -2
  115. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  116. package/src/mux/live-mux/left-nav.ts +35 -0
  117. package/src/mux/live-mux/link-click.ts +292 -0
  118. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  119. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  120. package/src/mux/live-mux/modal-input-reducers.ts +77 -12
  121. package/src/mux/live-mux/modal-overlays.ts +168 -34
  122. package/src/mux/live-mux/modal-pointer.ts +3 -7
  123. package/src/mux/live-mux/modal-prompt-handlers.ts +23 -2
  124. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  125. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  126. package/src/mux/live-mux/pointer-routing.ts +5 -2
  127. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  128. package/src/mux/live-mux/rail-layout.ts +33 -30
  129. package/src/mux/live-mux/release-notes.ts +383 -0
  130. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  131. package/src/mux/live-mux/repository-folding.ts +3 -0
  132. package/src/mux/live-mux/selection.ts +0 -4
  133. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  134. package/src/mux/project-pane-github-review.ts +271 -0
  135. package/src/mux/render-frame.ts +4 -0
  136. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  137. package/src/mux/task-composer.ts +21 -14
  138. package/src/mux/task-focused-pane.ts +118 -117
  139. package/src/mux/task-screen-keybindings.ts +10 -101
  140. package/src/mux/workspace-rail-model.ts +270 -104
  141. package/src/mux/workspace-rail.ts +45 -22
  142. package/src/pty/session-broker.ts +1 -1
  143. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  144. package/src/services/control-plane.ts +50 -32
  145. package/src/services/conversation-lifecycle.ts +118 -87
  146. package/src/services/conversation-startup-hydration.ts +20 -12
  147. package/src/services/directory-hydration.ts +21 -16
  148. package/src/services/event-persistence.ts +7 -0
  149. package/src/services/left-rail-pointer-handler.ts +329 -0
  150. package/src/services/mux-ui-state-persistence.ts +5 -1
  151. package/src/services/recording.ts +34 -26
  152. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  153. package/src/services/runtime-control-actions.ts +79 -61
  154. package/src/services/runtime-control-plane-ops.ts +122 -83
  155. package/src/services/runtime-conversation-actions.ts +40 -26
  156. package/src/services/runtime-conversation-activation.ts +73 -46
  157. package/src/services/runtime-conversation-starter.ts +53 -45
  158. package/src/services/runtime-conversation-title-edit.ts +91 -80
  159. package/src/services/runtime-envelope-handler.ts +107 -105
  160. package/src/services/runtime-git-state.ts +42 -29
  161. package/src/services/runtime-layout-resize.ts +3 -1
  162. package/src/services/runtime-left-rail-render.ts +99 -63
  163. package/src/services/runtime-nim-cli-session.ts +438 -0
  164. package/src/services/runtime-nim-session.ts +705 -0
  165. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  166. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  167. package/src/services/runtime-process-wiring.ts +29 -36
  168. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  169. package/src/services/runtime-render-flush.ts +63 -70
  170. package/src/services/runtime-render-lifecycle.ts +65 -64
  171. package/src/services/runtime-render-orchestrator.ts +55 -45
  172. package/src/services/runtime-render-pipeline.ts +106 -103
  173. package/src/services/runtime-render-state.ts +62 -49
  174. package/src/services/runtime-repository-actions.ts +97 -72
  175. package/src/services/runtime-right-pane-render.ts +80 -53
  176. package/src/services/runtime-shutdown.ts +38 -35
  177. package/src/services/runtime-stream-subscriptions.ts +35 -27
  178. package/src/services/runtime-task-composer-persistence.ts +71 -59
  179. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  180. package/src/services/runtime-task-editor-actions.ts +46 -29
  181. package/src/services/runtime-task-pane-actions.ts +220 -134
  182. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  183. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  184. package/src/services/runtime-workspace-observed-events.ts +33 -184
  185. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  186. package/src/services/session-diagnostics-store.ts +217 -0
  187. package/src/services/startup-background-resume.ts +26 -21
  188. package/src/services/startup-orchestrator.ts +16 -13
  189. package/src/services/startup-paint-tracker.ts +29 -21
  190. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  191. package/src/services/startup-settled-gate.ts +25 -15
  192. package/src/services/startup-shutdown.ts +18 -22
  193. package/src/services/startup-state-hydration.ts +44 -34
  194. package/src/services/startup-visibility.ts +12 -4
  195. package/src/services/task-pane-selection-actions.ts +89 -72
  196. package/src/services/task-planning-hydration.ts +24 -18
  197. package/src/services/task-planning-observed-events.ts +50 -52
  198. package/src/services/workspace-observed-events.ts +66 -63
  199. package/src/storage/storage-lifecycle-core.ts +438 -0
  200. package/src/store/control-plane-store-normalize.ts +33 -242
  201. package/src/store/control-plane-store-types.ts +1 -35
  202. package/src/store/control-plane-store.ts +360 -56
  203. package/src/store/event-store.ts +366 -8
  204. package/src/terminal/snapshot-oracle.ts +207 -94
  205. package/src/ui/mux-theme.ts +112 -8
  206. package/src/ui/panes/home-gridfire.ts +40 -31
  207. package/src/ui/panes/home.ts +10 -2
  208. package/src/ui/panes/nim.ts +315 -0
  209. package/src/mux/live-mux/actions-task.ts +0 -115
  210. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  211. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -85
  212. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  213. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  214. package/src/services/runtime-directory-actions.ts +0 -164
  215. package/src/services/runtime-input-pipeline.ts +0 -50
  216. package/src/services/runtime-input-router.ts +0 -195
  217. package/src/services/runtime-main-pane-input.ts +0 -230
  218. package/src/services/runtime-modal-input.ts +0 -137
  219. package/src/services/runtime-navigation-input.ts +0 -197
  220. package/src/services/runtime-rail-input.ts +0 -279
  221. package/src/services/runtime-task-pane.ts +0 -62
  222. package/src/services/runtime-workspace-actions.ts +0 -158
  223. package/src/ui/conversation-input-forwarder.ts +0 -114
  224. package/src/ui/conversation-selection-input.ts +0 -103
  225. package/src/ui/global-shortcut-input.ts +0 -89
  226. package/src/ui/input.ts +0 -269
  227. package/src/ui/kit.ts +0 -509
  228. package/src/ui/left-nav-input.ts +0 -80
  229. package/src/ui/left-rail-pointer-input.ts +0 -148
  230. package/src/ui/modals/manager.ts +0 -218
  231. package/src/ui/repository-fold-input.ts +0 -91
  232. package/src/ui/surface.ts +0 -224
@@ -0,0 +1,965 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { createServer as createNetServer } from 'node:net';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import type { GatewayRecord } from '../gateway-record.ts';
6
+ import {
7
+ DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
8
+ type EnsureGatewayResult,
9
+ type GatewayProbeResult,
10
+ type GatewayStartOptions,
11
+ type GatewayStopOptions,
12
+ type GatewayStopResult,
13
+ type ResolvedGatewaySettings,
14
+ } from '../gateway/runtime.ts';
15
+ import { parsePositiveIntFlag, readCliValue } from '../parsing/flags.ts';
16
+ import { GatewayControlInfra } from '../runtime-infra/gateway-control.ts';
17
+ import type { HarnessRuntimeContext } from '../runtime/context.ts';
18
+ import {
19
+ parseActiveStatusTimelineState,
20
+ STATUS_TIMELINE_MODE,
21
+ STATUS_TIMELINE_STATE_VERSION,
22
+ } from '../../mux/live-mux/status-timeline-state.ts';
23
+ import {
24
+ parseActiveRenderTraceState,
25
+ RENDER_TRACE_MODE,
26
+ RENDER_TRACE_STATE_VERSION,
27
+ } from '../../mux/live-mux/render-trace-state.ts';
28
+ import {
29
+ buildInspectorProfileStartExpression,
30
+ buildInspectorProfileStopExpression,
31
+ connectGatewayInspector,
32
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
33
+ evaluateInspectorExpression,
34
+ InspectorWebSocketClient,
35
+ readInspectorProfileState,
36
+ type InspectorProfileState,
37
+ } from './inspector.ts';
38
+
39
+ const DEFAULT_GATEWAY_STOP_POLL_MS = 50;
40
+ const PROFILE_STATE_VERSION = 2;
41
+ const PROFILE_LIVE_INSPECT_MODE = 'live-inspector';
42
+ const PROFILE_CLIENT_FILE_NAME = 'client.cpuprofile';
43
+ const PROFILE_GATEWAY_FILE_NAME = 'gateway.cpuprofile';
44
+
45
+ interface ProfileStopOptions {
46
+ readonly timeoutMs: number;
47
+ }
48
+
49
+ interface ParsedProfileRunCommand {
50
+ readonly type: 'run';
51
+ readonly profileDir: string | null;
52
+ readonly muxArgs: readonly string[];
53
+ }
54
+
55
+ interface ParsedProfileStartCommand {
56
+ readonly type: 'start';
57
+ readonly profileDir: string | null;
58
+ }
59
+
60
+ interface ParsedProfileStopCommand {
61
+ readonly type: 'stop';
62
+ readonly stopOptions: ProfileStopOptions;
63
+ }
64
+
65
+ type ParsedProfileCommand =
66
+ | ParsedProfileRunCommand
67
+ | ParsedProfileStartCommand
68
+ | ParsedProfileStopCommand;
69
+
70
+ interface ParsedStatusTimelineStartCommand {
71
+ readonly type: 'start';
72
+ readonly outputPath: string | null;
73
+ }
74
+
75
+ interface ParsedStatusTimelineStopCommand {
76
+ readonly type: 'stop';
77
+ }
78
+
79
+ type ParsedStatusTimelineCommand =
80
+ | ParsedStatusTimelineStartCommand
81
+ | ParsedStatusTimelineStopCommand;
82
+
83
+ interface ParsedRenderTraceStartCommand {
84
+ readonly type: 'start';
85
+ readonly outputPath: string | null;
86
+ readonly conversationId: string | null;
87
+ }
88
+
89
+ interface ParsedRenderTraceStopCommand {
90
+ readonly type: 'stop';
91
+ }
92
+
93
+ type ParsedRenderTraceCommand = ParsedRenderTraceStartCommand | ParsedRenderTraceStopCommand;
94
+
95
+ interface RuntimeCpuProfileOptions {
96
+ readonly cpuProfileDir: string;
97
+ readonly cpuProfileName: string;
98
+ }
99
+
100
+ interface ActiveProfileState {
101
+ readonly version: number;
102
+ readonly mode: typeof PROFILE_LIVE_INSPECT_MODE;
103
+ readonly pid: number;
104
+ readonly host: string;
105
+ readonly port: number;
106
+ readonly stateDbPath: string;
107
+ readonly profileDir: string;
108
+ readonly gatewayProfilePath: string;
109
+ readonly inspectWebSocketUrl: string;
110
+ readonly startedAt: string;
111
+ }
112
+
113
+ interface ActiveStatusTimelineState {
114
+ readonly version: number;
115
+ readonly mode: typeof STATUS_TIMELINE_MODE;
116
+ readonly outputPath: string;
117
+ readonly sessionName: string | null;
118
+ readonly startedAt: string;
119
+ }
120
+
121
+ interface ActiveRenderTraceState {
122
+ readonly version: number;
123
+ readonly mode: typeof RENDER_TRACE_MODE;
124
+ readonly outputPath: string;
125
+ readonly sessionName: string | null;
126
+ readonly conversationId: string | null;
127
+ readonly startedAt: string;
128
+ }
129
+
130
+ interface GatewayRuntimeFacade {
131
+ withLock<T>(operation: () => Promise<T>): Promise<T>;
132
+ ensureGatewayRunning(overrides?: GatewayStartOptions): Promise<EnsureGatewayResult>;
133
+ runMuxClient(
134
+ record: GatewayRecord,
135
+ muxArgs: readonly string[],
136
+ runtimeArgs?: readonly string[],
137
+ ): Promise<number>;
138
+ isPidRunning(pid: number): boolean;
139
+ readGatewayRecord(): GatewayRecord | null;
140
+ probeGateway(record: GatewayRecord): Promise<GatewayProbeResult>;
141
+ removeGatewayRecord(): void;
142
+ resolveGatewayHostFromConfigOrEnv(): string;
143
+ reservePort(host: string): Promise<number>;
144
+ resolveGatewaySettings(
145
+ record: GatewayRecord | null,
146
+ overrides: GatewayStartOptions,
147
+ ): ResolvedGatewaySettings;
148
+ startDetachedGateway(
149
+ settings: ResolvedGatewaySettings,
150
+ runtimeArgs?: readonly string[],
151
+ ): Promise<GatewayRecord>;
152
+ stopGateway(options: GatewayStopOptions): Promise<GatewayStopResult>;
153
+ waitForFileExists(filePath: string, timeoutMs: number): Promise<boolean>;
154
+ }
155
+
156
+ class ProfileCommandParser {
157
+ public constructor() {}
158
+
159
+ private parseRunCommand(argv: readonly string[]): ParsedProfileRunCommand {
160
+ let profileDir: string | null = null;
161
+ const muxArgs: string[] = [];
162
+ for (let index = 0; index < argv.length; index += 1) {
163
+ const arg = argv[index]!;
164
+ if (arg === '--profile-dir') {
165
+ profileDir = readCliValue(argv, index, '--profile-dir');
166
+ index += 1;
167
+ continue;
168
+ }
169
+ muxArgs.push(arg);
170
+ }
171
+ return {
172
+ type: 'run',
173
+ profileDir,
174
+ muxArgs,
175
+ };
176
+ }
177
+
178
+ private parseStartCommand(argv: readonly string[]): ParsedProfileStartCommand {
179
+ let profileDir: string | null = null;
180
+ for (let index = 0; index < argv.length; index += 1) {
181
+ const arg = argv[index]!;
182
+ if (arg === '--profile-dir') {
183
+ profileDir = readCliValue(argv, index, '--profile-dir');
184
+ index += 1;
185
+ continue;
186
+ }
187
+ throw new Error(`unknown profile option: ${arg}`);
188
+ }
189
+ return {
190
+ type: 'start',
191
+ profileDir,
192
+ };
193
+ }
194
+
195
+ private parseStopOptions(argv: readonly string[]): ProfileStopOptions {
196
+ let timeoutMs = DEFAULT_GATEWAY_STOP_TIMEOUT_MS;
197
+ for (let index = 0; index < argv.length; index += 1) {
198
+ const arg = argv[index]!;
199
+ if (arg === '--timeout-ms') {
200
+ timeoutMs = parsePositiveIntFlag(readCliValue(argv, index, '--timeout-ms'), '--timeout-ms');
201
+ index += 1;
202
+ continue;
203
+ }
204
+ throw new Error(`unknown profile option: ${arg}`);
205
+ }
206
+ return { timeoutMs };
207
+ }
208
+
209
+ public parse(argv: readonly string[]): ParsedProfileCommand {
210
+ if (argv.length === 0) {
211
+ return this.parseRunCommand(argv);
212
+ }
213
+ const subcommand = argv[0]!;
214
+ const rest = argv.slice(1);
215
+ if (subcommand === 'start') {
216
+ return this.parseStartCommand(rest);
217
+ }
218
+ if (subcommand === 'stop') {
219
+ return {
220
+ type: 'stop',
221
+ stopOptions: this.parseStopOptions(rest),
222
+ };
223
+ }
224
+ if (subcommand === 'run') {
225
+ return this.parseRunCommand(rest);
226
+ }
227
+ if (subcommand.startsWith('-')) {
228
+ return this.parseRunCommand(argv);
229
+ }
230
+ return this.parseRunCommand(argv);
231
+ }
232
+ }
233
+
234
+ class StatusTimelineCommandParser {
235
+ public constructor() {}
236
+
237
+ private parseStartCommand(argv: readonly string[]): ParsedStatusTimelineStartCommand {
238
+ let outputPath: string | null = null;
239
+ for (let index = 0; index < argv.length; index += 1) {
240
+ const arg = argv[index]!;
241
+ if (arg === '--output-path') {
242
+ outputPath = readCliValue(argv, index, '--output-path');
243
+ index += 1;
244
+ continue;
245
+ }
246
+ throw new Error(`unknown status-timeline option: ${arg}`);
247
+ }
248
+ return {
249
+ type: 'start',
250
+ outputPath,
251
+ };
252
+ }
253
+
254
+ private parseStopCommand(argv: readonly string[]): ParsedStatusTimelineStopCommand {
255
+ if (argv.length > 0) {
256
+ throw new Error(`unknown status-timeline option: ${argv[0]}`);
257
+ }
258
+ return { type: 'stop' };
259
+ }
260
+
261
+ public parse(argv: readonly string[]): ParsedStatusTimelineCommand {
262
+ if (argv.length === 0) {
263
+ return this.parseStartCommand(argv);
264
+ }
265
+ const subcommand = argv[0]!;
266
+ const rest = argv.slice(1);
267
+ if (subcommand === 'start') {
268
+ return this.parseStartCommand(rest);
269
+ }
270
+ if (subcommand === 'stop') {
271
+ return this.parseStopCommand(rest);
272
+ }
273
+ if (subcommand.startsWith('-')) {
274
+ return this.parseStartCommand(argv);
275
+ }
276
+ throw new Error(`unknown status-timeline subcommand: ${subcommand}`);
277
+ }
278
+ }
279
+
280
+ class RenderTraceCommandParser {
281
+ public constructor() {}
282
+
283
+ private parseStartCommand(argv: readonly string[]): ParsedRenderTraceStartCommand {
284
+ let outputPath: string | null = null;
285
+ let conversationId: string | null = null;
286
+ for (let index = 0; index < argv.length; index += 1) {
287
+ const arg = argv[index]!;
288
+ if (arg === '--output-path') {
289
+ outputPath = readCliValue(argv, index, '--output-path');
290
+ index += 1;
291
+ continue;
292
+ }
293
+ if (arg === '--conversation-id') {
294
+ const value = readCliValue(argv, index, '--conversation-id').trim();
295
+ if (value.length === 0) {
296
+ throw new Error('invalid --conversation-id value: empty string');
297
+ }
298
+ conversationId = value;
299
+ index += 1;
300
+ continue;
301
+ }
302
+ throw new Error(`unknown render-trace option: ${arg}`);
303
+ }
304
+ return {
305
+ type: 'start',
306
+ outputPath,
307
+ conversationId,
308
+ };
309
+ }
310
+
311
+ private parseStopCommand(argv: readonly string[]): ParsedRenderTraceStopCommand {
312
+ if (argv.length > 0) {
313
+ throw new Error(`unknown render-trace option: ${argv[0]}`);
314
+ }
315
+ return { type: 'stop' };
316
+ }
317
+
318
+ public parse(argv: readonly string[]): ParsedRenderTraceCommand {
319
+ if (argv.length === 0) {
320
+ return this.parseStartCommand(argv);
321
+ }
322
+ const subcommand = argv[0]!;
323
+ const rest = argv.slice(1);
324
+ if (subcommand === 'start') {
325
+ return this.parseStartCommand(rest);
326
+ }
327
+ if (subcommand === 'stop') {
328
+ return this.parseStopCommand(rest);
329
+ }
330
+ if (subcommand.startsWith('-')) {
331
+ return this.parseStartCommand(argv);
332
+ }
333
+ throw new Error(`unknown render-trace subcommand: ${subcommand}`);
334
+ }
335
+ }
336
+
337
+ function buildCpuProfileRuntimeArgs(options: RuntimeCpuProfileOptions): readonly string[] {
338
+ return [
339
+ '--cpu-prof',
340
+ '--cpu-prof-dir',
341
+ options.cpuProfileDir,
342
+ '--cpu-prof-name',
343
+ options.cpuProfileName,
344
+ ];
345
+ }
346
+
347
+ export class WorkflowRuntimeService {
348
+ private readonly profileParser = new ProfileCommandParser();
349
+ private readonly statusTimelineParser = new StatusTimelineCommandParser();
350
+ private readonly renderTraceParser = new RenderTraceCommandParser();
351
+
352
+ constructor(
353
+ private readonly runtime: HarnessRuntimeContext,
354
+ private readonly gatewayService: GatewayRuntimeFacade,
355
+ private readonly infra: GatewayControlInfra = new GatewayControlInfra(),
356
+ private readonly writeStdout: (text: string) => void = (text) => {
357
+ process.stdout.write(text);
358
+ },
359
+ ) {}
360
+
361
+ private parseInspectRuntimeArg(
362
+ runtimeArg: string,
363
+ ): { host: string; port: number; flag: '--inspect' | '--inspect-brk' } | null {
364
+ const match = runtimeArg.match(
365
+ /^--(?<flag>inspect|inspect-brk)=(?<host>[^:]+):(?<port>\d+)(?:\/.*)?$/u,
366
+ );
367
+ if (!match?.groups) {
368
+ return null;
369
+ }
370
+ const host = match.groups['host'];
371
+ const portRaw = match.groups['port'];
372
+ const flag = match.groups['flag'];
373
+ if (host === undefined || portRaw === undefined || flag === undefined) {
374
+ return null;
375
+ }
376
+ const port = Number.parseInt(portRaw, 10);
377
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
378
+ return null;
379
+ }
380
+ if (flag !== 'inspect' && flag !== 'inspect-brk') {
381
+ return null;
382
+ }
383
+ return {
384
+ host,
385
+ port,
386
+ flag: `--${flag}`,
387
+ };
388
+ }
389
+
390
+ private async canBindPort(host: string, port: number): Promise<boolean> {
391
+ return await new Promise<boolean>((resolveCanBind, rejectCanBind) => {
392
+ const server = createNetServer();
393
+ server.unref();
394
+ server.once('error', (error: unknown) => {
395
+ const code = (error as NodeJS.ErrnoException).code;
396
+ if (code === 'EADDRINUSE') {
397
+ resolveCanBind(false);
398
+ return;
399
+ }
400
+ rejectCanBind(error);
401
+ });
402
+ server.listen(port, host, () => {
403
+ server.close((error) => {
404
+ if (error !== undefined) {
405
+ rejectCanBind(error);
406
+ return;
407
+ }
408
+ resolveCanBind(true);
409
+ });
410
+ });
411
+ });
412
+ }
413
+
414
+ private async resolveClientRuntimeArgs(
415
+ runtimeArgs: readonly string[],
416
+ ): Promise<readonly string[]> {
417
+ const inspectArg = runtimeArgs.findLast((arg) => this.parseInspectRuntimeArg(arg) !== null);
418
+ if (inspectArg === undefined) {
419
+ return runtimeArgs;
420
+ }
421
+ const inspect = this.parseInspectRuntimeArg(inspectArg);
422
+ if (inspect === null) {
423
+ return runtimeArgs;
424
+ }
425
+ const canBind = await this.canBindPort(inspect.host, inspect.port);
426
+ if (canBind) {
427
+ return runtimeArgs;
428
+ }
429
+ this.writeStdout(
430
+ `warning: client inspector ${inspect.host}:${String(inspect.port)} is already in use; continuing without inspector\n`,
431
+ );
432
+ return runtimeArgs.filter((arg) => this.parseInspectRuntimeArg(arg) === null);
433
+ }
434
+
435
+ public async runDefaultClient(args: readonly string[]): Promise<number> {
436
+ const ensured = await this.gatewayService.withLock(
437
+ async () => await this.gatewayService.ensureGatewayRunning({}),
438
+ );
439
+ if (ensured.started) {
440
+ this.writeStdout(
441
+ `gateway started pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
442
+ );
443
+ }
444
+ return await this.gatewayService.runMuxClient(
445
+ ensured.record,
446
+ args,
447
+ await this.resolveClientRuntimeArgs(this.runtime.runtimeOptions.clientRuntimeArgs),
448
+ );
449
+ }
450
+
451
+ public async runProfileCli(args: readonly string[]): Promise<number> {
452
+ const command = this.profileParser.parse(args);
453
+ if (command.type === 'start') {
454
+ return await this.runProfileStart(command);
455
+ }
456
+ if (command.type === 'stop') {
457
+ return await this.runProfileStop(command);
458
+ }
459
+ return await this.runProfileRun(command);
460
+ }
461
+
462
+ public async runStatusTimelineCli(args: readonly string[]): Promise<number> {
463
+ const command = this.statusTimelineParser.parse(args);
464
+ if (command.type === 'stop') {
465
+ return await this.runStatusTimelineStop();
466
+ }
467
+ return await this.runStatusTimelineStart(command);
468
+ }
469
+
470
+ public async runRenderTraceCli(args: readonly string[]): Promise<number> {
471
+ const command = this.renderTraceParser.parse(args);
472
+ if (command.type === 'stop') {
473
+ return await this.runRenderTraceStop();
474
+ }
475
+ return await this.runRenderTraceStart(command);
476
+ }
477
+
478
+ private removeFileIfExists(filePath: string): void {
479
+ try {
480
+ unlinkSync(filePath);
481
+ } catch (error: unknown) {
482
+ const code = (error as NodeJS.ErrnoException).code;
483
+ if (code !== 'ENOENT') {
484
+ throw error;
485
+ }
486
+ }
487
+ }
488
+
489
+ private parseActiveProfileState(raw: unknown): ActiveProfileState | null {
490
+ if (typeof raw !== 'object' || raw === null) {
491
+ return null;
492
+ }
493
+ const candidate = raw as Record<string, unknown>;
494
+ if (candidate['version'] !== PROFILE_STATE_VERSION) {
495
+ return null;
496
+ }
497
+ if (candidate['mode'] !== PROFILE_LIVE_INSPECT_MODE) {
498
+ return null;
499
+ }
500
+ const pid = candidate['pid'];
501
+ const host = candidate['host'];
502
+ const port = candidate['port'];
503
+ const stateDbPath = candidate['stateDbPath'];
504
+ const profileDir = candidate['profileDir'];
505
+ const gatewayProfilePath = candidate['gatewayProfilePath'];
506
+ const inspectWebSocketUrl = candidate['inspectWebSocketUrl'];
507
+ const startedAt = candidate['startedAt'];
508
+ if (!Number.isInteger(pid) || (pid as number) <= 0) {
509
+ return null;
510
+ }
511
+ if (typeof host !== 'string' || host.length === 0) {
512
+ return null;
513
+ }
514
+ if (!Number.isInteger(port) || (port as number) <= 0 || (port as number) > 65535) {
515
+ return null;
516
+ }
517
+ if (typeof stateDbPath !== 'string' || stateDbPath.length === 0) {
518
+ return null;
519
+ }
520
+ if (typeof profileDir !== 'string' || profileDir.length === 0) {
521
+ return null;
522
+ }
523
+ if (typeof gatewayProfilePath !== 'string' || gatewayProfilePath.length === 0) {
524
+ return null;
525
+ }
526
+ if (typeof inspectWebSocketUrl !== 'string' || inspectWebSocketUrl.length === 0) {
527
+ return null;
528
+ }
529
+ if (typeof startedAt !== 'string' || startedAt.length === 0) {
530
+ return null;
531
+ }
532
+ return {
533
+ version: PROFILE_STATE_VERSION,
534
+ mode: PROFILE_LIVE_INSPECT_MODE,
535
+ pid: pid as number,
536
+ host,
537
+ port: port as number,
538
+ stateDbPath,
539
+ profileDir,
540
+ gatewayProfilePath,
541
+ inspectWebSocketUrl,
542
+ startedAt,
543
+ };
544
+ }
545
+
546
+ private readActiveProfileState(profileStatePath: string): ActiveProfileState | null {
547
+ if (!existsSync(profileStatePath)) {
548
+ return null;
549
+ }
550
+ try {
551
+ const raw = JSON.parse(readFileSync(profileStatePath, 'utf8')) as unknown;
552
+ return this.parseActiveProfileState(raw);
553
+ } catch {
554
+ return null;
555
+ }
556
+ }
557
+
558
+ private writeActiveProfileState(profileStatePath: string, state: ActiveProfileState): void {
559
+ this.infra.writeTextFileAtomically(profileStatePath, `${JSON.stringify(state, null, 2)}\n`);
560
+ }
561
+
562
+ private removeActiveProfileState(profileStatePath: string): void {
563
+ this.removeFileIfExists(profileStatePath);
564
+ }
565
+
566
+ private readActiveStatusTimelineState(statePath: string): ActiveStatusTimelineState | null {
567
+ if (!existsSync(statePath)) {
568
+ return null;
569
+ }
570
+ try {
571
+ const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
572
+ const parsed = parseActiveStatusTimelineState(raw);
573
+ if (parsed === null) {
574
+ return null;
575
+ }
576
+ return {
577
+ version: parsed.version,
578
+ mode: parsed.mode,
579
+ outputPath: parsed.outputPath,
580
+ sessionName: parsed.sessionName,
581
+ startedAt: parsed.startedAt,
582
+ };
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+
588
+ private writeActiveStatusTimelineState(
589
+ statePath: string,
590
+ state: ActiveStatusTimelineState,
591
+ ): void {
592
+ this.infra.writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
593
+ }
594
+
595
+ private removeActiveStatusTimelineState(statePath: string): void {
596
+ this.removeFileIfExists(statePath);
597
+ }
598
+
599
+ private readActiveRenderTraceState(statePath: string): ActiveRenderTraceState | null {
600
+ if (!existsSync(statePath)) {
601
+ return null;
602
+ }
603
+ try {
604
+ const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
605
+ const parsed = parseActiveRenderTraceState(raw);
606
+ if (parsed === null) {
607
+ return null;
608
+ }
609
+ return {
610
+ version: parsed.version,
611
+ mode: parsed.mode,
612
+ outputPath: parsed.outputPath,
613
+ sessionName: parsed.sessionName,
614
+ conversationId: parsed.conversationId,
615
+ startedAt: parsed.startedAt,
616
+ };
617
+ } catch {
618
+ return null;
619
+ }
620
+ }
621
+
622
+ private writeActiveRenderTraceState(statePath: string, state: ActiveRenderTraceState): void {
623
+ this.infra.writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
624
+ }
625
+
626
+ private removeActiveRenderTraceState(statePath: string): void {
627
+ this.removeFileIfExists(statePath);
628
+ }
629
+
630
+ private async runProfileRun(command: ParsedProfileRunCommand): Promise<number> {
631
+ const { invocationDirectory } = this.runtime;
632
+ const profileDir =
633
+ command.profileDir === null
634
+ ? this.runtime.profileDir
635
+ : resolve(invocationDirectory, command.profileDir);
636
+ mkdirSync(profileDir, { recursive: true });
637
+
638
+ const clientProfilePath = resolve(profileDir, PROFILE_CLIENT_FILE_NAME);
639
+ const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
640
+ this.removeFileIfExists(clientProfilePath);
641
+ this.removeFileIfExists(gatewayProfilePath);
642
+
643
+ const existingProfileState = this.readActiveProfileState(this.runtime.profileStatePath);
644
+ if (existingProfileState !== null) {
645
+ if (this.gatewayService.isPidRunning(existingProfileState.pid)) {
646
+ throw new Error(
647
+ 'profile run requires no active profile session; stop it first with `harness profile stop`',
648
+ );
649
+ }
650
+ this.removeActiveProfileState(this.runtime.profileStatePath);
651
+ }
652
+
653
+ const gateway = await this.gatewayService.withLock(async () => {
654
+ const existingRecord = this.gatewayService.readGatewayRecord();
655
+ if (existingRecord !== null) {
656
+ const existingProbe = await this.gatewayService.probeGateway(existingRecord);
657
+ if (existingProbe.connected || this.gatewayService.isPidRunning(existingRecord.pid)) {
658
+ throw new Error(
659
+ 'profile command requires the target session gateway to be stopped first',
660
+ );
661
+ }
662
+ this.gatewayService.removeGatewayRecord();
663
+ }
664
+
665
+ const host = this.gatewayService.resolveGatewayHostFromConfigOrEnv();
666
+ const reservedPort = await this.gatewayService.reservePort(host);
667
+ const settings = this.gatewayService.resolveGatewaySettings(null, {
668
+ port: reservedPort,
669
+ stateDbPath: this.runtime.gatewayDefaultStateDbPath,
670
+ });
671
+
672
+ return await this.gatewayService.startDetachedGateway(settings, [
673
+ ...this.runtime.runtimeOptions.gatewayRuntimeArgs,
674
+ ...buildCpuProfileRuntimeArgs({
675
+ cpuProfileDir: profileDir,
676
+ cpuProfileName: PROFILE_GATEWAY_FILE_NAME,
677
+ }),
678
+ ]);
679
+ });
680
+
681
+ let clientExitCode = 1;
682
+ let clientError: Error | null = null;
683
+ try {
684
+ const clientRuntimeArgs = await this.resolveClientRuntimeArgs(
685
+ this.runtime.runtimeOptions.clientRuntimeArgs,
686
+ );
687
+ clientExitCode = await this.gatewayService.runMuxClient(gateway, command.muxArgs, [
688
+ ...clientRuntimeArgs,
689
+ ...buildCpuProfileRuntimeArgs({
690
+ cpuProfileDir: profileDir,
691
+ cpuProfileName: PROFILE_CLIENT_FILE_NAME,
692
+ }),
693
+ ]);
694
+ } catch (error: unknown) {
695
+ clientError = error instanceof Error ? error : new Error(String(error));
696
+ }
697
+
698
+ const stopped = await this.gatewayService.withLock(
699
+ async () =>
700
+ await this.gatewayService.stopGateway({
701
+ force: true,
702
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
703
+ cleanupOrphans: true,
704
+ }),
705
+ );
706
+ this.writeStdout(`${stopped.message}\n`);
707
+ if (!stopped.stopped) {
708
+ throw new Error(`failed to stop profile gateway: ${stopped.message}`);
709
+ }
710
+ if (clientError !== null) {
711
+ throw clientError;
712
+ }
713
+ if (!existsSync(clientProfilePath)) {
714
+ throw new Error(`missing client CPU profile: ${clientProfilePath}`);
715
+ }
716
+ if (!existsSync(gatewayProfilePath)) {
717
+ throw new Error(`missing gateway CPU profile: ${gatewayProfilePath}`);
718
+ }
719
+
720
+ this.writeStdout(`profiles: client=${clientProfilePath} gateway=${gatewayProfilePath}\n`);
721
+ return clientExitCode;
722
+ }
723
+
724
+ private async runProfileStart(command: ParsedProfileStartCommand): Promise<number> {
725
+ const { invocationDirectory } = this.runtime;
726
+ const profileDir =
727
+ command.profileDir === null
728
+ ? this.runtime.profileDir
729
+ : resolve(invocationDirectory, command.profileDir);
730
+ mkdirSync(profileDir, { recursive: true });
731
+ const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
732
+ this.removeFileIfExists(gatewayProfilePath);
733
+
734
+ const existingProfileState = this.readActiveProfileState(this.runtime.profileStatePath);
735
+ if (existingProfileState !== null) {
736
+ if (this.gatewayService.isPidRunning(existingProfileState.pid)) {
737
+ throw new Error('profile already running; stop it first with `harness profile stop`');
738
+ }
739
+ this.removeActiveProfileState(this.runtime.profileStatePath);
740
+ }
741
+
742
+ const existingRecord = this.gatewayService.readGatewayRecord();
743
+ if (existingRecord === null) {
744
+ throw new Error('profile start requires the target session gateway to be running');
745
+ }
746
+ const existingProbe = await this.gatewayService.probeGateway(existingRecord);
747
+ if (!existingProbe.connected || !this.gatewayService.isPidRunning(existingRecord.pid)) {
748
+ throw new Error('profile start requires the target session gateway to be running');
749
+ }
750
+ const inspector = await connectGatewayInspector(
751
+ invocationDirectory,
752
+ this.runtime.gatewayLogPath,
753
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
754
+ );
755
+ try {
756
+ const startCommandRaw = await evaluateInspectorExpression(
757
+ inspector.client,
758
+ buildInspectorProfileStartExpression(),
759
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
760
+ );
761
+ if (typeof startCommandRaw !== 'string') {
762
+ throw new Error('failed to start gateway profiler (invalid inspector response)');
763
+ }
764
+ const startCommandResult = JSON.parse(startCommandRaw) as Record<string, unknown>;
765
+ if (startCommandResult['ok'] !== true) {
766
+ const reason = startCommandResult['reason'];
767
+ throw new Error(
768
+ `failed to start gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
769
+ );
770
+ }
771
+
772
+ const startDeadline = Date.now() + DEFAULT_PROFILE_INSPECT_TIMEOUT_MS;
773
+ let runningState: InspectorProfileState | null = null;
774
+ while (Date.now() < startDeadline) {
775
+ const state = await readInspectorProfileState(
776
+ inspector.client,
777
+ DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
778
+ );
779
+ if (state !== null && state.status === 'running') {
780
+ runningState = state;
781
+ break;
782
+ }
783
+ if (state !== null && state.status === 'failed') {
784
+ throw new Error(`failed to start gateway profiler (${state.error ?? 'unknown error'})`);
785
+ }
786
+ await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
787
+ }
788
+ if (runningState === null) {
789
+ throw new Error('failed to start gateway profiler (inspector runtime timeout)');
790
+ }
791
+ } finally {
792
+ inspector.client.close();
793
+ }
794
+
795
+ this.writeActiveProfileState(this.runtime.profileStatePath, {
796
+ version: PROFILE_STATE_VERSION,
797
+ mode: PROFILE_LIVE_INSPECT_MODE,
798
+ pid: existingRecord.pid,
799
+ host: existingRecord.host,
800
+ port: existingRecord.port,
801
+ stateDbPath: existingRecord.stateDbPath,
802
+ profileDir,
803
+ gatewayProfilePath,
804
+ inspectWebSocketUrl: inspector.endpoint,
805
+ startedAt: new Date().toISOString(),
806
+ });
807
+
808
+ this.writeStdout(
809
+ `profile started pid=${String(existingRecord.pid)} host=${existingRecord.host} port=${String(existingRecord.port)}\n`,
810
+ );
811
+ this.writeStdout(`record: ${this.runtime.gatewayRecordPath}\n`);
812
+ this.writeStdout(`log: ${this.runtime.gatewayLogPath}\n`);
813
+ this.writeStdout(`profile-state: ${this.runtime.profileStatePath}\n`);
814
+ this.writeStdout(`profile-target: ${gatewayProfilePath}\n`);
815
+ this.writeStdout('stop with: harness profile stop\n');
816
+ return 0;
817
+ }
818
+
819
+ private async runProfileStop(command: ParsedProfileStopCommand): Promise<number> {
820
+ const profileState = this.readActiveProfileState(this.runtime.profileStatePath);
821
+ if (profileState === null) {
822
+ throw new Error(
823
+ 'no active profile run for this session; start one with `harness profile start`',
824
+ );
825
+ }
826
+ if (profileState.mode !== PROFILE_LIVE_INSPECT_MODE) {
827
+ throw new Error('active profile run is incompatible with this harness version');
828
+ }
829
+ const inspector = await InspectorWebSocketClient.connect(
830
+ profileState.inspectWebSocketUrl,
831
+ command.stopOptions.timeoutMs,
832
+ );
833
+ try {
834
+ await inspector.sendCommand('Runtime.enable', {}, command.stopOptions.timeoutMs);
835
+ const stopCommandRaw = await evaluateInspectorExpression(
836
+ inspector,
837
+ buildInspectorProfileStopExpression(
838
+ profileState.gatewayProfilePath,
839
+ profileState.profileDir,
840
+ ),
841
+ command.stopOptions.timeoutMs,
842
+ );
843
+ if (typeof stopCommandRaw !== 'string') {
844
+ throw new Error('failed to stop gateway profiler (invalid inspector response)');
845
+ }
846
+ const stopCommandResult = JSON.parse(stopCommandRaw) as Record<string, unknown>;
847
+ if (stopCommandResult['ok'] !== true) {
848
+ const reason = stopCommandResult['reason'];
849
+ throw new Error(
850
+ `failed to stop gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
851
+ );
852
+ }
853
+
854
+ const startedAt = Date.now();
855
+ while (Date.now() - startedAt < command.stopOptions.timeoutMs) {
856
+ const state = await readInspectorProfileState(inspector, command.stopOptions.timeoutMs);
857
+ if (state !== null && state.status === 'failed') {
858
+ throw new Error(`failed to stop gateway profiler (${state.error ?? 'unknown error'})`);
859
+ }
860
+ if (state !== null && state.status === 'stopped' && state.written) {
861
+ break;
862
+ }
863
+ await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
864
+ }
865
+ } finally {
866
+ inspector.close();
867
+ }
868
+
869
+ const profileFlushed = await this.gatewayService.waitForFileExists(
870
+ profileState.gatewayProfilePath,
871
+ command.stopOptions.timeoutMs,
872
+ );
873
+ if (!profileFlushed) {
874
+ throw new Error(`missing gateway CPU profile: ${profileState.gatewayProfilePath}`);
875
+ }
876
+
877
+ this.removeActiveProfileState(this.runtime.profileStatePath);
878
+ this.writeStdout(`profile: gateway=${profileState.gatewayProfilePath}\n`);
879
+ return 0;
880
+ }
881
+
882
+ private async runStatusTimelineStart(command: ParsedStatusTimelineStartCommand): Promise<number> {
883
+ const { invocationDirectory, sessionName } = this.runtime;
884
+ const outputPath =
885
+ command.outputPath === null
886
+ ? this.runtime.defaultStatusTimelineOutputPath
887
+ : resolve(invocationDirectory, command.outputPath);
888
+ const existingState = this.readActiveStatusTimelineState(this.runtime.statusTimelineStatePath);
889
+ if (existingState !== null) {
890
+ throw new Error(
891
+ 'status timeline already running; stop it first with `harness status-timeline stop`',
892
+ );
893
+ }
894
+ mkdirSync(dirname(outputPath), { recursive: true });
895
+ writeFileSync(outputPath, '', 'utf8');
896
+ this.writeActiveStatusTimelineState(this.runtime.statusTimelineStatePath, {
897
+ version: STATUS_TIMELINE_STATE_VERSION,
898
+ mode: STATUS_TIMELINE_MODE,
899
+ outputPath,
900
+ sessionName,
901
+ startedAt: new Date().toISOString(),
902
+ });
903
+ this.writeStdout('status timeline started\n');
904
+ this.writeStdout(`status-timeline-state: ${this.runtime.statusTimelineStatePath}\n`);
905
+ this.writeStdout(`status-timeline-target: ${outputPath}\n`);
906
+ this.writeStdout('stop with: harness status-timeline stop\n');
907
+ return 0;
908
+ }
909
+
910
+ private async runStatusTimelineStop(): Promise<number> {
911
+ const state = this.readActiveStatusTimelineState(this.runtime.statusTimelineStatePath);
912
+ if (state === null) {
913
+ throw new Error(
914
+ 'no active status timeline run for this session; start one with `harness status-timeline start`',
915
+ );
916
+ }
917
+ this.removeActiveStatusTimelineState(this.runtime.statusTimelineStatePath);
918
+ this.writeStdout(`status timeline stopped: ${state.outputPath}\n`);
919
+ return 0;
920
+ }
921
+
922
+ private async runRenderTraceStart(command: ParsedRenderTraceStartCommand): Promise<number> {
923
+ const { invocationDirectory, sessionName } = this.runtime;
924
+ const outputPath =
925
+ command.outputPath === null
926
+ ? this.runtime.defaultRenderTraceOutputPath
927
+ : resolve(invocationDirectory, command.outputPath);
928
+ const existingState = this.readActiveRenderTraceState(this.runtime.renderTraceStatePath);
929
+ if (existingState !== null) {
930
+ throw new Error(
931
+ 'render trace already running; stop it first with `harness render-trace stop`',
932
+ );
933
+ }
934
+ mkdirSync(dirname(outputPath), { recursive: true });
935
+ writeFileSync(outputPath, '', 'utf8');
936
+ this.writeActiveRenderTraceState(this.runtime.renderTraceStatePath, {
937
+ version: RENDER_TRACE_STATE_VERSION,
938
+ mode: RENDER_TRACE_MODE,
939
+ outputPath,
940
+ sessionName,
941
+ conversationId: command.conversationId,
942
+ startedAt: new Date().toISOString(),
943
+ });
944
+ this.writeStdout('render trace started\n');
945
+ this.writeStdout(`render-trace-state: ${this.runtime.renderTraceStatePath}\n`);
946
+ this.writeStdout(`render-trace-target: ${outputPath}\n`);
947
+ if (command.conversationId !== null) {
948
+ this.writeStdout(`render-trace-conversation-id: ${command.conversationId}\n`);
949
+ }
950
+ this.writeStdout('stop with: harness render-trace stop\n');
951
+ return 0;
952
+ }
953
+
954
+ private async runRenderTraceStop(): Promise<number> {
955
+ const state = this.readActiveRenderTraceState(this.runtime.renderTraceStatePath);
956
+ if (state === null) {
957
+ throw new Error(
958
+ 'no active render trace run for this session; start one with `harness render-trace start`',
959
+ );
960
+ }
961
+ this.removeActiveRenderTraceState(this.runtime.renderTraceStatePath);
962
+ this.writeStdout(`render trace stopped: ${state.outputPath}\n`);
963
+ return 0;
964
+ }
965
+ }