@jmoyers/harness 0.1.10 → 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 (239) hide show
  1. package/README.md +31 -35
  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/{src/ui/modals/manager.ts → packages/harness-ui/src/modal-manager.ts} +94 -64
  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 -3721
  38. package/scripts/control-plane-daemon.ts +24 -2
  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 -3007
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/default-gateway-pointer.ts +193 -0
  46. package/src/cli/gateway/runtime.ts +1872 -0
  47. package/src/cli/parsing/flags.ts +23 -0
  48. package/src/cli/parsing/session.ts +42 -0
  49. package/src/cli/runtime/context.ts +193 -0
  50. package/src/cli/runtime-app/application.ts +392 -0
  51. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  52. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  53. package/src/cli/workflows/runtime.ts +965 -0
  54. package/src/clients/tui/left-rail-interactions.ts +519 -0
  55. package/src/clients/tui/main-pane-interactions.ts +509 -0
  56. package/src/clients/tui/modal-input-routing.ts +71 -0
  57. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  58. package/src/clients/web/synced-selectors.ts +132 -0
  59. package/src/codex/live-session.ts +82 -29
  60. package/src/config/config-core.ts +361 -10
  61. package/src/config/harness-paths.ts +4 -7
  62. package/src/config/harness-runtime-migration.ts +142 -19
  63. package/src/config/harness.config.template.jsonc +33 -0
  64. package/src/config/secrets-core.ts +92 -4
  65. package/src/control-plane/agent-realtime-api.ts +82 -427
  66. package/src/control-plane/prompt/thread-title-namer.ts +49 -23
  67. package/src/control-plane/session-summary.ts +10 -81
  68. package/src/control-plane/status/reducer-base.ts +12 -12
  69. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  70. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  71. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  72. package/src/control-plane/stream-client.ts +12 -2
  73. package/src/control-plane/stream-command-parser.ts +83 -143
  74. package/src/control-plane/stream-protocol.ts +53 -37
  75. package/src/control-plane/stream-server-background.ts +18 -2
  76. package/src/control-plane/stream-server-command.ts +376 -69
  77. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  78. package/src/control-plane/stream-server.ts +943 -80
  79. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  80. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  81. package/src/core/state/observed-stream-cursor.ts +43 -0
  82. package/src/core/state/synced-observed-state.ts +273 -0
  83. package/src/core/store/harness-synced-store.ts +81 -0
  84. package/src/diff/budget.ts +136 -0
  85. package/src/diff/build.ts +289 -0
  86. package/src/diff/chunker.ts +146 -0
  87. package/src/diff/git-invoke.ts +315 -0
  88. package/src/diff/git-parse.ts +472 -0
  89. package/src/diff/hash.ts +70 -0
  90. package/src/diff/index.ts +24 -0
  91. package/src/diff/normalize.ts +134 -0
  92. package/src/diff/types.ts +178 -0
  93. package/src/diff-ui/args.ts +346 -0
  94. package/src/diff-ui/commands.ts +123 -0
  95. package/src/diff-ui/finder.ts +94 -0
  96. package/src/diff-ui/highlight.ts +127 -0
  97. package/src/diff-ui/index.ts +2 -0
  98. package/src/diff-ui/model.ts +141 -0
  99. package/src/diff-ui/pager.ts +412 -0
  100. package/src/diff-ui/render.ts +337 -0
  101. package/src/diff-ui/runtime.ts +379 -0
  102. package/src/diff-ui/state.ts +224 -0
  103. package/src/diff-ui/types.ts +236 -0
  104. package/src/domain/conversations.ts +11 -7
  105. package/src/domain/workspace.ts +76 -4
  106. package/src/mux/control-plane-op-queue.ts +93 -7
  107. package/src/mux/conversation-rail.ts +28 -71
  108. package/src/mux/dual-pane-core.ts +13 -13
  109. package/src/mux/harness-core-ui.ts +313 -42
  110. package/src/mux/input-shortcuts.ts +22 -112
  111. package/src/mux/keybinding-catalog.ts +340 -0
  112. package/src/mux/keybinding-registry.ts +103 -0
  113. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  114. package/src/mux/live-mux/command-menu.ts +167 -4
  115. package/src/mux/live-mux/conversation-state.ts +13 -0
  116. package/src/mux/live-mux/directory-resolution.ts +1 -1
  117. package/src/mux/live-mux/git-parsing.ts +16 -0
  118. package/src/mux/live-mux/git-snapshot.ts +33 -2
  119. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  120. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  121. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  122. package/src/mux/live-mux/input-forwarding.ts +59 -2
  123. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  124. package/src/mux/live-mux/left-nav.ts +35 -0
  125. package/src/mux/live-mux/link-click.ts +292 -0
  126. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  127. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  128. package/src/mux/live-mux/modal-input-reducers.ts +106 -8
  129. package/src/mux/live-mux/modal-overlays.ts +210 -31
  130. package/src/mux/live-mux/modal-pointer.ts +3 -7
  131. package/src/mux/live-mux/modal-prompt-handlers.ts +107 -1
  132. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  133. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  134. package/src/mux/live-mux/pointer-routing.ts +5 -2
  135. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  136. package/src/mux/live-mux/rail-layout.ts +33 -30
  137. package/src/mux/live-mux/release-notes.ts +383 -0
  138. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  139. package/src/mux/live-mux/repository-folding.ts +3 -0
  140. package/src/mux/live-mux/selection.ts +0 -4
  141. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  142. package/src/mux/project-pane-github-review.ts +271 -0
  143. package/src/mux/render-frame.ts +4 -0
  144. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  145. package/src/mux/task-composer.ts +21 -14
  146. package/src/mux/task-focused-pane.ts +118 -117
  147. package/src/mux/task-screen-keybindings.ts +19 -82
  148. package/src/mux/workspace-rail-model.ts +270 -104
  149. package/src/mux/workspace-rail.ts +45 -22
  150. package/src/pty/session-broker.ts +1 -1
  151. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  152. package/src/services/control-plane.ts +50 -32
  153. package/src/services/conversation-lifecycle.ts +118 -87
  154. package/src/services/conversation-startup-hydration.ts +20 -12
  155. package/src/services/directory-hydration.ts +21 -16
  156. package/src/services/event-persistence.ts +7 -0
  157. package/src/services/left-rail-pointer-handler.ts +329 -0
  158. package/src/services/mux-ui-state-persistence.ts +5 -1
  159. package/src/services/recording.ts +34 -26
  160. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  161. package/src/services/runtime-control-actions.ts +79 -61
  162. package/src/services/runtime-control-plane-ops.ts +122 -83
  163. package/src/services/runtime-conversation-actions.ts +40 -26
  164. package/src/services/runtime-conversation-activation.ts +82 -30
  165. package/src/services/runtime-conversation-starter.ts +80 -48
  166. package/src/services/runtime-conversation-title-edit.ts +91 -80
  167. package/src/services/runtime-envelope-handler.ts +107 -105
  168. package/src/services/runtime-git-state.ts +42 -29
  169. package/src/services/runtime-layout-resize.ts +3 -1
  170. package/src/services/runtime-left-rail-render.ts +99 -63
  171. package/src/services/runtime-nim-cli-session.ts +438 -0
  172. package/src/services/runtime-nim-session.ts +705 -0
  173. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  174. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  175. package/src/services/runtime-process-wiring.ts +29 -36
  176. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  177. package/src/services/runtime-render-flush.ts +63 -70
  178. package/src/services/runtime-render-lifecycle.ts +65 -64
  179. package/src/services/runtime-render-orchestrator.ts +55 -45
  180. package/src/services/runtime-render-pipeline.ts +106 -103
  181. package/src/services/runtime-render-state.ts +62 -49
  182. package/src/services/runtime-repository-actions.ts +97 -70
  183. package/src/services/runtime-right-pane-render.ts +80 -53
  184. package/src/services/runtime-shutdown.ts +38 -35
  185. package/src/services/runtime-stream-subscriptions.ts +35 -27
  186. package/src/services/runtime-task-composer-persistence.ts +71 -59
  187. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  188. package/src/services/runtime-task-editor-actions.ts +46 -29
  189. package/src/services/runtime-task-pane-actions.ts +220 -134
  190. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  191. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  192. package/src/services/runtime-workspace-observed-events.ts +33 -184
  193. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  194. package/src/services/session-diagnostics-store.ts +217 -0
  195. package/src/services/startup-background-resume.ts +26 -21
  196. package/src/services/startup-orchestrator.ts +16 -13
  197. package/src/services/startup-paint-tracker.ts +29 -21
  198. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  199. package/src/services/startup-settled-gate.ts +25 -15
  200. package/src/services/startup-shutdown.ts +18 -22
  201. package/src/services/startup-state-hydration.ts +44 -34
  202. package/src/services/startup-visibility.ts +12 -4
  203. package/src/services/task-pane-selection-actions.ts +89 -72
  204. package/src/services/task-planning-hydration.ts +24 -18
  205. package/src/services/task-planning-observed-events.ts +50 -52
  206. package/src/services/workspace-observed-events.ts +66 -63
  207. package/src/storage/storage-lifecycle-core.ts +438 -0
  208. package/src/store/control-plane-store-normalize.ts +33 -242
  209. package/src/store/control-plane-store-types.ts +1 -35
  210. package/src/store/control-plane-store.ts +396 -56
  211. package/src/store/event-store.ts +397 -3
  212. package/src/terminal/snapshot-oracle.ts +207 -94
  213. package/src/ui/mux-theme.ts +112 -8
  214. package/src/ui/panes/home-gridfire.ts +40 -31
  215. package/src/ui/panes/home.ts +10 -2
  216. package/src/ui/panes/nim.ts +315 -0
  217. package/src/mux/live-mux/actions-task.ts +0 -115
  218. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  219. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -82
  220. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  221. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  222. package/src/services/runtime-directory-actions.ts +0 -164
  223. package/src/services/runtime-input-pipeline.ts +0 -50
  224. package/src/services/runtime-input-router.ts +0 -189
  225. package/src/services/runtime-main-pane-input.ts +0 -230
  226. package/src/services/runtime-modal-input.ts +0 -119
  227. package/src/services/runtime-navigation-input.ts +0 -197
  228. package/src/services/runtime-rail-input.ts +0 -278
  229. package/src/services/runtime-task-pane.ts +0 -62
  230. package/src/services/runtime-workspace-actions.ts +0 -158
  231. package/src/ui/conversation-input-forwarder.ts +0 -114
  232. package/src/ui/conversation-selection-input.ts +0 -103
  233. package/src/ui/global-shortcut-input.ts +0 -89
  234. package/src/ui/input.ts +0 -238
  235. package/src/ui/kit.ts +0 -509
  236. package/src/ui/left-nav-input.ts +0 -80
  237. package/src/ui/left-rail-pointer-input.ts +0 -148
  238. package/src/ui/repository-fold-input.ts +0 -91
  239. package/src/ui/surface.ts +0 -224
@@ -1,3026 +1,52 @@
1
- import { once } from 'node:events';
2
- import {
3
- closeSync,
4
- existsSync,
5
- mkdirSync,
6
- openSync,
7
- readFileSync,
8
- renameSync,
9
- unlinkSync,
10
- writeFileSync,
11
- } from 'node:fs';
12
- import { execFileSync, spawn } from 'node:child_process';
13
- import { createServer } from 'node:net';
1
+ import { execute } from '@oclif/core';
14
2
  import { dirname, resolve } from 'node:path';
15
3
  import { fileURLToPath } from 'node:url';
16
- import { randomUUID } from 'node:crypto';
17
- import { setTimeout as delay } from 'node:timers/promises';
18
- import { connectControlPlaneStreamClient } from '../src/control-plane/stream-client.ts';
19
- import { parseStreamCommand } from '../src/control-plane/stream-command-parser.ts';
20
- import type { StreamCommand } from '../src/control-plane/stream-protocol.ts';
21
- import { runHarnessAnimate } from './harness-animate.ts';
22
- import {
23
- GATEWAY_RECORD_VERSION,
24
- DEFAULT_GATEWAY_DB_PATH,
25
- isLoopbackHost,
26
- normalizeGatewayHost,
27
- normalizeGatewayPort,
28
- normalizeGatewayStateDbPath,
29
- resolveGatewayLockPath,
30
- parseGatewayRecordText,
31
- resolveGatewayLogPath,
32
- resolveGatewayRecordPath,
33
- resolveInvocationDirectory,
34
- serializeGatewayRecord,
35
- type GatewayRecord,
36
- } from '../src/cli/gateway-record.ts';
37
- import { loadHarnessConfig } from '../src/config/config-core.ts';
38
- import {
39
- resolveHarnessRuntimePath,
40
- resolveHarnessWorkspaceDirectory,
41
- } from '../src/config/harness-paths.ts';
42
- import { migrateLegacyHarnessLayout } from '../src/config/harness-runtime-migration.ts';
43
- import { loadHarnessSecrets } from '../src/config/secrets-core.ts';
44
- import {
45
- buildCursorManagedHookRelayCommand,
46
- ensureManagedCursorHooksInstalled,
47
- uninstallManagedCursorHooks,
48
- } from '../src/cursor/managed-hooks.ts';
49
- import {
50
- buildInspectorProfileStartExpression,
51
- buildInspectorProfileStopExpression,
52
- connectGatewayInspector,
53
- DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
54
- evaluateInspectorExpression,
55
- InspectorWebSocketClient,
56
- type InspectorProfileState,
57
- readInspectorProfileState,
58
- } from './harness-inspector.ts';
59
- import {
60
- parseActiveStatusTimelineState,
61
- resolveDefaultStatusTimelineOutputPath,
62
- resolveStatusTimelineStatePath,
63
- STATUS_TIMELINE_MODE,
64
- STATUS_TIMELINE_STATE_VERSION,
65
- } from '../src/mux/live-mux/status-timeline-state.ts';
66
- import {
67
- parseActiveRenderTraceState,
68
- resolveDefaultRenderTraceOutputPath,
69
- resolveRenderTraceStatePath,
70
- RENDER_TRACE_MODE,
71
- RENDER_TRACE_STATE_VERSION,
72
- } from '../src/mux/live-mux/render-trace-state.ts';
4
+ import { parseGlobalCliOptions } from './harness-runtime.ts';
73
5
 
74
6
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
75
- const DEFAULT_DAEMON_SCRIPT_PATH = resolve(SCRIPT_DIR, 'control-plane-daemon.ts');
76
- const DEFAULT_MUX_SCRIPT_PATH = resolve(SCRIPT_DIR, 'harness-core.ts');
77
- const DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH = resolve(SCRIPT_DIR, 'cursor-hook-relay.ts');
78
- const DEFAULT_GATEWAY_START_RETRY_WINDOW_MS = 6000;
79
- const DEFAULT_GATEWAY_START_RETRY_DELAY_MS = 40;
80
- const DEFAULT_GATEWAY_STOP_TIMEOUT_MS = 5000;
81
- const DEFAULT_GATEWAY_STOP_POLL_MS = 50;
82
- const DEFAULT_GATEWAY_LOCK_TIMEOUT_MS = 7000;
83
- const DEFAULT_GATEWAY_LOCK_POLL_MS = 40;
84
- const GATEWAY_LOCK_VERSION = 1;
85
- const DEFAULT_PROFILE_ROOT_PATH = 'profiles';
86
- const DEFAULT_SESSION_ROOT_PATH = 'sessions';
87
- const PROFILE_STATE_FILE_NAME = 'active-profile.json';
88
- const PROFILE_CLIENT_FILE_NAME = 'client.cpuprofile';
89
- const PROFILE_GATEWAY_FILE_NAME = 'gateway.cpuprofile';
90
- const DEFAULT_HARNESS_UPDATE_PACKAGE = '@jmoyers/harness@latest';
91
- const PROFILE_STATE_VERSION = 2;
92
- const PROFILE_LIVE_INSPECT_MODE = 'live-inspector';
93
- const SESSION_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
7
+ const PROJECT_ROOT = resolve(SCRIPT_DIR, '..');
8
+ const ROOT_HELP_TOKENS = new Set(['--help', '-h', '--version', 'help']);
9
+ const TOP_LEVEL_COMMANDS = new Set([
10
+ 'client',
11
+ 'gateway',
12
+ 'profile',
13
+ 'status-timeline',
14
+ 'render-trace',
15
+ 'auth',
16
+ 'update',
17
+ 'upgrade',
18
+ 'cursor-hooks',
19
+ 'nim',
20
+ 'animate',
21
+ 'diff',
22
+ ]);
23
+
24
+ function normalizeHarnessArgs(rawArgv: readonly string[]): string[] {
25
+ const parsedGlobals = parseGlobalCliOptions(rawArgv);
26
+ let argv = [...parsedGlobals.argv];
94
27
 
95
- interface GatewayStartOptions {
96
- host?: string;
97
- port?: number;
98
- authToken?: string;
99
- stateDbPath?: string;
100
- }
101
-
102
- interface GatewayStopOptions {
103
- force: boolean;
104
- timeoutMs: number;
105
- cleanupOrphans: boolean;
106
- }
107
-
108
- interface ParsedGatewayCommand {
109
- type: 'start' | 'stop' | 'status' | 'restart' | 'run' | 'call';
110
- startOptions?: GatewayStartOptions;
111
- stopOptions?: GatewayStopOptions;
112
- callJson?: string;
113
- }
114
-
115
- interface ParsedProfileRunCommand {
116
- type: 'run';
117
- profileDir: string | null;
118
- muxArgs: readonly string[];
119
- }
120
-
121
- interface ParsedProfileStartCommand {
122
- type: 'start';
123
- profileDir: string | null;
124
- }
125
-
126
- interface ProfileStopOptions {
127
- timeoutMs: number;
128
- }
129
-
130
- interface ParsedProfileStopCommand {
131
- type: 'stop';
132
- stopOptions: ProfileStopOptions;
133
- }
134
-
135
- type ParsedProfileCommand =
136
- | ParsedProfileRunCommand
137
- | ParsedProfileStartCommand
138
- | ParsedProfileStopCommand;
139
-
140
- interface ParsedStatusTimelineStartCommand {
141
- type: 'start';
142
- outputPath: string | null;
143
- }
144
-
145
- interface ParsedStatusTimelineStopCommand {
146
- type: 'stop';
147
- }
148
-
149
- type ParsedStatusTimelineCommand =
150
- | ParsedStatusTimelineStartCommand
151
- | ParsedStatusTimelineStopCommand;
152
-
153
- interface ParsedRenderTraceStartCommand {
154
- type: 'start';
155
- outputPath: string | null;
156
- conversationId: string | null;
157
- }
158
-
159
- interface ParsedRenderTraceStopCommand {
160
- type: 'stop';
161
- }
162
-
163
- type ParsedRenderTraceCommand = ParsedRenderTraceStartCommand | ParsedRenderTraceStopCommand;
164
-
165
- interface ParsedCursorHooksCommand {
166
- type: 'install' | 'uninstall';
167
- hooksFilePath: string | null;
168
- }
169
-
170
- interface RuntimeCpuProfileOptions {
171
- cpuProfileDir: string;
172
- cpuProfileName: string;
173
- }
174
-
175
- interface RuntimeInspectOptions {
176
- readonly gatewayRuntimeArgs: readonly string[];
177
- readonly clientRuntimeArgs: readonly string[];
178
- }
179
-
180
- interface SessionPaths {
181
- recordPath: string;
182
- logPath: string;
183
- lockPath: string;
184
- defaultStateDbPath: string;
185
- profileDir: string;
186
- profileStatePath: string;
187
- statusTimelineStatePath: string;
188
- defaultStatusTimelineOutputPath: string;
189
- renderTraceStatePath: string;
190
- defaultRenderTraceOutputPath: string;
191
- }
192
-
193
- interface ParsedGlobalCliOptions {
194
- sessionName: string | null;
195
- argv: readonly string[];
196
- }
197
-
198
- interface ResolvedGatewaySettings {
199
- host: string;
200
- port: number;
201
- authToken: string | null;
202
- stateDbPath: string;
203
- }
204
-
205
- interface GatewayProbeResult {
206
- connected: boolean;
207
- sessionCount: number;
208
- liveSessionCount: number;
209
- error: string | null;
210
- }
211
-
212
- interface EnsureGatewayResult {
213
- record: GatewayRecord;
214
- started: boolean;
215
- }
216
-
217
- interface ProcessTableEntry {
218
- pid: number;
219
- ppid: number;
220
- command: string;
221
- }
222
-
223
- interface OrphanProcessCleanupResult {
224
- matchedPids: readonly number[];
225
- terminatedPids: readonly number[];
226
- failedPids: readonly number[];
227
- errorMessage: string | null;
228
- }
229
-
230
- interface GatewayProcessIdentity {
231
- pid: number;
232
- startedAt: string;
233
- }
234
-
235
- interface GatewayControlLockRecord {
236
- version: number;
237
- owner: GatewayProcessIdentity;
238
- acquiredAt: string;
239
- workspaceRoot: string;
240
- token: string;
241
- }
242
-
243
- interface GatewayControlLockHandle {
244
- lockPath: string;
245
- record: GatewayControlLockRecord;
246
- release: () => void;
247
- }
248
-
249
- interface ParsedGatewayDaemonEntry {
250
- pid: number;
251
- host: string;
252
- port: number;
253
- authToken: string | null;
254
- stateDbPath: string;
255
- }
256
-
257
- interface ActiveProfileState {
258
- version: number;
259
- mode: typeof PROFILE_LIVE_INSPECT_MODE;
260
- pid: number;
261
- host: string;
262
- port: number;
263
- stateDbPath: string;
264
- profileDir: string;
265
- gatewayProfilePath: string;
266
- inspectWebSocketUrl: string;
267
- startedAt: string;
268
- }
269
-
270
- interface ActiveStatusTimelineState {
271
- version: number;
272
- mode: typeof STATUS_TIMELINE_MODE;
273
- outputPath: string;
274
- sessionName: string | null;
275
- startedAt: string;
276
- }
277
-
278
- interface ActiveRenderTraceState {
279
- version: number;
280
- mode: typeof RENDER_TRACE_MODE;
281
- outputPath: string;
282
- sessionName: string | null;
283
- conversationId: string | null;
284
- startedAt: string;
285
- }
286
-
287
- function normalizeSignalExitCode(signal: NodeJS.Signals | null): number {
288
- if (signal === null) {
289
- return 1;
290
- }
291
- if (signal === 'SIGINT') {
292
- return 130;
293
- }
294
- if (signal === 'SIGTERM') {
295
- return 143;
296
- }
297
- return 1;
298
- }
299
-
300
- function tsRuntimeArgs(
301
- scriptPath: string,
302
- args: readonly string[] = [],
303
- runtimeArgs: readonly string[] = [],
304
- ): string[] {
305
- return [...runtimeArgs, scriptPath, ...args];
306
- }
307
-
308
- function readCliValue(argv: readonly string[], index: number, flag: string): string {
309
- const value = argv[index + 1];
310
- if (value === undefined) {
311
- throw new Error(`missing value for ${flag}`);
312
- }
313
- return value;
314
- }
315
-
316
- function parsePortFlag(value: string, flag: string): number {
317
- const parsed = Number.parseInt(value, 10);
318
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
319
- throw new Error(`invalid ${flag} value: ${value}`);
320
- }
321
- return parsed;
322
- }
323
-
324
- function parsePositiveIntFlag(value: string, flag: string): number {
325
- const parsed = Number.parseInt(value, 10);
326
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
327
- throw new Error(`invalid ${flag} value: ${value}`);
328
- }
329
- return parsed;
330
- }
331
-
332
- function parseSessionName(rawValue: string): string {
333
- const trimmed = rawValue.trim();
334
- if (!SESSION_NAME_PATTERN.test(trimmed)) {
335
- throw new Error(`invalid --session value: ${rawValue}`);
336
- }
337
- return trimmed;
338
- }
339
-
340
- function parseGlobalCliOptions(argv: readonly string[]): ParsedGlobalCliOptions {
341
- if (argv[0] !== '--session') {
342
- return {
343
- sessionName: null,
344
- argv,
345
- };
346
- }
347
- const sessionName = parseSessionName(readCliValue(argv, 0, '--session'));
348
- return {
349
- sessionName,
350
- argv: argv.slice(2),
351
- };
352
- }
353
-
354
- function resolveSessionPaths(
355
- invocationDirectory: string,
356
- sessionName: string | null,
357
- ): SessionPaths {
358
- const workspaceDirectory = resolveHarnessWorkspaceDirectory(invocationDirectory, process.env);
359
- const statusTimelineStatePath = resolveStatusTimelineStatePath(
360
- invocationDirectory,
361
- sessionName,
362
- process.env,
363
- );
364
- const defaultStatusTimelineOutputPath = resolveDefaultStatusTimelineOutputPath(
365
- invocationDirectory,
366
- sessionName,
367
- process.env,
368
- );
369
- const renderTraceStatePath = resolveRenderTraceStatePath(
370
- invocationDirectory,
371
- sessionName,
372
- process.env,
373
- );
374
- const defaultRenderTraceOutputPath = resolveDefaultRenderTraceOutputPath(
375
- invocationDirectory,
376
- sessionName,
377
- process.env,
378
- );
379
- if (sessionName === null) {
380
- return {
381
- recordPath: resolveGatewayRecordPath(invocationDirectory, process.env),
382
- logPath: resolveGatewayLogPath(invocationDirectory, process.env),
383
- lockPath: resolveGatewayLockPath(invocationDirectory, process.env),
384
- defaultStateDbPath: resolveHarnessRuntimePath(
385
- invocationDirectory,
386
- DEFAULT_GATEWAY_DB_PATH,
387
- process.env,
388
- ),
389
- profileDir: resolve(workspaceDirectory, DEFAULT_PROFILE_ROOT_PATH),
390
- profileStatePath: resolve(workspaceDirectory, PROFILE_STATE_FILE_NAME),
391
- statusTimelineStatePath,
392
- defaultStatusTimelineOutputPath,
393
- renderTraceStatePath,
394
- defaultRenderTraceOutputPath,
395
- };
396
- }
397
- const sessionRoot = resolve(workspaceDirectory, DEFAULT_SESSION_ROOT_PATH, sessionName);
398
- return {
399
- recordPath: resolve(sessionRoot, 'gateway.json'),
400
- logPath: resolve(sessionRoot, 'gateway.log'),
401
- lockPath: resolve(sessionRoot, 'gateway.lock'),
402
- defaultStateDbPath: resolve(sessionRoot, 'control-plane.sqlite'),
403
- profileDir: resolve(workspaceDirectory, DEFAULT_PROFILE_ROOT_PATH, sessionName),
404
- profileStatePath: resolve(sessionRoot, PROFILE_STATE_FILE_NAME),
405
- statusTimelineStatePath,
406
- defaultStatusTimelineOutputPath,
407
- renderTraceStatePath,
408
- defaultRenderTraceOutputPath,
409
- };
410
- }
411
-
412
- function parseProfileRunCommand(argv: readonly string[]): ParsedProfileRunCommand {
413
- let profileDir: string | null = null;
414
- const muxArgs: string[] = [];
415
- for (let index = 0; index < argv.length; index += 1) {
416
- const arg = argv[index]!;
417
- if (arg === '--profile-dir') {
418
- profileDir = readCliValue(argv, index, '--profile-dir');
419
- index += 1;
420
- continue;
421
- }
422
- muxArgs.push(arg);
423
- }
424
- return {
425
- type: 'run',
426
- profileDir,
427
- muxArgs,
428
- };
429
- }
430
-
431
- function parseProfileStartCommand(argv: readonly string[]): ParsedProfileStartCommand {
432
- let profileDir: string | null = null;
433
- for (let index = 0; index < argv.length; index += 1) {
434
- const arg = argv[index]!;
435
- if (arg === '--profile-dir') {
436
- profileDir = readCliValue(argv, index, '--profile-dir');
437
- index += 1;
438
- continue;
439
- }
440
- throw new Error(`unknown profile option: ${arg}`);
441
- }
442
- return {
443
- type: 'start',
444
- profileDir,
445
- };
446
- }
447
-
448
- function parseProfileStopOptions(argv: readonly string[]): ProfileStopOptions {
449
- const options: ProfileStopOptions = {
450
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
451
- };
452
- for (let index = 0; index < argv.length; index += 1) {
453
- const arg = argv[index]!;
454
- if (arg === '--timeout-ms') {
455
- options.timeoutMs = parsePositiveIntFlag(
456
- readCliValue(argv, index, '--timeout-ms'),
457
- '--timeout-ms',
458
- );
459
- index += 1;
460
- continue;
461
- }
462
- throw new Error(`unknown profile option: ${arg}`);
463
- }
464
- return options;
465
- }
466
-
467
- function parseProfileStopCommand(argv: readonly string[]): ParsedProfileStopCommand {
468
- return {
469
- type: 'stop',
470
- stopOptions: parseProfileStopOptions(argv),
471
- };
472
- }
473
-
474
- function parseProfileCommand(argv: readonly string[]): ParsedProfileCommand {
475
- if (argv.length === 0) {
476
- return parseProfileRunCommand(argv);
477
- }
478
- const subcommand = argv[0]!;
479
- const rest = argv.slice(1);
480
- if (subcommand === 'start') {
481
- return parseProfileStartCommand(rest);
482
- }
483
- if (subcommand === 'stop') {
484
- return parseProfileStopCommand(rest);
485
- }
486
- if (subcommand === 'run') {
487
- return parseProfileRunCommand(rest);
488
- }
489
- if (subcommand.startsWith('-')) {
490
- return parseProfileRunCommand(argv);
491
- }
492
- return parseProfileRunCommand(argv);
493
- }
494
-
495
- function parseStatusTimelineStartCommand(
496
- argv: readonly string[],
497
- ): ParsedStatusTimelineStartCommand {
498
- let outputPath: string | null = null;
499
- for (let index = 0; index < argv.length; index += 1) {
500
- const arg = argv[index]!;
501
- if (arg === '--output-path') {
502
- outputPath = readCliValue(argv, index, '--output-path');
503
- index += 1;
504
- continue;
505
- }
506
- throw new Error(`unknown status-timeline option: ${arg}`);
507
- }
508
- return {
509
- type: 'start',
510
- outputPath,
511
- };
512
- }
513
-
514
- function parseStatusTimelineStopCommand(argv: readonly string[]): ParsedStatusTimelineStopCommand {
515
- if (argv.length > 0) {
516
- throw new Error(`unknown status-timeline option: ${argv[0]}`);
517
- }
518
- return {
519
- type: 'stop',
520
- };
521
- }
522
-
523
- function parseStatusTimelineCommand(argv: readonly string[]): ParsedStatusTimelineCommand {
524
- if (argv.length === 0) {
525
- return parseStatusTimelineStartCommand(argv);
526
- }
527
- const subcommand = argv[0]!;
528
- const rest = argv.slice(1);
529
- if (subcommand === 'start') {
530
- return parseStatusTimelineStartCommand(rest);
531
- }
532
- if (subcommand === 'stop') {
533
- return parseStatusTimelineStopCommand(rest);
534
- }
535
- if (subcommand.startsWith('-')) {
536
- return parseStatusTimelineStartCommand(argv);
537
- }
538
- throw new Error(`unknown status-timeline subcommand: ${subcommand}`);
539
- }
540
-
541
- function parseRenderTraceStartCommand(argv: readonly string[]): ParsedRenderTraceStartCommand {
542
- let outputPath: string | null = null;
543
- let conversationId: string | null = null;
544
- for (let index = 0; index < argv.length; index += 1) {
545
- const arg = argv[index]!;
546
- if (arg === '--output-path') {
547
- outputPath = readCliValue(argv, index, '--output-path');
548
- index += 1;
549
- continue;
550
- }
551
- if (arg === '--conversation-id') {
552
- const value = readCliValue(argv, index, '--conversation-id').trim();
553
- if (value.length === 0) {
554
- throw new Error('invalid --conversation-id value: empty string');
555
- }
556
- conversationId = value;
557
- index += 1;
558
- continue;
559
- }
560
- throw new Error(`unknown render-trace option: ${arg}`);
561
- }
562
- return {
563
- type: 'start',
564
- outputPath,
565
- conversationId,
566
- };
567
- }
568
-
569
- function parseRenderTraceStopCommand(argv: readonly string[]): ParsedRenderTraceStopCommand {
570
- if (argv.length > 0) {
571
- throw new Error(`unknown render-trace option: ${argv[0]}`);
572
- }
573
- return {
574
- type: 'stop',
575
- };
576
- }
577
-
578
- function parseRenderTraceCommand(argv: readonly string[]): ParsedRenderTraceCommand {
579
- if (argv.length === 0) {
580
- return parseRenderTraceStartCommand(argv);
581
- }
582
- const subcommand = argv[0]!;
583
- const rest = argv.slice(1);
584
- if (subcommand === 'start') {
585
- return parseRenderTraceStartCommand(rest);
586
- }
587
- if (subcommand === 'stop') {
588
- return parseRenderTraceStopCommand(rest);
589
- }
590
- if (subcommand.startsWith('-')) {
591
- return parseRenderTraceStartCommand(argv);
592
- }
593
- throw new Error(`unknown render-trace subcommand: ${subcommand}`);
594
- }
595
-
596
- function parseCursorHooksOptions(argv: readonly string[]): { hooksFilePath: string | null } {
597
- const options = {
598
- hooksFilePath: null as string | null,
599
- };
600
- for (let index = 0; index < argv.length; index += 1) {
601
- const arg = argv[index]!;
602
- if (arg === '--hooks-file') {
603
- options.hooksFilePath = readCliValue(argv, index, '--hooks-file');
604
- index += 1;
605
- continue;
606
- }
607
- throw new Error(`unknown cursor-hooks option: ${arg}`);
608
- }
609
- return options;
610
- }
611
-
612
- function parseCursorHooksCommand(argv: readonly string[]): ParsedCursorHooksCommand {
613
28
  if (argv.length === 0) {
614
- throw new Error('missing cursor-hooks subcommand');
29
+ argv = ['client'];
30
+ } else if (!ROOT_HELP_TOKENS.has(argv[0]!) && !TOP_LEVEL_COMMANDS.has(argv[0]!)) {
31
+ argv = ['client', ...argv];
615
32
  }
616
- const subcommand = argv[0]!;
617
- const options = parseCursorHooksOptions(argv.slice(1));
618
- if (subcommand === 'install') {
619
- return {
620
- type: 'install',
621
- hooksFilePath: options.hooksFilePath,
622
- };
623
- }
624
- if (subcommand === 'uninstall') {
625
- return {
626
- type: 'uninstall',
627
- hooksFilePath: options.hooksFilePath,
628
- };
629
- }
630
- throw new Error(`unknown cursor-hooks subcommand: ${subcommand}`);
631
- }
632
-
633
- function buildCpuProfileRuntimeArgs(options: RuntimeCpuProfileOptions): readonly string[] {
634
- return [
635
- '--cpu-prof',
636
- '--cpu-prof-dir',
637
- options.cpuProfileDir,
638
- '--cpu-prof-name',
639
- options.cpuProfileName,
640
- ];
641
- }
642
33
 
643
- function resolveInspectRuntimeOptions(invocationDirectory: string): RuntimeInspectOptions {
644
- const loadedConfig = loadHarnessConfig({ cwd: invocationDirectory });
645
- const debugConfig = loadedConfig.config.debug;
646
- if (!debugConfig.enabled || !debugConfig.inspect.enabled) {
647
- return {
648
- gatewayRuntimeArgs: [],
649
- clientRuntimeArgs: [],
650
- };
34
+ if (parsedGlobals.sessionName !== null && argv.length > 0 && !ROOT_HELP_TOKENS.has(argv[0]!)) {
35
+ argv = [argv[0]!, '--session', parsedGlobals.sessionName, ...argv.slice(1)];
651
36
  }
652
- return {
653
- gatewayRuntimeArgs: [
654
- `--inspect=localhost:${String(debugConfig.inspect.gatewayPort)}/harness-gateway`,
655
- ],
656
- clientRuntimeArgs: [
657
- `--inspect=localhost:${String(debugConfig.inspect.clientPort)}/harness-client`,
658
- ],
659
- };
660
- }
661
37
 
662
- function removeFileIfExists(filePath: string): void {
663
- try {
664
- unlinkSync(filePath);
665
- } catch (error: unknown) {
666
- const code = (error as NodeJS.ErrnoException).code;
667
- if (code !== 'ENOENT') {
668
- throw error;
669
- }
670
- }
38
+ return argv;
671
39
  }
672
40
 
673
- async function reservePort(host: string): Promise<number> {
674
- return await new Promise<number>((resolvePort, reject) => {
675
- const server = createServer();
676
- server.unref();
677
- server.once('error', reject);
678
- server.listen(0, host, () => {
679
- const address = server.address();
680
- if (address === null || typeof address === 'string') {
681
- server.close(() => {
682
- reject(new Error('failed to reserve local port'));
683
- });
684
- return;
685
- }
686
- const port = address.port;
687
- server.close((error) => {
688
- if (error !== undefined) {
689
- reject(error);
690
- return;
691
- }
692
- resolvePort(port);
693
- });
694
- });
41
+ async function main(): Promise<void> {
42
+ await execute({
43
+ args: normalizeHarnessArgs(process.argv.slice(2)),
44
+ dir: PROJECT_ROOT,
695
45
  });
696
46
  }
697
47
 
698
- function parseGatewayStartOptions(argv: readonly string[]): GatewayStartOptions {
699
- const options: GatewayStartOptions = {};
700
- for (let index = 0; index < argv.length; index += 1) {
701
- const arg = argv[index]!;
702
- if (arg === '--host') {
703
- options.host = readCliValue(argv, index, '--host');
704
- index += 1;
705
- continue;
706
- }
707
- if (arg === '--port') {
708
- options.port = parsePortFlag(readCliValue(argv, index, '--port'), '--port');
709
- index += 1;
710
- continue;
711
- }
712
- if (arg === '--auth-token') {
713
- options.authToken = readCliValue(argv, index, '--auth-token');
714
- index += 1;
715
- continue;
716
- }
717
- if (arg === '--state-db-path') {
718
- options.stateDbPath = readCliValue(argv, index, '--state-db-path');
719
- index += 1;
720
- continue;
721
- }
722
- throw new Error(`unknown gateway option: ${arg}`);
723
- }
724
- return options;
725
- }
726
-
727
- function parseGatewayStopOptions(argv: readonly string[]): GatewayStopOptions {
728
- const options: GatewayStopOptions = {
729
- force: false,
730
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
731
- cleanupOrphans: true,
732
- };
733
- for (let index = 0; index < argv.length; index += 1) {
734
- const arg = argv[index]!;
735
- if (arg === '--force') {
736
- options.force = true;
737
- continue;
738
- }
739
- if (arg === '--cleanup-orphans') {
740
- options.cleanupOrphans = true;
741
- continue;
742
- }
743
- if (arg === '--no-cleanup-orphans') {
744
- options.cleanupOrphans = false;
745
- continue;
746
- }
747
- if (arg === '--timeout-ms') {
748
- options.timeoutMs = parsePositiveIntFlag(
749
- readCliValue(argv, index, '--timeout-ms'),
750
- '--timeout-ms',
751
- );
752
- index += 1;
753
- continue;
754
- }
755
- throw new Error(`unknown gateway option: ${arg}`);
756
- }
757
- return options;
758
- }
759
-
760
- function parseGatewayCallOptions(argv: readonly string[]): { json: string } {
761
- let json: string | null = null;
762
- for (let index = 0; index < argv.length; index += 1) {
763
- const arg = argv[index]!;
764
- if (arg === '--json') {
765
- json = readCliValue(argv, index, '--json');
766
- index += 1;
767
- continue;
768
- }
769
- if (json === null) {
770
- json = arg;
771
- continue;
772
- }
773
- throw new Error(`unknown gateway option: ${arg}`);
774
- }
775
- if (json === null) {
776
- throw new Error(
777
- 'missing command json; use `harness gateway call --json \'{"type":"session.list"}\'`',
778
- );
779
- }
780
- return { json };
781
- }
782
-
783
- function parseGatewayCommand(argv: readonly string[]): ParsedGatewayCommand {
784
- if (argv.length === 0) {
785
- throw new Error('missing gateway subcommand');
786
- }
787
- const subcommand = argv[0]!;
788
- const rest = argv.slice(1);
789
- if (subcommand === 'start') {
790
- return {
791
- type: 'start',
792
- startOptions: parseGatewayStartOptions(rest),
793
- };
794
- }
795
- if (subcommand === 'run') {
796
- return {
797
- type: 'run',
798
- startOptions: parseGatewayStartOptions(rest),
799
- };
800
- }
801
- if (subcommand === 'restart') {
802
- return {
803
- type: 'restart',
804
- startOptions: parseGatewayStartOptions(rest),
805
- };
806
- }
807
- if (subcommand === 'stop') {
808
- return {
809
- type: 'stop',
810
- stopOptions: parseGatewayStopOptions(rest),
811
- };
812
- }
813
- if (subcommand === 'status') {
814
- if (rest.length > 0) {
815
- throw new Error(`unknown gateway option: ${rest[0]}`);
816
- }
817
- return {
818
- type: 'status',
819
- };
820
- }
821
- if (subcommand === 'call') {
822
- const parsed = parseGatewayCallOptions(rest);
823
- return {
824
- type: 'call',
825
- callJson: parsed.json,
826
- };
827
- }
828
- throw new Error(`unknown gateway subcommand: ${subcommand}`);
829
- }
830
-
831
- function resolveHarnessUpdatePackageSpec(env: NodeJS.ProcessEnv): string {
832
- const configured = env.HARNESS_UPDATE_PACKAGE;
833
- if (typeof configured !== 'string') {
834
- return DEFAULT_HARNESS_UPDATE_PACKAGE;
835
- }
836
- const trimmed = configured.trim();
837
- return trimmed.length > 0 ? trimmed : DEFAULT_HARNESS_UPDATE_PACKAGE;
838
- }
839
-
840
- function formatExecErrorOutput(value: unknown): string {
841
- if (typeof value === 'string') {
842
- return value;
843
- }
844
- if (value instanceof Buffer) {
845
- return value.toString('utf8');
846
- }
847
- return '';
848
- }
849
-
850
- function runHarnessUpdateCommand(invocationDirectory: string, env: NodeJS.ProcessEnv): number {
851
- const packageSpec = resolveHarnessUpdatePackageSpec(env);
852
- process.stdout.write(`updating Harness package: ${packageSpec}\n`);
853
- try {
854
- const stdout = execFileSync('bun', ['add', '-g', '--trust', packageSpec], {
855
- cwd: invocationDirectory,
856
- env,
857
- encoding: 'utf8',
858
- stdio: ['ignore', 'pipe', 'pipe'],
859
- });
860
- if (stdout.length > 0) {
861
- process.stdout.write(stdout);
862
- }
863
- process.stdout.write(`harness update complete: ${packageSpec}\n`);
864
- return 0;
865
- } catch (error: unknown) {
866
- const typed = error as NodeJS.ErrnoException & {
867
- readonly stdout?: unknown;
868
- readonly stderr?: unknown;
869
- readonly status?: number | null;
870
- };
871
- const stdout = formatExecErrorOutput(typed.stdout);
872
- const stderr = formatExecErrorOutput(typed.stderr);
873
- if (stdout.length > 0) {
874
- process.stdout.write(stdout);
875
- }
876
- if (stderr.length > 0) {
877
- process.stderr.write(stderr);
878
- }
879
- const statusText =
880
- typeof typed.status === 'number' ? `exit=${String(typed.status)}` : 'exit=unknown';
881
- throw new Error(`harness update command failed (${statusText})`);
882
- }
883
- }
884
-
885
- function printUsage(): void {
886
- process.stdout.write(
887
- [
888
- 'usage:',
889
- ' harness [--session <name>] [mux-args...]',
890
- ' harness [--session <name>] gateway start [--host <host>] [--port <port>] [--auth-token <token>] [--state-db-path <path>]',
891
- ' harness [--session <name>] gateway run [--host <host>] [--port <port>] [--auth-token <token>] [--state-db-path <path>]',
892
- ' harness [--session <name>] gateway stop [--force] [--timeout-ms <ms>] [--cleanup-orphans|--no-cleanup-orphans]',
893
- ' harness [--session <name>] gateway status',
894
- ' harness [--session <name>] gateway restart [--host <host>] [--port <port>] [--auth-token <token>] [--state-db-path <path>]',
895
- ' harness [--session <name>] gateway call --json \'{"type":"session.list"}\'',
896
- ' harness [--session <name>] profile start [--profile-dir <path>]',
897
- ' harness [--session <name>] profile stop [--timeout-ms <ms>]',
898
- ' harness [--session <name>] profile run [--profile-dir <path>] [mux-args...]',
899
- ' harness [--session <name>] profile [--profile-dir <path>] [mux-args...]',
900
- ' harness [--session <name>] status-timeline start [--output-path <path>]',
901
- ' harness [--session <name>] status-timeline stop',
902
- ' harness [--session <name>] status-timeline [--output-path <path>]',
903
- ' harness [--session <name>] render-trace start [--output-path <path>] [--conversation-id <id>]',
904
- ' harness [--session <name>] render-trace stop',
905
- ' harness [--session <name>] render-trace [--output-path <path>] [--conversation-id <id>]',
906
- ' harness update',
907
- ' harness upgrade',
908
- ' harness cursor-hooks install [--hooks-file <path>]',
909
- ' harness cursor-hooks uninstall [--hooks-file <path>]',
910
- ' harness animate [--fps <fps>] [--frames <count>] [--duration-ms <ms>] [--seed <seed>] [--no-color]',
911
- '',
912
- 'session naming:',
913
- ' --session accepts [A-Za-z0-9][A-Za-z0-9._-]{0,63} and isolates gateway record/log/db paths.',
914
- ].join('\n') + '\n',
915
- );
916
- }
917
-
918
- function resolveScriptPath(
919
- envValue: string | undefined,
920
- fallback: string,
921
- invocationDirectory: string,
922
- ): string {
923
- if (typeof envValue !== 'string' || envValue.trim().length === 0) {
924
- return fallback;
925
- }
926
- const trimmed = envValue.trim();
927
- if (trimmed.startsWith('/')) {
928
- return trimmed;
929
- }
930
- return resolve(invocationDirectory, trimmed);
931
- }
932
-
933
- function readGatewayRecord(recordPath: string): GatewayRecord | null {
934
- if (!existsSync(recordPath)) {
935
- return null;
936
- }
937
- try {
938
- const raw = readFileSync(recordPath, 'utf8');
939
- return parseGatewayRecordText(raw);
940
- } catch {
941
- return null;
942
- }
943
- }
944
-
945
- function writeTextFileAtomically(filePath: string, text: string): void {
946
- mkdirSync(dirname(filePath), { recursive: true });
947
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
948
- try {
949
- writeFileSync(tempPath, text, 'utf8');
950
- renameSync(tempPath, filePath);
951
- } catch (error: unknown) {
952
- try {
953
- unlinkSync(tempPath);
954
- } catch {
955
- // Best-effort cleanup only.
956
- }
957
- throw error;
958
- }
959
- }
960
-
961
- function writeGatewayRecord(recordPath: string, record: GatewayRecord): void {
962
- writeTextFileAtomically(recordPath, serializeGatewayRecord(record));
963
- }
964
-
965
- function removeGatewayRecord(recordPath: string): void {
966
- try {
967
- unlinkSync(recordPath);
968
- } catch (error: unknown) {
969
- const code = (error as NodeJS.ErrnoException).code;
970
- if (code !== 'ENOENT') {
971
- throw error;
972
- }
973
- }
974
- }
975
-
976
- function readProcessStartedAt(pid: number): string | null {
977
- if (!Number.isInteger(pid) || pid <= 0) {
978
- return null;
979
- }
980
- try {
981
- const output = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
982
- encoding: 'utf8',
983
- }).trim();
984
- return output.length > 0 ? output : null;
985
- } catch {
986
- return null;
987
- }
988
- }
989
-
990
- function resolveCurrentProcessIdentity(): GatewayProcessIdentity {
991
- const startedAt = readProcessStartedAt(process.pid);
992
- if (startedAt === null) {
993
- throw new Error(
994
- `failed to resolve current process start timestamp for pid=${String(process.pid)}`,
995
- );
996
- }
997
- return {
998
- pid: process.pid,
999
- startedAt,
1000
- };
1001
- }
1002
-
1003
- function parseGatewayControlLockText(text: string): GatewayControlLockRecord | null {
1004
- let parsed: unknown;
1005
- try {
1006
- parsed = JSON.parse(text);
1007
- } catch {
1008
- return null;
1009
- }
1010
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1011
- return null;
1012
- }
1013
- const candidate = parsed as Record<string, unknown>;
1014
- if (candidate['version'] !== GATEWAY_LOCK_VERSION) {
1015
- return null;
1016
- }
1017
- if (typeof candidate['acquiredAt'] !== 'string' || candidate['acquiredAt'].trim().length === 0) {
1018
- return null;
1019
- }
1020
- if (
1021
- typeof candidate['workspaceRoot'] !== 'string' ||
1022
- candidate['workspaceRoot'].trim().length === 0
1023
- ) {
1024
- return null;
1025
- }
1026
- if (typeof candidate['token'] !== 'string' || candidate['token'].trim().length === 0) {
1027
- return null;
1028
- }
1029
- const owner = candidate['owner'];
1030
- if (typeof owner !== 'object' || owner === null || Array.isArray(owner)) {
1031
- return null;
1032
- }
1033
- const ownerRecord = owner as Record<string, unknown>;
1034
- const pid = ownerRecord['pid'];
1035
- const startedAt = ownerRecord['startedAt'];
1036
- if (!Number.isInteger(pid) || (pid as number) <= 0) {
1037
- return null;
1038
- }
1039
- if (typeof startedAt !== 'string' || startedAt.trim().length === 0) {
1040
- return null;
1041
- }
1042
- return {
1043
- version: GATEWAY_LOCK_VERSION,
1044
- owner: {
1045
- pid: pid as number,
1046
- startedAt,
1047
- },
1048
- acquiredAt: candidate['acquiredAt'] as string,
1049
- workspaceRoot: candidate['workspaceRoot'] as string,
1050
- token: candidate['token'] as string,
1051
- };
1052
- }
1053
-
1054
- function readGatewayControlLock(lockPath: string): GatewayControlLockRecord | null {
1055
- if (!existsSync(lockPath)) {
1056
- return null;
1057
- }
1058
- try {
1059
- return parseGatewayControlLockText(readFileSync(lockPath, 'utf8'));
1060
- } catch {
1061
- return null;
1062
- }
1063
- }
1064
-
1065
- function removeGatewayControlLock(lockPath: string): void {
1066
- try {
1067
- unlinkSync(lockPath);
1068
- } catch (error: unknown) {
1069
- const code = (error as NodeJS.ErrnoException).code;
1070
- if (code !== 'ENOENT') {
1071
- throw error;
1072
- }
1073
- }
1074
- }
1075
-
1076
- function isGatewayControlLockOwnerAlive(record: GatewayControlLockRecord): boolean {
1077
- if (!isPidRunning(record.owner.pid)) {
1078
- return false;
1079
- }
1080
- const startedAt = readProcessStartedAt(record.owner.pid);
1081
- if (startedAt === null) {
1082
- return false;
1083
- }
1084
- return startedAt === record.owner.startedAt;
1085
- }
1086
-
1087
- function createGatewayControlLockHandle(
1088
- lockPath: string,
1089
- record: GatewayControlLockRecord,
1090
- ): GatewayControlLockHandle {
1091
- return {
1092
- lockPath,
1093
- record,
1094
- release: () => {
1095
- const current = readGatewayControlLock(lockPath);
1096
- if (current === null) {
1097
- return;
1098
- }
1099
- if (
1100
- current.token !== record.token ||
1101
- current.owner.pid !== record.owner.pid ||
1102
- current.owner.startedAt !== record.owner.startedAt
1103
- ) {
1104
- return;
1105
- }
1106
- removeGatewayControlLock(lockPath);
1107
- },
1108
- };
1109
- }
1110
-
1111
- async function acquireGatewayControlLock(
1112
- lockPath: string,
1113
- workspaceRoot: string,
1114
- timeoutMs = DEFAULT_GATEWAY_LOCK_TIMEOUT_MS,
1115
- ): Promise<GatewayControlLockHandle> {
1116
- const owner = resolveCurrentProcessIdentity();
1117
- const deadlineMs = Date.now() + timeoutMs;
1118
- const candidate: GatewayControlLockRecord = {
1119
- version: GATEWAY_LOCK_VERSION,
1120
- owner,
1121
- acquiredAt: new Date().toISOString(),
1122
- workspaceRoot,
1123
- token: randomUUID(),
1124
- };
1125
-
1126
- while (true) {
1127
- mkdirSync(dirname(lockPath), { recursive: true });
1128
- try {
1129
- const fd = openSync(lockPath, 'wx');
1130
- try {
1131
- writeFileSync(fd, `${JSON.stringify(candidate, null, 2)}\n`, 'utf8');
1132
- } finally {
1133
- closeSync(fd);
1134
- }
1135
- return createGatewayControlLockHandle(lockPath, candidate);
1136
- } catch (error: unknown) {
1137
- const code = (error as NodeJS.ErrnoException).code;
1138
- if (code !== 'EEXIST') {
1139
- throw error;
1140
- }
1141
- }
1142
-
1143
- const existing = readGatewayControlLock(lockPath);
1144
- if (existing === null) {
1145
- removeGatewayControlLock(lockPath);
1146
- continue;
1147
- }
1148
-
1149
- if (existing.owner.pid === owner.pid && existing.owner.startedAt === owner.startedAt) {
1150
- return createGatewayControlLockHandle(lockPath, existing);
1151
- }
1152
-
1153
- if (!isGatewayControlLockOwnerAlive(existing)) {
1154
- removeGatewayControlLock(lockPath);
1155
- continue;
1156
- }
1157
-
1158
- if (Date.now() >= deadlineMs) {
1159
- throw new Error(
1160
- `timed out waiting for gateway control lock: lockPath=${lockPath} ownerPid=${String(existing.owner.pid)} acquiredAt=${existing.acquiredAt}`,
1161
- );
1162
- }
1163
- await delay(DEFAULT_GATEWAY_LOCK_POLL_MS);
1164
- }
1165
- }
1166
-
1167
- async function withGatewayControlLock<T>(
1168
- lockPath: string,
1169
- workspaceRoot: string,
1170
- operation: () => Promise<T>,
1171
- ): Promise<T> {
1172
- const handle = await acquireGatewayControlLock(lockPath, workspaceRoot);
1173
- try {
1174
- return await operation();
1175
- } finally {
1176
- handle.release();
1177
- }
1178
- }
1179
-
1180
- function parseActiveProfileState(raw: unknown): ActiveProfileState | null {
1181
- if (typeof raw !== 'object' || raw === null) {
1182
- return null;
1183
- }
1184
- const candidate = raw as Record<string, unknown>;
1185
- if (candidate['version'] !== PROFILE_STATE_VERSION) {
1186
- return null;
1187
- }
1188
- if (candidate['mode'] !== PROFILE_LIVE_INSPECT_MODE) {
1189
- return null;
1190
- }
1191
- const pid = candidate['pid'];
1192
- const host = candidate['host'];
1193
- const port = candidate['port'];
1194
- const stateDbPath = candidate['stateDbPath'];
1195
- const profileDir = candidate['profileDir'];
1196
- const gatewayProfilePath = candidate['gatewayProfilePath'];
1197
- const inspectWebSocketUrl = candidate['inspectWebSocketUrl'];
1198
- const startedAt = candidate['startedAt'];
1199
- if (!Number.isInteger(pid) || (pid as number) <= 0) {
1200
- return null;
1201
- }
1202
- if (typeof host !== 'string' || host.length === 0) {
1203
- return null;
1204
- }
1205
- if (!Number.isInteger(port) || (port as number) <= 0 || (port as number) > 65535) {
1206
- return null;
1207
- }
1208
- if (typeof stateDbPath !== 'string' || stateDbPath.length === 0) {
1209
- return null;
1210
- }
1211
- if (typeof profileDir !== 'string' || profileDir.length === 0) {
1212
- return null;
1213
- }
1214
- if (typeof gatewayProfilePath !== 'string' || gatewayProfilePath.length === 0) {
1215
- return null;
1216
- }
1217
- if (typeof inspectWebSocketUrl !== 'string' || inspectWebSocketUrl.length === 0) {
1218
- return null;
1219
- }
1220
- if (typeof startedAt !== 'string' || startedAt.length === 0) {
1221
- return null;
1222
- }
1223
- return {
1224
- version: PROFILE_STATE_VERSION,
1225
- mode: PROFILE_LIVE_INSPECT_MODE,
1226
- pid: pid as number,
1227
- host,
1228
- port: port as number,
1229
- stateDbPath,
1230
- profileDir,
1231
- gatewayProfilePath,
1232
- inspectWebSocketUrl,
1233
- startedAt,
1234
- };
1235
- }
1236
-
1237
- function readActiveProfileState(profileStatePath: string): ActiveProfileState | null {
1238
- if (!existsSync(profileStatePath)) {
1239
- return null;
1240
- }
1241
- try {
1242
- const raw = JSON.parse(readFileSync(profileStatePath, 'utf8')) as unknown;
1243
- return parseActiveProfileState(raw);
1244
- } catch {
1245
- return null;
1246
- }
1247
- }
1248
-
1249
- function writeActiveProfileState(profileStatePath: string, state: ActiveProfileState): void {
1250
- writeTextFileAtomically(profileStatePath, `${JSON.stringify(state, null, 2)}\n`);
1251
- }
1252
-
1253
- function removeActiveProfileState(profileStatePath: string): void {
1254
- removeFileIfExists(profileStatePath);
1255
- }
1256
-
1257
- function readActiveStatusTimelineState(statePath: string): ActiveStatusTimelineState | null {
1258
- if (!existsSync(statePath)) {
1259
- return null;
1260
- }
1261
- try {
1262
- const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
1263
- const parsed = parseActiveStatusTimelineState(raw);
1264
- if (parsed === null) {
1265
- return null;
1266
- }
1267
- return {
1268
- version: parsed.version,
1269
- mode: parsed.mode,
1270
- outputPath: parsed.outputPath,
1271
- sessionName: parsed.sessionName,
1272
- startedAt: parsed.startedAt,
1273
- };
1274
- } catch {
1275
- return null;
1276
- }
1277
- }
1278
-
1279
- function writeActiveStatusTimelineState(statePath: string, state: ActiveStatusTimelineState): void {
1280
- writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
1281
- }
1282
-
1283
- function removeActiveStatusTimelineState(statePath: string): void {
1284
- removeFileIfExists(statePath);
1285
- }
1286
-
1287
- function readActiveRenderTraceState(statePath: string): ActiveRenderTraceState | null {
1288
- if (!existsSync(statePath)) {
1289
- return null;
1290
- }
1291
- try {
1292
- const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
1293
- const parsed = parseActiveRenderTraceState(raw);
1294
- if (parsed === null) {
1295
- return null;
1296
- }
1297
- return {
1298
- version: parsed.version,
1299
- mode: parsed.mode,
1300
- outputPath: parsed.outputPath,
1301
- sessionName: parsed.sessionName,
1302
- conversationId: parsed.conversationId,
1303
- startedAt: parsed.startedAt,
1304
- };
1305
- } catch {
1306
- return null;
1307
- }
1308
- }
1309
-
1310
- function writeActiveRenderTraceState(statePath: string, state: ActiveRenderTraceState): void {
1311
- writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
1312
- }
1313
-
1314
- function removeActiveRenderTraceState(statePath: string): void {
1315
- removeFileIfExists(statePath);
1316
- }
1317
-
1318
- function isPidRunning(pid: number): boolean {
1319
- if (!Number.isInteger(pid) || pid <= 0) {
1320
- return false;
1321
- }
1322
- try {
1323
- process.kill(pid, 0);
1324
- return true;
1325
- } catch (error: unknown) {
1326
- const code = (error as NodeJS.ErrnoException).code;
1327
- if (code === 'ESRCH') {
1328
- return false;
1329
- }
1330
- return true;
1331
- }
1332
- }
1333
-
1334
- async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
1335
- const startedAt = Date.now();
1336
- while (Date.now() - startedAt < timeoutMs) {
1337
- if (!isPidRunning(pid)) {
1338
- return true;
1339
- }
1340
- await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
1341
- }
1342
- return !isPidRunning(pid);
1343
- }
1344
-
1345
- async function waitForFileExists(filePath: string, timeoutMs: number): Promise<boolean> {
1346
- const startedAt = Date.now();
1347
- while (Date.now() - startedAt < timeoutMs) {
1348
- if (existsSync(filePath)) {
1349
- return true;
1350
- }
1351
- await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
1352
- }
1353
- return existsSync(filePath);
1354
- }
1355
-
1356
- function signalPidWithOptionalProcessGroup(
1357
- pid: number,
1358
- signal: NodeJS.Signals,
1359
- includeProcessGroup: boolean,
1360
- ): boolean {
1361
- let sent = false;
1362
- if (includeProcessGroup && pid > 1) {
1363
- try {
1364
- process.kill(-pid, signal);
1365
- sent = true;
1366
- } catch (error: unknown) {
1367
- const code = (error as NodeJS.ErrnoException).code;
1368
- if (code !== 'ESRCH') {
1369
- throw error;
1370
- }
1371
- }
1372
- }
1373
-
1374
- try {
1375
- process.kill(pid, signal);
1376
- sent = true;
1377
- } catch (error: unknown) {
1378
- const code = (error as NodeJS.ErrnoException).code;
1379
- if (code !== 'ESRCH') {
1380
- throw error;
1381
- }
1382
- }
1383
-
1384
- return sent;
1385
- }
1386
-
1387
- function readProcessTable(): readonly ProcessTableEntry[] {
1388
- const output = execFileSync('ps', ['-axww', '-o', 'pid=,ppid=,command='], {
1389
- encoding: 'utf8',
1390
- });
1391
- const lines = output.split('\n');
1392
- const entries: ProcessTableEntry[] = [];
1393
- for (const line of lines) {
1394
- const trimmed = line.trim();
1395
- if (trimmed.length === 0) {
1396
- continue;
1397
- }
1398
- const match = /^(\d+)\s+(\d+)\s+(.*)$/u.exec(trimmed);
1399
- if (match === null) {
1400
- continue;
1401
- }
1402
- const pid = Number.parseInt(match[1] ?? '', 10);
1403
- const ppid = Number.parseInt(match[2] ?? '', 10);
1404
- const command = match[3] ?? '';
1405
- if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid < 0) {
1406
- continue;
1407
- }
1408
- entries.push({
1409
- pid,
1410
- ppid,
1411
- command,
1412
- });
1413
- }
1414
- return entries;
1415
- }
1416
-
1417
- function tokenizeProcessCommand(command: string): readonly string[] {
1418
- const trimmed = command.trim();
1419
- return trimmed.length === 0 ? [] : trimmed.split(/\s+/u);
1420
- }
1421
-
1422
- function readCommandFlagValue(tokens: readonly string[], flag: string): string | null {
1423
- for (let index = 0; index < tokens.length; index += 1) {
1424
- const token = tokens[index]!;
1425
- if (token === flag) {
1426
- const value = tokens[index + 1];
1427
- return value === undefined ? null : value;
1428
- }
1429
- if (token.startsWith(`${flag}=`)) {
1430
- const value = token.slice(flag.length + 1);
1431
- return value.length === 0 ? null : value;
1432
- }
1433
- }
1434
- return null;
1435
- }
1436
-
1437
- function parseGatewayDaemonProcessEntry(entry: ProcessTableEntry): ParsedGatewayDaemonEntry | null {
1438
- if (!/\bcontrol-plane-daemon\.(?:ts|js)\b/u.test(entry.command)) {
1439
- return null;
1440
- }
1441
- const tokens = tokenizeProcessCommand(entry.command);
1442
- const host = readCommandFlagValue(tokens, '--host');
1443
- const portRaw = readCommandFlagValue(tokens, '--port');
1444
- const stateDbPath = readCommandFlagValue(tokens, '--state-db-path');
1445
- const authToken = readCommandFlagValue(tokens, '--auth-token');
1446
- if (host === null || portRaw === null || stateDbPath === null) {
1447
- return null;
1448
- }
1449
- const port = Number.parseInt(portRaw, 10);
1450
- if (!Number.isFinite(port) || !Number.isInteger(port) || port <= 0 || port > 65535) {
1451
- return null;
1452
- }
1453
- return {
1454
- pid: entry.pid,
1455
- host,
1456
- port,
1457
- authToken,
1458
- stateDbPath: resolve(stateDbPath),
1459
- };
1460
- }
1461
-
1462
- function listGatewayDaemonProcesses(): readonly ParsedGatewayDaemonEntry[] {
1463
- const parsed: ParsedGatewayDaemonEntry[] = [];
1464
- for (const entry of readProcessTable()) {
1465
- const daemon = parseGatewayDaemonProcessEntry(entry);
1466
- if (daemon !== null) {
1467
- parsed.push(daemon);
1468
- }
1469
- }
1470
- return parsed;
1471
- }
1472
-
1473
- function isPathWithinWorkspaceRuntimeScope(
1474
- pathValue: string,
1475
- invocationDirectory: string,
1476
- ): boolean {
1477
- const runtimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, process.env);
1478
- const normalizedRoot = resolve(runtimeRoot);
1479
- const normalizedPath = resolve(pathValue);
1480
- return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
1481
- }
1482
-
1483
- function findOrphanSqlitePidsForDbPath(stateDbPath: string): readonly number[] {
1484
- const normalizedDbPath = resolve(stateDbPath);
1485
- return readProcessTable()
1486
- .filter((entry) => entry.ppid === 1)
1487
- .filter((entry) => entry.pid !== process.pid)
1488
- .filter((entry) => /\bsqlite3\b/u.test(entry.command))
1489
- .filter((entry) => entry.command.includes(normalizedDbPath))
1490
- .map((entry) => entry.pid);
1491
- }
1492
-
1493
- function dedupePids(pids: readonly number[]): readonly number[] {
1494
- return [...new Set(pids)];
1495
- }
1496
-
1497
- function resolvePtyHelperPathCandidates(invocationDirectory: string): readonly string[] {
1498
- return [
1499
- resolve(invocationDirectory, 'native/ptyd/target/release/ptyd'),
1500
- resolve(invocationDirectory, 'bin/ptyd'),
1501
- ];
1502
- }
1503
-
1504
- function findOrphanGatewayDaemonPids(
1505
- stateDbPath: string,
1506
- daemonScriptPath: string,
1507
- ): readonly number[] {
1508
- const normalizedDbPath = resolve(stateDbPath);
1509
- const normalizedDaemonScriptPath = resolve(daemonScriptPath);
1510
- return dedupePids(
1511
- readProcessTable()
1512
- .filter((entry) => entry.ppid === 1)
1513
- .filter((entry) => entry.pid !== process.pid)
1514
- .filter((entry) => entry.command.includes('--state-db-path'))
1515
- .filter((entry) => {
1516
- if (entry.command.includes(normalizedDaemonScriptPath)) {
1517
- return true;
1518
- }
1519
- return (
1520
- /\bcontrol-plane-daemon\.(?:ts|js)\b/u.test(entry.command) &&
1521
- entry.command.includes(normalizedDbPath)
1522
- );
1523
- })
1524
- .map((entry) => entry.pid),
1525
- );
1526
- }
1527
-
1528
- function findOrphanPtyHelperPidsForWorkspace(invocationDirectory: string): readonly number[] {
1529
- const helperPathCandidates = resolvePtyHelperPathCandidates(invocationDirectory);
1530
- return readProcessTable()
1531
- .filter((entry) => entry.ppid === 1)
1532
- .filter((entry) => entry.pid !== process.pid)
1533
- .filter((entry) => helperPathCandidates.some((candidate) => entry.command.includes(candidate)))
1534
- .map((entry) => entry.pid);
1535
- }
1536
-
1537
- function findOrphanRelayLinkedAgentPidsForWorkspace(
1538
- invocationDirectory: string,
1539
- ): readonly number[] {
1540
- const relayScriptPath = resolve(invocationDirectory, 'scripts/codex-notify-relay.ts');
1541
- return readProcessTable()
1542
- .filter((entry) => entry.ppid === 1)
1543
- .filter((entry) => entry.pid !== process.pid)
1544
- .filter((entry) => entry.command.includes(relayScriptPath))
1545
- .map((entry) => entry.pid);
1546
- }
1547
-
1548
- function formatOrphanProcessCleanupResult(
1549
- label: string,
1550
- result: OrphanProcessCleanupResult,
1551
- ): string {
1552
- if (result.errorMessage !== null) {
1553
- return `${label} cleanup error: ${result.errorMessage}`;
1554
- }
1555
- if (result.matchedPids.length === 0) {
1556
- return `${label} cleanup: none found`;
1557
- }
1558
- if (result.failedPids.length === 0) {
1559
- return `${label} cleanup: terminated ${String(result.terminatedPids.length)} process(es)`;
1560
- }
1561
- return [
1562
- `${label} cleanup:`,
1563
- `matched=${String(result.matchedPids.length)}`,
1564
- `terminated=${String(result.terminatedPids.length)}`,
1565
- `failed=${String(result.failedPids.length)}`,
1566
- ].join(' ');
1567
- }
1568
-
1569
- async function cleanupOrphanPids(
1570
- matchedPids: readonly number[],
1571
- options: GatewayStopOptions,
1572
- killProcessGroup = false,
1573
- ): Promise<OrphanProcessCleanupResult> {
1574
- const terminatedPids: number[] = [];
1575
- const failedPids: number[] = [];
1576
-
1577
- for (const pid of matchedPids) {
1578
- if (!isPidRunning(pid)) {
1579
- continue;
1580
- }
1581
- const signaledTerm = signalPidWithOptionalProcessGroup(pid, 'SIGTERM', killProcessGroup);
1582
- if (!signaledTerm) {
1583
- terminatedPids.push(pid);
1584
- continue;
1585
- }
1586
-
1587
- const exitedAfterTerm = await waitForPidExit(pid, options.timeoutMs);
1588
- if (exitedAfterTerm) {
1589
- terminatedPids.push(pid);
1590
- continue;
1591
- }
1592
-
1593
- if (!options.force) {
1594
- failedPids.push(pid);
1595
- continue;
1596
- }
1597
-
1598
- const signaledKill = signalPidWithOptionalProcessGroup(pid, 'SIGKILL', killProcessGroup);
1599
- if (!signaledKill) {
1600
- terminatedPids.push(pid);
1601
- continue;
1602
- }
1603
-
1604
- if (await waitForPidExit(pid, options.timeoutMs)) {
1605
- terminatedPids.push(pid);
1606
- } else {
1607
- failedPids.push(pid);
1608
- }
1609
- }
1610
-
1611
- return {
1612
- matchedPids,
1613
- terminatedPids,
1614
- failedPids,
1615
- errorMessage: null,
1616
- };
1617
- }
1618
-
1619
- async function cleanupOrphanSqliteProcessesForDbPath(
1620
- stateDbPath: string,
1621
- options: GatewayStopOptions,
1622
- ): Promise<OrphanProcessCleanupResult> {
1623
- let matchedPids: readonly number[] = [];
1624
- try {
1625
- matchedPids = findOrphanSqlitePidsForDbPath(stateDbPath);
1626
- } catch (error: unknown) {
1627
- return {
1628
- matchedPids: [],
1629
- terminatedPids: [],
1630
- failedPids: [],
1631
- errorMessage: error instanceof Error ? error.message : String(error),
1632
- };
1633
- }
1634
- return await cleanupOrphanPids(matchedPids, options, false);
1635
- }
1636
-
1637
- async function cleanupOrphanGatewayDaemons(
1638
- stateDbPath: string,
1639
- daemonScriptPath: string,
1640
- options: GatewayStopOptions,
1641
- ): Promise<OrphanProcessCleanupResult> {
1642
- let matchedPids: readonly number[] = [];
1643
- try {
1644
- matchedPids = findOrphanGatewayDaemonPids(stateDbPath, daemonScriptPath);
1645
- } catch (error: unknown) {
1646
- return {
1647
- matchedPids: [],
1648
- terminatedPids: [],
1649
- failedPids: [],
1650
- errorMessage: error instanceof Error ? error.message : String(error),
1651
- };
1652
- }
1653
- return await cleanupOrphanPids(matchedPids, options, true);
1654
- }
1655
-
1656
- async function cleanupOrphanPtyHelpersForWorkspace(
1657
- invocationDirectory: string,
1658
- options: GatewayStopOptions,
1659
- ): Promise<OrphanProcessCleanupResult> {
1660
- let matchedPids: readonly number[] = [];
1661
- try {
1662
- matchedPids = findOrphanPtyHelperPidsForWorkspace(invocationDirectory);
1663
- } catch (error: unknown) {
1664
- return {
1665
- matchedPids: [],
1666
- terminatedPids: [],
1667
- failedPids: [],
1668
- errorMessage: error instanceof Error ? error.message : String(error),
1669
- };
1670
- }
1671
- return await cleanupOrphanPids(matchedPids, options, false);
1672
- }
1673
-
1674
- async function cleanupOrphanRelayLinkedAgentsForWorkspace(
1675
- invocationDirectory: string,
1676
- options: GatewayStopOptions,
1677
- ): Promise<OrphanProcessCleanupResult> {
1678
- let matchedPids: readonly number[] = [];
1679
- try {
1680
- matchedPids = findOrphanRelayLinkedAgentPidsForWorkspace(invocationDirectory);
1681
- } catch (error: unknown) {
1682
- return {
1683
- matchedPids: [],
1684
- terminatedPids: [],
1685
- failedPids: [],
1686
- errorMessage: error instanceof Error ? error.message : String(error),
1687
- };
1688
- }
1689
- return await cleanupOrphanPids(matchedPids, options, false);
1690
- }
1691
-
1692
- function resolveGatewaySettings(
1693
- invocationDirectory: string,
1694
- record: GatewayRecord | null,
1695
- overrides: GatewayStartOptions,
1696
- env: NodeJS.ProcessEnv,
1697
- defaultStateDbPath: string,
1698
- ): ResolvedGatewaySettings {
1699
- const host = normalizeGatewayHost(
1700
- overrides.host ?? record?.host ?? env.HARNESS_CONTROL_PLANE_HOST,
1701
- );
1702
- const port = normalizeGatewayPort(
1703
- overrides.port ?? record?.port ?? env.HARNESS_CONTROL_PLANE_PORT,
1704
- );
1705
- const configuredStateDbPath =
1706
- overrides.stateDbPath ?? env.HARNESS_CONTROL_PLANE_DB_PATH ?? defaultStateDbPath;
1707
- const stateDbPathRaw = normalizeGatewayStateDbPath(configuredStateDbPath, defaultStateDbPath);
1708
- const stateDbPath = resolveHarnessRuntimePath(invocationDirectory, stateDbPathRaw, env);
1709
-
1710
- const envToken =
1711
- typeof env.HARNESS_CONTROL_PLANE_AUTH_TOKEN === 'string' &&
1712
- env.HARNESS_CONTROL_PLANE_AUTH_TOKEN.trim().length > 0
1713
- ? env.HARNESS_CONTROL_PLANE_AUTH_TOKEN.trim()
1714
- : null;
1715
- const explicitToken = overrides.authToken ?? record?.authToken ?? envToken;
1716
- const authToken = explicitToken ?? (isLoopbackHost(host) ? `gateway-${randomUUID()}` : null);
1717
-
1718
- if (!isLoopbackHost(host) && authToken === null) {
1719
- throw new Error('non-loopback hosts require --auth-token or HARNESS_CONTROL_PLANE_AUTH_TOKEN');
1720
- }
1721
-
1722
- return {
1723
- host,
1724
- port,
1725
- authToken,
1726
- stateDbPath,
1727
- };
1728
- }
1729
-
1730
- async function probeGatewayEndpoint(
1731
- host: string,
1732
- port: number,
1733
- authToken: string | null,
1734
- ): Promise<GatewayProbeResult> {
1735
- try {
1736
- const client = await connectControlPlaneStreamClient({
1737
- host,
1738
- port,
1739
- ...(authToken !== null
1740
- ? {
1741
- authToken,
1742
- }
1743
- : {}),
1744
- });
1745
- try {
1746
- const result = await client.sendCommand({
1747
- type: 'session.list',
1748
- });
1749
- const sessionsRaw = result['sessions'];
1750
- if (!Array.isArray(sessionsRaw)) {
1751
- return {
1752
- connected: true,
1753
- sessionCount: 0,
1754
- liveSessionCount: 0,
1755
- error: null,
1756
- };
1757
- }
1758
- let liveCount = 0;
1759
- for (const session of sessionsRaw) {
1760
- if (
1761
- typeof session === 'object' &&
1762
- session !== null &&
1763
- (session as Record<string, unknown>)['live'] === true
1764
- ) {
1765
- liveCount += 1;
1766
- }
1767
- }
1768
- return {
1769
- connected: true,
1770
- sessionCount: sessionsRaw.length,
1771
- liveSessionCount: liveCount,
1772
- error: null,
1773
- };
1774
- } finally {
1775
- client.close();
1776
- }
1777
- } catch (error: unknown) {
1778
- return {
1779
- connected: false,
1780
- sessionCount: 0,
1781
- liveSessionCount: 0,
1782
- error: error instanceof Error ? error.message : String(error),
1783
- };
1784
- }
1785
- }
1786
-
1787
- async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult> {
1788
- return await probeGatewayEndpoint(record.host, record.port, record.authToken);
1789
- }
1790
-
1791
- async function waitForGatewayReady(record: GatewayRecord): Promise<void> {
1792
- const client = await connectControlPlaneStreamClient({
1793
- host: record.host,
1794
- port: record.port,
1795
- ...(record.authToken !== null
1796
- ? {
1797
- authToken: record.authToken,
1798
- }
1799
- : {}),
1800
- connectRetryWindowMs: DEFAULT_GATEWAY_START_RETRY_WINDOW_MS,
1801
- connectRetryDelayMs: DEFAULT_GATEWAY_START_RETRY_DELAY_MS,
1802
- });
1803
- try {
1804
- await client.sendCommand({
1805
- type: 'session.list',
1806
- limit: 1,
1807
- });
1808
- } finally {
1809
- client.close();
1810
- }
1811
- }
1812
-
1813
- async function startDetachedGateway(
1814
- invocationDirectory: string,
1815
- recordPath: string,
1816
- logPath: string,
1817
- settings: ResolvedGatewaySettings,
1818
- daemonScriptPath: string,
1819
- runtimeArgs: readonly string[] = [],
1820
- ): Promise<GatewayRecord> {
1821
- mkdirSync(dirname(logPath), { recursive: true });
1822
- const logFd = openSync(logPath, 'a');
1823
- const gatewayRunId = randomUUID();
1824
- const daemonArgs = tsRuntimeArgs(
1825
- daemonScriptPath,
1826
- [
1827
- '--host',
1828
- settings.host,
1829
- '--port',
1830
- String(settings.port),
1831
- '--state-db-path',
1832
- settings.stateDbPath,
1833
- ],
1834
- runtimeArgs,
1835
- );
1836
- if (settings.authToken !== null) {
1837
- daemonArgs.push('--auth-token', settings.authToken);
1838
- }
1839
- const child = spawn(process.execPath, daemonArgs, {
1840
- detached: true,
1841
- stdio: ['ignore', logFd, logFd],
1842
- env: {
1843
- ...process.env,
1844
- HARNESS_INVOKE_CWD: invocationDirectory,
1845
- HARNESS_GATEWAY_RUN_ID: gatewayRunId,
1846
- },
1847
- });
1848
- closeSync(logFd);
1849
-
1850
- if (child.pid === undefined) {
1851
- throw new Error('failed to start gateway daemon (missing pid)');
1852
- }
1853
-
1854
- const record: GatewayRecord = {
1855
- version: GATEWAY_RECORD_VERSION,
1856
- pid: child.pid,
1857
- host: settings.host,
1858
- port: settings.port,
1859
- authToken: settings.authToken,
1860
- stateDbPath: settings.stateDbPath,
1861
- startedAt: new Date().toISOString(),
1862
- workspaceRoot: invocationDirectory,
1863
- gatewayRunId,
1864
- };
1865
-
1866
- try {
1867
- await waitForGatewayReady(record);
1868
- if (!isPidRunning(child.pid)) {
1869
- throw new Error(
1870
- `gateway daemon exited during startup (pid=${String(child.pid)}); possible duplicate start or port collision`,
1871
- );
1872
- }
1873
- } catch (error: unknown) {
1874
- try {
1875
- process.kill(child.pid, 'SIGTERM');
1876
- } catch {
1877
- // Best-effort cleanup only.
1878
- }
1879
- throw error;
1880
- }
1881
-
1882
- writeGatewayRecord(recordPath, record);
1883
- child.unref();
1884
- return record;
1885
- }
1886
-
1887
- function authTokenMatches(
1888
- candidate: ParsedGatewayDaemonEntry,
1889
- expectedAuthToken: string | null,
1890
- ): boolean {
1891
- if (expectedAuthToken === null) {
1892
- return candidate.authToken === null;
1893
- }
1894
- return candidate.authToken === expectedAuthToken;
1895
- }
1896
-
1897
- function findReachableGatewayDaemonCandidates(
1898
- invocationDirectory: string,
1899
- settings: ResolvedGatewaySettings,
1900
- ): readonly ParsedGatewayDaemonEntry[] {
1901
- return listGatewayDaemonProcesses().filter((candidate) => {
1902
- if (candidate.host !== settings.host || candidate.port !== settings.port) {
1903
- return false;
1904
- }
1905
- if (!authTokenMatches(candidate, settings.authToken)) {
1906
- return false;
1907
- }
1908
- return isPathWithinWorkspaceRuntimeScope(candidate.stateDbPath, invocationDirectory);
1909
- });
1910
- }
1911
-
1912
- function createAdoptedGatewayRecord(
1913
- invocationDirectory: string,
1914
- daemon: ParsedGatewayDaemonEntry,
1915
- ): GatewayRecord {
1916
- return {
1917
- version: GATEWAY_RECORD_VERSION,
1918
- pid: daemon.pid,
1919
- host: daemon.host,
1920
- port: daemon.port,
1921
- authToken: daemon.authToken,
1922
- stateDbPath: daemon.stateDbPath,
1923
- startedAt: new Date().toISOString(),
1924
- workspaceRoot: invocationDirectory,
1925
- };
1926
- }
1927
-
1928
- async function ensureGatewayRunning(
1929
- invocationDirectory: string,
1930
- recordPath: string,
1931
- logPath: string,
1932
- daemonScriptPath: string,
1933
- defaultStateDbPath: string,
1934
- overrides: GatewayStartOptions = {},
1935
- daemonRuntimeArgs: readonly string[] = [],
1936
- ): Promise<EnsureGatewayResult> {
1937
- const existingRecord = readGatewayRecord(recordPath);
1938
- if (existingRecord !== null) {
1939
- const probe = await probeGateway(existingRecord);
1940
- if (probe.connected) {
1941
- return {
1942
- record: existingRecord,
1943
- started: false,
1944
- };
1945
- }
1946
- if (isPidRunning(existingRecord.pid)) {
1947
- throw new Error(
1948
- `gateway record is present but unreachable (pid=${String(existingRecord.pid)} still running): ${probe.error ?? 'unknown error'}`,
1949
- );
1950
- }
1951
- removeGatewayRecord(recordPath);
1952
- }
1953
-
1954
- const settings = resolveGatewaySettings(
1955
- invocationDirectory,
1956
- existingRecord,
1957
- overrides,
1958
- process.env,
1959
- defaultStateDbPath,
1960
- );
1961
- if (existingRecord === null) {
1962
- const endpointProbe = await probeGatewayEndpoint(
1963
- settings.host,
1964
- settings.port,
1965
- settings.authToken,
1966
- );
1967
- if (endpointProbe.connected) {
1968
- const candidates = findReachableGatewayDaemonCandidates(invocationDirectory, settings);
1969
- if (candidates.length === 1) {
1970
- const adopted = createAdoptedGatewayRecord(invocationDirectory, candidates[0]!);
1971
- writeGatewayRecord(recordPath, adopted);
1972
- return {
1973
- record: adopted,
1974
- started: false,
1975
- };
1976
- }
1977
- if (candidates.length > 1) {
1978
- const pidList = candidates.map((candidate) => String(candidate.pid)).join(', ');
1979
- throw new Error(
1980
- `gateway endpoint reachable with multiple daemon candidates (${pidList}); stop with \`harness gateway stop --force\` and retry`,
1981
- );
1982
- }
1983
- throw new Error(
1984
- 'gateway endpoint is reachable but no matching daemon could be adopted; stop with `harness gateway stop --force` and retry',
1985
- );
1986
- }
1987
- }
1988
- const record = await startDetachedGateway(
1989
- invocationDirectory,
1990
- recordPath,
1991
- logPath,
1992
- settings,
1993
- daemonScriptPath,
1994
- daemonRuntimeArgs,
1995
- );
1996
- return {
1997
- record,
1998
- started: true,
1999
- };
2000
- }
2001
-
2002
- async function stopGateway(
2003
- invocationDirectory: string,
2004
- daemonScriptPath: string,
2005
- recordPath: string,
2006
- defaultStateDbPath: string,
2007
- options: GatewayStopOptions,
2008
- ): Promise<{ stopped: boolean; message: string }> {
2009
- const appendCleanupSummary = async (
2010
- baseMessage: string,
2011
- stateDbPath: string,
2012
- ): Promise<string> => {
2013
- if (!options.cleanupOrphans) {
2014
- return baseMessage;
2015
- }
2016
- const [gatewayCleanupResult, ptyCleanupResult, relayCleanupResult, sqliteCleanupResult] =
2017
- await Promise.all([
2018
- cleanupOrphanGatewayDaemons(stateDbPath, daemonScriptPath, options),
2019
- cleanupOrphanPtyHelpersForWorkspace(invocationDirectory, options),
2020
- cleanupOrphanRelayLinkedAgentsForWorkspace(invocationDirectory, options),
2021
- cleanupOrphanSqliteProcessesForDbPath(stateDbPath, options),
2022
- ]);
2023
- return [
2024
- baseMessage,
2025
- formatOrphanProcessCleanupResult('orphan gateway daemon', gatewayCleanupResult),
2026
- formatOrphanProcessCleanupResult('orphan pty helper', ptyCleanupResult),
2027
- formatOrphanProcessCleanupResult('orphan relay-linked agent', relayCleanupResult),
2028
- formatOrphanProcessCleanupResult('orphan sqlite', sqliteCleanupResult),
2029
- ].join('; ');
2030
- };
2031
-
2032
- const record = readGatewayRecord(recordPath);
2033
- if (record === null) {
2034
- return {
2035
- stopped: false,
2036
- message: await appendCleanupSummary('gateway not running (no record)', defaultStateDbPath),
2037
- };
2038
- }
2039
-
2040
- const probe = await probeGateway(record);
2041
- const pidRunning = isPidRunning(record.pid);
2042
-
2043
- if (!probe.connected && pidRunning && !options.force) {
2044
- return {
2045
- stopped: false,
2046
- message: `gateway record points to a running but unreachable process (pid=${String(record.pid)}); re-run with --force`,
2047
- };
2048
- }
2049
-
2050
- if (!pidRunning) {
2051
- removeGatewayRecord(recordPath);
2052
- return {
2053
- stopped: true,
2054
- message: await appendCleanupSummary('removed stale gateway record', record.stateDbPath),
2055
- };
2056
- }
2057
-
2058
- const signaledTerm = signalPidWithOptionalProcessGroup(record.pid, 'SIGTERM', true);
2059
- if (!signaledTerm) {
2060
- removeGatewayRecord(recordPath);
2061
- return {
2062
- stopped: true,
2063
- message: await appendCleanupSummary('gateway already exited', record.stateDbPath),
2064
- };
2065
- }
2066
-
2067
- const exitedAfterTerm = await waitForPidExit(record.pid, options.timeoutMs);
2068
- if (!exitedAfterTerm && options.force) {
2069
- signalPidWithOptionalProcessGroup(record.pid, 'SIGKILL', true);
2070
- const exitedAfterKill = await waitForPidExit(record.pid, options.timeoutMs);
2071
- if (!exitedAfterKill) {
2072
- return {
2073
- stopped: false,
2074
- message: `gateway did not exit after SIGKILL (pid=${String(record.pid)})`,
2075
- };
2076
- }
2077
- } else if (!exitedAfterTerm) {
2078
- return {
2079
- stopped: false,
2080
- message: `gateway did not exit after ${String(options.timeoutMs)}ms; retry with --force`,
2081
- };
2082
- }
2083
-
2084
- removeGatewayRecord(recordPath);
2085
- return {
2086
- stopped: true,
2087
- message: await appendCleanupSummary(
2088
- `gateway stopped (pid=${String(record.pid)})`,
2089
- record.stateDbPath,
2090
- ),
2091
- };
2092
- }
2093
-
2094
- async function runMuxClient(
2095
- muxScriptPath: string,
2096
- invocationDirectory: string,
2097
- gateway: GatewayRecord,
2098
- passthroughArgs: readonly string[],
2099
- sessionName: string | null,
2100
- runtimeArgs: readonly string[] = [],
2101
- ): Promise<number> {
2102
- const args = tsRuntimeArgs(
2103
- muxScriptPath,
2104
- [
2105
- '--harness-server-host',
2106
- gateway.host,
2107
- '--harness-server-port',
2108
- String(gateway.port),
2109
- ...(gateway.authToken === null ? [] : ['--harness-server-token', gateway.authToken]),
2110
- ...passthroughArgs,
2111
- ],
2112
- runtimeArgs,
2113
- );
2114
-
2115
- const child = spawn(process.execPath, args, {
2116
- stdio: 'inherit',
2117
- env: {
2118
- ...process.env,
2119
- HARNESS_INVOKE_CWD: invocationDirectory,
2120
- ...(sessionName === null ? {} : { HARNESS_SESSION_NAME: sessionName }),
2121
- },
2122
- });
2123
- const exit = await once(child, 'exit');
2124
- const code = (exit[0] as number | null) ?? null;
2125
- const signal = (exit[1] as NodeJS.Signals | null) ?? null;
2126
- if (code !== null) {
2127
- return code;
2128
- }
2129
- return normalizeSignalExitCode(signal);
2130
- }
2131
-
2132
- async function runGatewayForeground(
2133
- daemonScriptPath: string,
2134
- invocationDirectory: string,
2135
- recordPath: string,
2136
- settings: ResolvedGatewaySettings,
2137
- runtimeArgs: readonly string[] = [],
2138
- ): Promise<number> {
2139
- const gatewayRunId = randomUUID();
2140
- const existingRecord = readGatewayRecord(recordPath);
2141
- if (existingRecord !== null) {
2142
- const probe = await probeGateway(existingRecord);
2143
- if (probe.connected || isPidRunning(existingRecord.pid)) {
2144
- throw new Error('gateway is already running; stop it first or use `harness gateway start`');
2145
- }
2146
- removeGatewayRecord(recordPath);
2147
- }
2148
-
2149
- const daemonArgs = tsRuntimeArgs(
2150
- daemonScriptPath,
2151
- [
2152
- '--host',
2153
- settings.host,
2154
- '--port',
2155
- String(settings.port),
2156
- '--state-db-path',
2157
- settings.stateDbPath,
2158
- ],
2159
- runtimeArgs,
2160
- );
2161
- if (settings.authToken !== null) {
2162
- daemonArgs.push('--auth-token', settings.authToken);
2163
- }
2164
-
2165
- const child = spawn(process.execPath, daemonArgs, {
2166
- stdio: 'inherit',
2167
- env: {
2168
- ...process.env,
2169
- HARNESS_INVOKE_CWD: invocationDirectory,
2170
- HARNESS_GATEWAY_RUN_ID: gatewayRunId,
2171
- },
2172
- });
2173
- if (child.pid !== undefined) {
2174
- writeGatewayRecord(recordPath, {
2175
- version: GATEWAY_RECORD_VERSION,
2176
- pid: child.pid,
2177
- host: settings.host,
2178
- port: settings.port,
2179
- authToken: settings.authToken,
2180
- stateDbPath: settings.stateDbPath,
2181
- startedAt: new Date().toISOString(),
2182
- workspaceRoot: invocationDirectory,
2183
- gatewayRunId,
2184
- });
2185
- }
2186
-
2187
- const exit = await once(child, 'exit');
2188
- const code = (exit[0] as number | null) ?? null;
2189
- const signal = (exit[1] as NodeJS.Signals | null) ?? null;
2190
- const record = readGatewayRecord(recordPath);
2191
- if (record !== null && child.pid !== undefined && record.pid === child.pid) {
2192
- removeGatewayRecord(recordPath);
2193
- }
2194
- if (code !== null) {
2195
- return code;
2196
- }
2197
- return normalizeSignalExitCode(signal);
2198
- }
2199
-
2200
- function parseCallCommand(raw: string): StreamCommand {
2201
- let parsed: unknown;
2202
- try {
2203
- parsed = JSON.parse(raw);
2204
- } catch (error: unknown) {
2205
- throw new Error(
2206
- `invalid JSON command: ${error instanceof Error ? error.message : String(error)}`,
2207
- );
2208
- }
2209
- const command = parseStreamCommand(parsed);
2210
- if (command === null) {
2211
- throw new Error('invalid stream command payload');
2212
- }
2213
- return command;
2214
- }
2215
-
2216
- async function executeGatewayCall(record: GatewayRecord, rawCommand: string): Promise<number> {
2217
- const command = parseCallCommand(rawCommand);
2218
- const client = await connectControlPlaneStreamClient({
2219
- host: record.host,
2220
- port: record.port,
2221
- ...(record.authToken === null
2222
- ? {}
2223
- : {
2224
- authToken: record.authToken,
2225
- }),
2226
- });
2227
- try {
2228
- const result = await client.sendCommand(command);
2229
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2230
- } finally {
2231
- client.close();
2232
- }
2233
- return 0;
2234
- }
2235
-
2236
- async function runGatewayCommandEntry(
2237
- command: ParsedGatewayCommand,
2238
- invocationDirectory: string,
2239
- daemonScriptPath: string,
2240
- lockPath: string,
2241
- recordPath: string,
2242
- logPath: string,
2243
- defaultStateDbPath: string,
2244
- runtimeOptions: RuntimeInspectOptions,
2245
- ): Promise<number> {
2246
- const withLock = async <T>(operation: () => Promise<T>): Promise<T> => {
2247
- return await withGatewayControlLock(lockPath, invocationDirectory, operation);
2248
- };
2249
-
2250
- if (command.type === 'status') {
2251
- return await withLock(async () => {
2252
- const record = readGatewayRecord(recordPath);
2253
- if (record === null) {
2254
- process.stdout.write('gateway status: stopped\n');
2255
- return 0;
2256
- }
2257
- const pidRunning = isPidRunning(record.pid);
2258
- const probe = await probeGateway(record);
2259
- process.stdout.write(`gateway status: ${probe.connected ? 'running' : 'unreachable'}\n`);
2260
- process.stdout.write(`record: ${recordPath}\n`);
2261
- process.stdout.write(`lock: ${lockPath}\n`);
2262
- process.stdout.write(
2263
- `pid: ${String(record.pid)} (${pidRunning ? 'running' : 'not-running'})\n`,
2264
- );
2265
- process.stdout.write(`host: ${record.host}\n`);
2266
- process.stdout.write(`port: ${String(record.port)}\n`);
2267
- process.stdout.write(`auth: ${record.authToken === null ? 'off' : 'on'}\n`);
2268
- process.stdout.write(`db: ${record.stateDbPath}\n`);
2269
- process.stdout.write(`startedAt: ${record.startedAt}\n`);
2270
- if (typeof record.gatewayRunId === 'string' && record.gatewayRunId.length > 0) {
2271
- process.stdout.write(`runId: ${record.gatewayRunId}\n`);
2272
- }
2273
- process.stdout.write(
2274
- `sessions: total=${String(probe.sessionCount)} live=${String(probe.liveSessionCount)}\n`,
2275
- );
2276
- if (!probe.connected) {
2277
- process.stdout.write(`lastError: ${probe.error ?? 'unknown'}\n`);
2278
- return 1;
2279
- }
2280
- return 0;
2281
- });
2282
- }
2283
-
2284
- if (command.type === 'stop') {
2285
- const stopOptions = command.stopOptions ?? {
2286
- force: false,
2287
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2288
- cleanupOrphans: true,
2289
- };
2290
- const stopped = await withLock(
2291
- async () =>
2292
- await stopGateway(
2293
- invocationDirectory,
2294
- daemonScriptPath,
2295
- recordPath,
2296
- defaultStateDbPath,
2297
- stopOptions,
2298
- ),
2299
- );
2300
- process.stdout.write(`${stopped.message}\n`);
2301
- return stopped.stopped ? 0 : 1;
2302
- }
2303
-
2304
- if (command.type === 'start') {
2305
- const ensured = await withLock(
2306
- async () =>
2307
- await ensureGatewayRunning(
2308
- invocationDirectory,
2309
- recordPath,
2310
- logPath,
2311
- daemonScriptPath,
2312
- defaultStateDbPath,
2313
- command.startOptions ?? {},
2314
- runtimeOptions.gatewayRuntimeArgs,
2315
- ),
2316
- );
2317
- if (ensured.started) {
2318
- process.stdout.write(
2319
- `gateway started pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
2320
- );
2321
- } else {
2322
- process.stdout.write(
2323
- `gateway already running pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
2324
- );
2325
- }
2326
- process.stdout.write(`record: ${recordPath}\n`);
2327
- process.stdout.write(`log: ${logPath}\n`);
2328
- process.stdout.write(`lock: ${lockPath}\n`);
2329
- return 0;
2330
- }
2331
-
2332
- if (command.type === 'restart') {
2333
- const stopResult = await withLock(
2334
- async () =>
2335
- await stopGateway(invocationDirectory, daemonScriptPath, recordPath, defaultStateDbPath, {
2336
- force: true,
2337
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2338
- cleanupOrphans: true,
2339
- }),
2340
- );
2341
- process.stdout.write(`${stopResult.message}\n`);
2342
- const ensured = await withLock(
2343
- async () =>
2344
- await ensureGatewayRunning(
2345
- invocationDirectory,
2346
- recordPath,
2347
- logPath,
2348
- daemonScriptPath,
2349
- defaultStateDbPath,
2350
- command.startOptions ?? {},
2351
- runtimeOptions.gatewayRuntimeArgs,
2352
- ),
2353
- );
2354
- process.stdout.write(
2355
- `gateway restarted pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
2356
- );
2357
- process.stdout.write(`record: ${recordPath}\n`);
2358
- process.stdout.write(`log: ${logPath}\n`);
2359
- process.stdout.write(`lock: ${lockPath}\n`);
2360
- return 0;
2361
- }
2362
-
2363
- if (command.type === 'run') {
2364
- return await withLock(async () => {
2365
- const existingRecord = readGatewayRecord(recordPath);
2366
- const settings = resolveGatewaySettings(
2367
- invocationDirectory,
2368
- existingRecord,
2369
- command.startOptions ?? {},
2370
- process.env,
2371
- defaultStateDbPath,
2372
- );
2373
- process.stdout.write(
2374
- `gateway foreground run host=${settings.host} port=${String(settings.port)} db=${settings.stateDbPath}\n`,
2375
- );
2376
- process.stdout.write(`lock: ${lockPath}\n`);
2377
- return await runGatewayForeground(
2378
- daemonScriptPath,
2379
- invocationDirectory,
2380
- recordPath,
2381
- settings,
2382
- runtimeOptions.gatewayRuntimeArgs,
2383
- );
2384
- });
2385
- }
2386
-
2387
- const record = await withLock(async () => readGatewayRecord(recordPath));
2388
- if (record === null) {
2389
- throw new Error('gateway not running; start it first');
2390
- }
2391
- if (command.callJson === undefined) {
2392
- throw new Error('missing gateway call json');
2393
- }
2394
- return await executeGatewayCall(record, command.callJson);
2395
- }
2396
-
2397
- async function runDefaultClient(
2398
- invocationDirectory: string,
2399
- daemonScriptPath: string,
2400
- muxScriptPath: string,
2401
- lockPath: string,
2402
- recordPath: string,
2403
- logPath: string,
2404
- defaultStateDbPath: string,
2405
- args: readonly string[],
2406
- sessionName: string | null,
2407
- runtimeOptions: RuntimeInspectOptions,
2408
- ): Promise<number> {
2409
- const ensured = await withGatewayControlLock(
2410
- lockPath,
2411
- invocationDirectory,
2412
- async () =>
2413
- await ensureGatewayRunning(
2414
- invocationDirectory,
2415
- recordPath,
2416
- logPath,
2417
- daemonScriptPath,
2418
- defaultStateDbPath,
2419
- {},
2420
- runtimeOptions.gatewayRuntimeArgs,
2421
- ),
2422
- );
2423
- if (ensured.started) {
2424
- process.stdout.write(
2425
- `gateway started pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
2426
- );
2427
- }
2428
- return await runMuxClient(
2429
- muxScriptPath,
2430
- invocationDirectory,
2431
- ensured.record,
2432
- args,
2433
- sessionName,
2434
- runtimeOptions.clientRuntimeArgs,
2435
- );
2436
- }
2437
-
2438
- async function runProfileRun(
2439
- invocationDirectory: string,
2440
- daemonScriptPath: string,
2441
- muxScriptPath: string,
2442
- sessionPaths: SessionPaths,
2443
- command: ParsedProfileRunCommand,
2444
- sessionName: string | null,
2445
- runtimeOptions: RuntimeInspectOptions,
2446
- ): Promise<number> {
2447
- const profileDir =
2448
- command.profileDir === null
2449
- ? sessionPaths.profileDir
2450
- : resolve(invocationDirectory, command.profileDir);
2451
- mkdirSync(profileDir, { recursive: true });
2452
-
2453
- const clientProfilePath = resolve(profileDir, PROFILE_CLIENT_FILE_NAME);
2454
- const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
2455
- removeFileIfExists(clientProfilePath);
2456
- removeFileIfExists(gatewayProfilePath);
2457
-
2458
- const existingProfileState = readActiveProfileState(sessionPaths.profileStatePath);
2459
- if (existingProfileState !== null) {
2460
- if (isPidRunning(existingProfileState.pid)) {
2461
- throw new Error(
2462
- 'profile run requires no active profile session; stop it first with `harness profile stop`',
2463
- );
2464
- }
2465
- removeActiveProfileState(sessionPaths.profileStatePath);
2466
- }
2467
-
2468
- const gateway = await withGatewayControlLock(
2469
- sessionPaths.lockPath,
2470
- invocationDirectory,
2471
- async () => {
2472
- const existingRecord = readGatewayRecord(sessionPaths.recordPath);
2473
- if (existingRecord !== null) {
2474
- const existingProbe = await probeGateway(existingRecord);
2475
- if (existingProbe.connected || isPidRunning(existingRecord.pid)) {
2476
- throw new Error(
2477
- 'profile command requires the target session gateway to be stopped first',
2478
- );
2479
- }
2480
- removeGatewayRecord(sessionPaths.recordPath);
2481
- }
2482
-
2483
- const host = normalizeGatewayHost(process.env.HARNESS_CONTROL_PLANE_HOST);
2484
- const reservedPort = await reservePort(host);
2485
- const settings = resolveGatewaySettings(
2486
- invocationDirectory,
2487
- null,
2488
- {
2489
- port: reservedPort,
2490
- stateDbPath: sessionPaths.defaultStateDbPath,
2491
- },
2492
- process.env,
2493
- sessionPaths.defaultStateDbPath,
2494
- );
2495
-
2496
- return await startDetachedGateway(
2497
- invocationDirectory,
2498
- sessionPaths.recordPath,
2499
- sessionPaths.logPath,
2500
- settings,
2501
- daemonScriptPath,
2502
- [
2503
- ...runtimeOptions.gatewayRuntimeArgs,
2504
- ...buildCpuProfileRuntimeArgs({
2505
- cpuProfileDir: profileDir,
2506
- cpuProfileName: PROFILE_GATEWAY_FILE_NAME,
2507
- }),
2508
- ],
2509
- );
2510
- },
2511
- );
2512
-
2513
- let clientExitCode = 1;
2514
- let clientError: Error | null = null;
2515
- try {
2516
- clientExitCode = await runMuxClient(
2517
- muxScriptPath,
2518
- invocationDirectory,
2519
- gateway,
2520
- command.muxArgs,
2521
- sessionName,
2522
- [
2523
- ...runtimeOptions.clientRuntimeArgs,
2524
- ...buildCpuProfileRuntimeArgs({
2525
- cpuProfileDir: profileDir,
2526
- cpuProfileName: PROFILE_CLIENT_FILE_NAME,
2527
- }),
2528
- ],
2529
- );
2530
- } catch (error: unknown) {
2531
- clientError = error instanceof Error ? error : new Error(String(error));
2532
- }
2533
-
2534
- const stopped = await withGatewayControlLock(
2535
- sessionPaths.lockPath,
2536
- invocationDirectory,
2537
- async () =>
2538
- await stopGateway(
2539
- invocationDirectory,
2540
- daemonScriptPath,
2541
- sessionPaths.recordPath,
2542
- sessionPaths.defaultStateDbPath,
2543
- {
2544
- force: true,
2545
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2546
- cleanupOrphans: true,
2547
- },
2548
- ),
2549
- );
2550
- process.stdout.write(`${stopped.message}\n`);
2551
- if (!stopped.stopped) {
2552
- throw new Error(`failed to stop profile gateway: ${stopped.message}`);
2553
- }
2554
- if (clientError !== null) {
2555
- throw clientError;
2556
- }
2557
- if (!existsSync(clientProfilePath)) {
2558
- throw new Error(`missing client CPU profile: ${clientProfilePath}`);
2559
- }
2560
- if (!existsSync(gatewayProfilePath)) {
2561
- throw new Error(`missing gateway CPU profile: ${gatewayProfilePath}`);
2562
- }
2563
-
2564
- process.stdout.write(`profiles: client=${clientProfilePath} gateway=${gatewayProfilePath}\n`);
2565
- return clientExitCode;
2566
- }
2567
-
2568
- async function runProfileStart(
2569
- invocationDirectory: string,
2570
- sessionPaths: SessionPaths,
2571
- command: ParsedProfileStartCommand,
2572
- ): Promise<number> {
2573
- const profileDir =
2574
- command.profileDir === null
2575
- ? sessionPaths.profileDir
2576
- : resolve(invocationDirectory, command.profileDir);
2577
- mkdirSync(profileDir, { recursive: true });
2578
- const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
2579
- removeFileIfExists(gatewayProfilePath);
2580
-
2581
- const existingProfileState = readActiveProfileState(sessionPaths.profileStatePath);
2582
- if (existingProfileState !== null) {
2583
- if (isPidRunning(existingProfileState.pid)) {
2584
- throw new Error('profile already running; stop it first with `harness profile stop`');
2585
- }
2586
- removeActiveProfileState(sessionPaths.profileStatePath);
2587
- }
2588
-
2589
- const existingRecord = readGatewayRecord(sessionPaths.recordPath);
2590
- if (existingRecord === null) {
2591
- throw new Error('profile start requires the target session gateway to be running');
2592
- }
2593
- const existingProbe = await probeGateway(existingRecord);
2594
- if (!existingProbe.connected || !isPidRunning(existingRecord.pid)) {
2595
- throw new Error('profile start requires the target session gateway to be running');
2596
- }
2597
- const inspector = await connectGatewayInspector(
2598
- invocationDirectory,
2599
- sessionPaths.logPath,
2600
- DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
2601
- );
2602
- try {
2603
- const startCommandRaw = await evaluateInspectorExpression(
2604
- inspector.client,
2605
- buildInspectorProfileStartExpression(),
2606
- DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
2607
- );
2608
- if (typeof startCommandRaw !== 'string') {
2609
- throw new Error('failed to start gateway profiler (invalid inspector response)');
2610
- }
2611
- const startCommandResult = JSON.parse(startCommandRaw) as Record<string, unknown>;
2612
- if (startCommandResult['ok'] !== true) {
2613
- const reason = startCommandResult['reason'];
2614
- throw new Error(
2615
- `failed to start gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
2616
- );
2617
- }
2618
-
2619
- const startDeadline = Date.now() + DEFAULT_PROFILE_INSPECT_TIMEOUT_MS;
2620
- let runningState: InspectorProfileState | null = null;
2621
- while (Date.now() < startDeadline) {
2622
- const state = await readInspectorProfileState(
2623
- inspector.client,
2624
- DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
2625
- );
2626
- if (state !== null && state.status === 'running') {
2627
- runningState = state;
2628
- break;
2629
- }
2630
- if (state !== null && state.status === 'failed') {
2631
- throw new Error(`failed to start gateway profiler (${state.error ?? 'unknown error'})`);
2632
- }
2633
- await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
2634
- }
2635
- if (runningState === null) {
2636
- throw new Error('failed to start gateway profiler (inspector runtime timeout)');
2637
- }
2638
- } finally {
2639
- inspector.client.close();
2640
- }
2641
-
2642
- writeActiveProfileState(sessionPaths.profileStatePath, {
2643
- version: PROFILE_STATE_VERSION,
2644
- mode: PROFILE_LIVE_INSPECT_MODE,
2645
- pid: existingRecord.pid,
2646
- host: existingRecord.host,
2647
- port: existingRecord.port,
2648
- stateDbPath: existingRecord.stateDbPath,
2649
- profileDir,
2650
- gatewayProfilePath,
2651
- inspectWebSocketUrl: inspector.endpoint,
2652
- startedAt: new Date().toISOString(),
2653
- });
2654
-
2655
- process.stdout.write(
2656
- `profile started pid=${String(existingRecord.pid)} host=${existingRecord.host} port=${String(existingRecord.port)}\n`,
2657
- );
2658
- process.stdout.write(`record: ${sessionPaths.recordPath}\n`);
2659
- process.stdout.write(`log: ${sessionPaths.logPath}\n`);
2660
- process.stdout.write(`profile-state: ${sessionPaths.profileStatePath}\n`);
2661
- process.stdout.write(`profile-target: ${gatewayProfilePath}\n`);
2662
- process.stdout.write('stop with: harness profile stop\n');
2663
- return 0;
2664
- }
2665
-
2666
- async function runProfileStop(
2667
- sessionPaths: SessionPaths,
2668
- command: ParsedProfileStopCommand,
2669
- ): Promise<number> {
2670
- const profileState = readActiveProfileState(sessionPaths.profileStatePath);
2671
- if (profileState === null) {
2672
- throw new Error(
2673
- 'no active profile run for this session; start one with `harness profile start`',
2674
- );
2675
- }
2676
- if (profileState.mode !== PROFILE_LIVE_INSPECT_MODE) {
2677
- throw new Error('active profile run is incompatible with this harness version');
2678
- }
2679
- const inspector = await InspectorWebSocketClient.connect(
2680
- profileState.inspectWebSocketUrl,
2681
- command.stopOptions.timeoutMs,
2682
- );
2683
- try {
2684
- await inspector.sendCommand('Runtime.enable', {}, command.stopOptions.timeoutMs);
2685
- const stopCommandRaw = await evaluateInspectorExpression(
2686
- inspector,
2687
- buildInspectorProfileStopExpression(profileState.gatewayProfilePath, profileState.profileDir),
2688
- command.stopOptions.timeoutMs,
2689
- );
2690
- if (typeof stopCommandRaw !== 'string') {
2691
- throw new Error('failed to stop gateway profiler (invalid inspector response)');
2692
- }
2693
- const stopCommandResult = JSON.parse(stopCommandRaw) as Record<string, unknown>;
2694
- if (stopCommandResult['ok'] !== true) {
2695
- const reason = stopCommandResult['reason'];
2696
- throw new Error(
2697
- `failed to stop gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
2698
- );
2699
- }
2700
-
2701
- const startedAt = Date.now();
2702
- while (Date.now() - startedAt < command.stopOptions.timeoutMs) {
2703
- const state = await readInspectorProfileState(inspector, command.stopOptions.timeoutMs);
2704
- if (state !== null && state.status === 'failed') {
2705
- throw new Error(`failed to stop gateway profiler (${state.error ?? 'unknown error'})`);
2706
- }
2707
- if (state !== null && state.status === 'stopped' && state.written) {
2708
- break;
2709
- }
2710
- await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
2711
- }
2712
- } finally {
2713
- inspector.close();
2714
- }
2715
-
2716
- const profileFlushed = await waitForFileExists(
2717
- profileState.gatewayProfilePath,
2718
- command.stopOptions.timeoutMs,
2719
- );
2720
- if (!profileFlushed) {
2721
- throw new Error(`missing gateway CPU profile: ${profileState.gatewayProfilePath}`);
2722
- }
2723
-
2724
- removeActiveProfileState(sessionPaths.profileStatePath);
2725
- process.stdout.write(`profile: gateway=${profileState.gatewayProfilePath}\n`);
2726
- return 0;
2727
- }
2728
-
2729
- async function runProfileCommandEntry(
2730
- invocationDirectory: string,
2731
- daemonScriptPath: string,
2732
- muxScriptPath: string,
2733
- sessionPaths: SessionPaths,
2734
- args: readonly string[],
2735
- sessionName: string | null,
2736
- runtimeOptions: RuntimeInspectOptions,
2737
- ): Promise<number> {
2738
- if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
2739
- printUsage();
2740
- return 0;
2741
- }
2742
- const command = parseProfileCommand(args);
2743
- if (command.type === 'start') {
2744
- return await runProfileStart(invocationDirectory, sessionPaths, command);
2745
- }
2746
- if (command.type === 'stop') {
2747
- return await runProfileStop(sessionPaths, command);
2748
- }
2749
- return await runProfileRun(
2750
- invocationDirectory,
2751
- daemonScriptPath,
2752
- muxScriptPath,
2753
- sessionPaths,
2754
- command,
2755
- sessionName,
2756
- runtimeOptions,
2757
- );
2758
- }
2759
-
2760
- async function runStatusTimelineStart(
2761
- invocationDirectory: string,
2762
- sessionPaths: SessionPaths,
2763
- sessionName: string | null,
2764
- command: ParsedStatusTimelineStartCommand,
2765
- ): Promise<number> {
2766
- const outputPath =
2767
- command.outputPath === null
2768
- ? sessionPaths.defaultStatusTimelineOutputPath
2769
- : resolve(invocationDirectory, command.outputPath);
2770
- const existingState = readActiveStatusTimelineState(sessionPaths.statusTimelineStatePath);
2771
- if (existingState !== null) {
2772
- throw new Error(
2773
- 'status timeline already running; stop it first with `harness status-timeline stop`',
2774
- );
2775
- }
2776
- mkdirSync(dirname(outputPath), { recursive: true });
2777
- writeFileSync(outputPath, '', 'utf8');
2778
- writeActiveStatusTimelineState(sessionPaths.statusTimelineStatePath, {
2779
- version: STATUS_TIMELINE_STATE_VERSION,
2780
- mode: STATUS_TIMELINE_MODE,
2781
- outputPath,
2782
- sessionName,
2783
- startedAt: new Date().toISOString(),
2784
- });
2785
- process.stdout.write('status timeline started\n');
2786
- process.stdout.write(`status-timeline-state: ${sessionPaths.statusTimelineStatePath}\n`);
2787
- process.stdout.write(`status-timeline-target: ${outputPath}\n`);
2788
- process.stdout.write('stop with: harness status-timeline stop\n');
2789
- return 0;
2790
- }
2791
-
2792
- async function runStatusTimelineStop(sessionPaths: SessionPaths): Promise<number> {
2793
- const state = readActiveStatusTimelineState(sessionPaths.statusTimelineStatePath);
2794
- if (state === null) {
2795
- throw new Error(
2796
- 'no active status timeline run for this session; start one with `harness status-timeline start`',
2797
- );
2798
- }
2799
- removeActiveStatusTimelineState(sessionPaths.statusTimelineStatePath);
2800
- process.stdout.write(`status timeline stopped: ${state.outputPath}\n`);
2801
- return 0;
2802
- }
2803
-
2804
- async function runStatusTimelineCommandEntry(
2805
- invocationDirectory: string,
2806
- sessionPaths: SessionPaths,
2807
- args: readonly string[],
2808
- sessionName: string | null,
2809
- ): Promise<number> {
2810
- if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
2811
- printUsage();
2812
- return 0;
2813
- }
2814
- const command = parseStatusTimelineCommand(args);
2815
- if (command.type === 'stop') {
2816
- return await runStatusTimelineStop(sessionPaths);
2817
- }
2818
- return await runStatusTimelineStart(invocationDirectory, sessionPaths, sessionName, command);
2819
- }
2820
-
2821
- async function runRenderTraceStart(
2822
- invocationDirectory: string,
2823
- sessionPaths: SessionPaths,
2824
- sessionName: string | null,
2825
- command: ParsedRenderTraceStartCommand,
2826
- ): Promise<number> {
2827
- const outputPath =
2828
- command.outputPath === null
2829
- ? sessionPaths.defaultRenderTraceOutputPath
2830
- : resolve(invocationDirectory, command.outputPath);
2831
- const existingState = readActiveRenderTraceState(sessionPaths.renderTraceStatePath);
2832
- if (existingState !== null) {
2833
- throw new Error('render trace already running; stop it first with `harness render-trace stop`');
2834
- }
2835
- mkdirSync(dirname(outputPath), { recursive: true });
2836
- writeFileSync(outputPath, '', 'utf8');
2837
- writeActiveRenderTraceState(sessionPaths.renderTraceStatePath, {
2838
- version: RENDER_TRACE_STATE_VERSION,
2839
- mode: RENDER_TRACE_MODE,
2840
- outputPath,
2841
- sessionName,
2842
- conversationId: command.conversationId,
2843
- startedAt: new Date().toISOString(),
2844
- });
2845
- process.stdout.write('render trace started\n');
2846
- process.stdout.write(`render-trace-state: ${sessionPaths.renderTraceStatePath}\n`);
2847
- process.stdout.write(`render-trace-target: ${outputPath}\n`);
2848
- if (command.conversationId !== null) {
2849
- process.stdout.write(`render-trace-conversation-id: ${command.conversationId}\n`);
2850
- }
2851
- process.stdout.write('stop with: harness render-trace stop\n');
2852
- return 0;
2853
- }
2854
-
2855
- async function runRenderTraceStop(sessionPaths: SessionPaths): Promise<number> {
2856
- const state = readActiveRenderTraceState(sessionPaths.renderTraceStatePath);
2857
- if (state === null) {
2858
- throw new Error(
2859
- 'no active render trace run for this session; start one with `harness render-trace start`',
2860
- );
2861
- }
2862
- removeActiveRenderTraceState(sessionPaths.renderTraceStatePath);
2863
- process.stdout.write(`render trace stopped: ${state.outputPath}\n`);
2864
- return 0;
2865
- }
2866
-
2867
- async function runRenderTraceCommandEntry(
2868
- invocationDirectory: string,
2869
- sessionPaths: SessionPaths,
2870
- args: readonly string[],
2871
- sessionName: string | null,
2872
- ): Promise<number> {
2873
- if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
2874
- printUsage();
2875
- return 0;
2876
- }
2877
- const command = parseRenderTraceCommand(args);
2878
- if (command.type === 'stop') {
2879
- return await runRenderTraceStop(sessionPaths);
2880
- }
2881
- return await runRenderTraceStart(invocationDirectory, sessionPaths, sessionName, command);
2882
- }
2883
-
2884
- async function runCursorHooksCommandEntry(
2885
- invocationDirectory: string,
2886
- command: ParsedCursorHooksCommand,
2887
- ): Promise<number> {
2888
- const hooksFilePath =
2889
- command.hooksFilePath === null
2890
- ? undefined
2891
- : resolve(invocationDirectory, command.hooksFilePath);
2892
- if (command.type === 'install') {
2893
- const relayScriptPath = resolveScriptPath(
2894
- process.env.HARNESS_CURSOR_HOOK_RELAY_SCRIPT_PATH,
2895
- DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH,
2896
- invocationDirectory,
2897
- );
2898
- const result = ensureManagedCursorHooksInstalled({
2899
- relayCommand: buildCursorManagedHookRelayCommand(relayScriptPath),
2900
- ...(hooksFilePath === undefined ? {} : { hooksFilePath }),
2901
- });
2902
- process.stdout.write(
2903
- `cursor hooks install: ${result.changed ? 'updated' : 'already up-to-date'} file=${result.filePath} removed=${String(result.removedCount)} added=${String(result.addedCount)}\n`,
2904
- );
2905
- return 0;
2906
- }
2907
- const result = uninstallManagedCursorHooks(hooksFilePath === undefined ? {} : { hooksFilePath });
2908
- process.stdout.write(
2909
- `cursor hooks uninstall: ${result.changed ? 'updated' : 'no changes'} file=${result.filePath} removed=${String(result.removedCount)}\n`,
2910
- );
2911
- return 0;
2912
- }
2913
-
2914
- async function main(): Promise<number> {
2915
- const invocationDirectory = resolveInvocationDirectory(process.env, process.cwd());
2916
- const migration = migrateLegacyHarnessLayout(invocationDirectory, process.env);
2917
- if (migration.migrated) {
2918
- process.stdout.write(
2919
- `[migration] local .harness migrated to global runtime layout (${String(migration.migratedEntries)} entries, configCopied=${String(migration.configCopied)}, secretsCopied=${String(migration.secretsCopied)})\n`,
2920
- );
2921
- }
2922
- loadHarnessSecrets({ cwd: invocationDirectory });
2923
- const runtimeOptions = resolveInspectRuntimeOptions(invocationDirectory);
2924
- const daemonScriptPath = resolveScriptPath(
2925
- process.env.HARNESS_DAEMON_SCRIPT_PATH,
2926
- DEFAULT_DAEMON_SCRIPT_PATH,
2927
- invocationDirectory,
2928
- );
2929
- const muxScriptPath = resolveScriptPath(
2930
- process.env.HARNESS_MUX_SCRIPT_PATH,
2931
- DEFAULT_MUX_SCRIPT_PATH,
2932
- invocationDirectory,
2933
- );
2934
-
2935
- const parsedGlobals = parseGlobalCliOptions(process.argv.slice(2));
2936
- const sessionPaths = resolveSessionPaths(invocationDirectory, parsedGlobals.sessionName);
2937
- const argv = parsedGlobals.argv;
2938
- if (argv.length > 0 && (argv[0] === '--help' || argv[0] === '-h')) {
2939
- printUsage();
2940
- return 0;
2941
- }
2942
-
2943
- if (argv.length > 0 && argv[0] === 'gateway') {
2944
- if (argv.length === 1) {
2945
- printUsage();
2946
- return 2;
2947
- }
2948
- const command = parseGatewayCommand(argv.slice(1));
2949
- return await runGatewayCommandEntry(
2950
- command,
2951
- invocationDirectory,
2952
- daemonScriptPath,
2953
- sessionPaths.lockPath,
2954
- sessionPaths.recordPath,
2955
- sessionPaths.logPath,
2956
- sessionPaths.defaultStateDbPath,
2957
- runtimeOptions,
2958
- );
2959
- }
2960
-
2961
- if (argv.length > 0 && argv[0] === 'profile') {
2962
- return await runProfileCommandEntry(
2963
- invocationDirectory,
2964
- daemonScriptPath,
2965
- muxScriptPath,
2966
- sessionPaths,
2967
- argv.slice(1),
2968
- parsedGlobals.sessionName,
2969
- runtimeOptions,
2970
- );
2971
- }
2972
-
2973
- if (argv.length > 0 && argv[0] === 'status-timeline') {
2974
- return await runStatusTimelineCommandEntry(
2975
- invocationDirectory,
2976
- sessionPaths,
2977
- argv.slice(1),
2978
- parsedGlobals.sessionName,
2979
- );
2980
- }
2981
-
2982
- if (argv.length > 0 && argv[0] === 'render-trace') {
2983
- return await runRenderTraceCommandEntry(
2984
- invocationDirectory,
2985
- sessionPaths,
2986
- argv.slice(1),
2987
- parsedGlobals.sessionName,
2988
- );
2989
- }
2990
-
2991
- if (argv.length > 0 && (argv[0] === 'update' || argv[0] === 'upgrade')) {
2992
- if (argv.length > 1) {
2993
- throw new Error(`unknown ${argv[0]} option: ${argv[1]}`);
2994
- }
2995
- return runHarnessUpdateCommand(invocationDirectory, process.env);
2996
- }
2997
-
2998
- if (argv.length > 0 && argv[0] === 'cursor-hooks') {
2999
- const command = parseCursorHooksCommand(argv.slice(1));
3000
- return await runCursorHooksCommandEntry(invocationDirectory, command);
3001
- }
3002
-
3003
- if (argv.length > 0 && argv[0] === 'animate') {
3004
- return await runHarnessAnimate(argv.slice(1));
3005
- }
3006
-
3007
- const passthroughArgs = argv[0] === 'client' ? argv.slice(1) : argv;
3008
- return await runDefaultClient(
3009
- invocationDirectory,
3010
- daemonScriptPath,
3011
- muxScriptPath,
3012
- sessionPaths.lockPath,
3013
- sessionPaths.recordPath,
3014
- sessionPaths.logPath,
3015
- sessionPaths.defaultStateDbPath,
3016
- passthroughArgs,
3017
- parsedGlobals.sessionName,
3018
- runtimeOptions,
3019
- );
3020
- }
3021
-
3022
48
  try {
3023
- process.exitCode = await main();
49
+ await main();
3024
50
  } catch (error: unknown) {
3025
51
  process.stderr.write(
3026
52
  `harness fatal error: ${error instanceof Error ? error.message : String(error)}\n`,