@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
@@ -0,0 +1,729 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ closeSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { dirname, resolve } from 'node:path';
14
+ import { setTimeout as delay } from 'node:timers/promises';
15
+ import {
16
+ clearDefaultGatewayPointerForRecordPath,
17
+ writeDefaultGatewayPointerFromGatewayRecord,
18
+ } from '../default-gateway-pointer.ts';
19
+ import {
20
+ parseGatewayRecordText,
21
+ serializeGatewayRecord,
22
+ type GatewayRecord,
23
+ } from '../gateway-record.ts';
24
+ import { resolveHarnessWorkspaceDirectory } from '../../config/harness-paths.ts';
25
+
26
+ const DEFAULT_GATEWAY_STOP_POLL_MS = 50;
27
+ const DEFAULT_GATEWAY_LOCK_TIMEOUT_MS = 7000;
28
+ const DEFAULT_GATEWAY_LOCK_POLL_MS = 40;
29
+ const GATEWAY_LOCK_VERSION = 1;
30
+
31
+ interface ProcessTableEntry {
32
+ pid: number;
33
+ ppid: number;
34
+ command: string;
35
+ }
36
+
37
+ interface GatewayProcessIdentity {
38
+ pid: number;
39
+ startedAt: string;
40
+ }
41
+
42
+ interface GatewayControlLockRecord {
43
+ version: number;
44
+ owner: GatewayProcessIdentity;
45
+ acquiredAt: string;
46
+ workspaceRoot: string;
47
+ token: string;
48
+ }
49
+
50
+ interface GatewayControlLockHandle {
51
+ lockPath: string;
52
+ record: GatewayControlLockRecord;
53
+ release: () => void;
54
+ }
55
+
56
+ export interface ParsedGatewayDaemonEntry {
57
+ pid: number;
58
+ host: string;
59
+ port: number;
60
+ authToken: string | null;
61
+ stateDbPath: string;
62
+ }
63
+
64
+ interface OrphanProcessCleanupResult {
65
+ matchedPids: readonly number[];
66
+ terminatedPids: readonly number[];
67
+ failedPids: readonly number[];
68
+ errorMessage: string | null;
69
+ }
70
+
71
+ interface GatewayStopProcessOptions {
72
+ force: boolean;
73
+ timeoutMs: number;
74
+ }
75
+
76
+ interface GatewayControlInfraOptions {
77
+ readonly env?: NodeJS.ProcessEnv;
78
+ readonly cwd?: string;
79
+ }
80
+
81
+ export class GatewayControlInfra {
82
+ constructor(private readonly options: GatewayControlInfraOptions = {}) {}
83
+
84
+ private env(): NodeJS.ProcessEnv {
85
+ return this.options.env ?? process.env;
86
+ }
87
+
88
+ private cwd(): string {
89
+ return this.options.cwd ?? process.cwd();
90
+ }
91
+
92
+ public readGatewayRecord(recordPath: string): GatewayRecord | null {
93
+ if (!existsSync(recordPath)) {
94
+ return null;
95
+ }
96
+ try {
97
+ const raw = readFileSync(recordPath, 'utf8');
98
+ return parseGatewayRecordText(raw);
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ public writeTextFileAtomically(filePath: string, text: string): void {
105
+ mkdirSync(dirname(filePath), { recursive: true });
106
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
107
+ try {
108
+ writeFileSync(tempPath, text, 'utf8');
109
+ renameSync(tempPath, filePath);
110
+ } catch (error: unknown) {
111
+ try {
112
+ unlinkSync(tempPath);
113
+ } catch {
114
+ // Best-effort cleanup only.
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ public writeGatewayRecord(recordPath: string, record: GatewayRecord): void {
121
+ this.writeTextFileAtomically(recordPath, serializeGatewayRecord(record));
122
+ writeDefaultGatewayPointerFromGatewayRecord(recordPath, record, this.env());
123
+ }
124
+
125
+ public removeGatewayRecord(recordPath: string): void {
126
+ try {
127
+ unlinkSync(recordPath);
128
+ } catch (error: unknown) {
129
+ const code = (error as NodeJS.ErrnoException).code;
130
+ if (code !== 'ENOENT') {
131
+ throw error;
132
+ }
133
+ }
134
+ clearDefaultGatewayPointerForRecordPath(recordPath, this.cwd(), this.env());
135
+ }
136
+
137
+ private readProcessStartedAt(pid: number): string | null {
138
+ if (!Number.isInteger(pid) || pid <= 0) {
139
+ return null;
140
+ }
141
+ try {
142
+ const output = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
143
+ encoding: 'utf8',
144
+ }).trim();
145
+ return output.length > 0 ? output : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ private resolveCurrentProcessIdentity(): GatewayProcessIdentity {
152
+ const startedAt = this.readProcessStartedAt(process.pid);
153
+ if (startedAt === null) {
154
+ throw new Error(
155
+ `failed to resolve current process start timestamp for pid=${String(process.pid)}`,
156
+ );
157
+ }
158
+ return {
159
+ pid: process.pid,
160
+ startedAt,
161
+ };
162
+ }
163
+
164
+ private parseGatewayControlLockText(text: string): GatewayControlLockRecord | null {
165
+ let parsed: unknown;
166
+ try {
167
+ parsed = JSON.parse(text);
168
+ } catch {
169
+ return null;
170
+ }
171
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
172
+ return null;
173
+ }
174
+ const candidate = parsed as Record<string, unknown>;
175
+ if (candidate['version'] !== GATEWAY_LOCK_VERSION) {
176
+ return null;
177
+ }
178
+ if (
179
+ typeof candidate['acquiredAt'] !== 'string' ||
180
+ candidate['acquiredAt'].trim().length === 0
181
+ ) {
182
+ return null;
183
+ }
184
+ if (
185
+ typeof candidate['workspaceRoot'] !== 'string' ||
186
+ candidate['workspaceRoot'].trim().length === 0
187
+ ) {
188
+ return null;
189
+ }
190
+ if (typeof candidate['token'] !== 'string' || candidate['token'].trim().length === 0) {
191
+ return null;
192
+ }
193
+ const owner = candidate['owner'];
194
+ if (typeof owner !== 'object' || owner === null || Array.isArray(owner)) {
195
+ return null;
196
+ }
197
+ const ownerRecord = owner as Record<string, unknown>;
198
+ const pid = ownerRecord['pid'];
199
+ const startedAt = ownerRecord['startedAt'];
200
+ if (!Number.isInteger(pid) || (pid as number) <= 0) {
201
+ return null;
202
+ }
203
+ if (typeof startedAt !== 'string' || startedAt.trim().length === 0) {
204
+ return null;
205
+ }
206
+ return {
207
+ version: GATEWAY_LOCK_VERSION,
208
+ owner: {
209
+ pid: pid as number,
210
+ startedAt,
211
+ },
212
+ acquiredAt: candidate['acquiredAt'] as string,
213
+ workspaceRoot: candidate['workspaceRoot'] as string,
214
+ token: candidate['token'] as string,
215
+ };
216
+ }
217
+
218
+ private readGatewayControlLock(lockPath: string): GatewayControlLockRecord | null {
219
+ if (!existsSync(lockPath)) {
220
+ return null;
221
+ }
222
+ try {
223
+ return this.parseGatewayControlLockText(readFileSync(lockPath, 'utf8'));
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ private removeGatewayControlLock(lockPath: string): void {
230
+ try {
231
+ unlinkSync(lockPath);
232
+ } catch (error: unknown) {
233
+ const code = (error as NodeJS.ErrnoException).code;
234
+ if (code !== 'ENOENT') {
235
+ throw error;
236
+ }
237
+ }
238
+ }
239
+
240
+ public isPidRunning(pid: number): boolean {
241
+ if (!Number.isInteger(pid) || pid <= 0) {
242
+ return false;
243
+ }
244
+ try {
245
+ process.kill(pid, 0);
246
+ return true;
247
+ } catch (error: unknown) {
248
+ const code = (error as NodeJS.ErrnoException).code;
249
+ if (code === 'ESRCH') {
250
+ return false;
251
+ }
252
+ return true;
253
+ }
254
+ }
255
+
256
+ private isGatewayControlLockOwnerAlive(record: GatewayControlLockRecord): boolean {
257
+ if (!this.isPidRunning(record.owner.pid)) {
258
+ return false;
259
+ }
260
+ const startedAt = this.readProcessStartedAt(record.owner.pid);
261
+ if (startedAt === null) {
262
+ return false;
263
+ }
264
+ return startedAt === record.owner.startedAt;
265
+ }
266
+
267
+ private createGatewayControlLockHandle(
268
+ lockPath: string,
269
+ record: GatewayControlLockRecord,
270
+ ): GatewayControlLockHandle {
271
+ return {
272
+ lockPath,
273
+ record,
274
+ release: () => {
275
+ const current = this.readGatewayControlLock(lockPath);
276
+ if (current === null) {
277
+ return;
278
+ }
279
+ if (
280
+ current.token !== record.token ||
281
+ current.owner.pid !== record.owner.pid ||
282
+ current.owner.startedAt !== record.owner.startedAt
283
+ ) {
284
+ return;
285
+ }
286
+ this.removeGatewayControlLock(lockPath);
287
+ },
288
+ };
289
+ }
290
+
291
+ public async acquireGatewayControlLock(
292
+ lockPath: string,
293
+ workspaceRoot: string,
294
+ timeoutMs = DEFAULT_GATEWAY_LOCK_TIMEOUT_MS,
295
+ ): Promise<GatewayControlLockHandle> {
296
+ const owner = this.resolveCurrentProcessIdentity();
297
+ const deadlineMs = Date.now() + timeoutMs;
298
+ const candidate: GatewayControlLockRecord = {
299
+ version: GATEWAY_LOCK_VERSION,
300
+ owner,
301
+ acquiredAt: new Date().toISOString(),
302
+ workspaceRoot,
303
+ token: randomUUID(),
304
+ };
305
+
306
+ while (true) {
307
+ mkdirSync(dirname(lockPath), { recursive: true });
308
+ try {
309
+ const fd = openSync(lockPath, 'wx');
310
+ try {
311
+ writeFileSync(fd, `${JSON.stringify(candidate, null, 2)}\n`, 'utf8');
312
+ } finally {
313
+ closeSync(fd);
314
+ }
315
+ return this.createGatewayControlLockHandle(lockPath, candidate);
316
+ } catch (error: unknown) {
317
+ const code = (error as NodeJS.ErrnoException).code;
318
+ if (code !== 'EEXIST') {
319
+ throw error;
320
+ }
321
+ }
322
+
323
+ const existing = this.readGatewayControlLock(lockPath);
324
+ if (existing === null) {
325
+ this.removeGatewayControlLock(lockPath);
326
+ continue;
327
+ }
328
+
329
+ if (existing.owner.pid === owner.pid && existing.owner.startedAt === owner.startedAt) {
330
+ return this.createGatewayControlLockHandle(lockPath, existing);
331
+ }
332
+
333
+ if (!this.isGatewayControlLockOwnerAlive(existing)) {
334
+ this.removeGatewayControlLock(lockPath);
335
+ continue;
336
+ }
337
+
338
+ if (Date.now() >= deadlineMs) {
339
+ throw new Error(
340
+ `timed out waiting for gateway control lock: lockPath=${lockPath} ownerPid=${String(existing.owner.pid)} acquiredAt=${existing.acquiredAt}`,
341
+ );
342
+ }
343
+ await delay(DEFAULT_GATEWAY_LOCK_POLL_MS);
344
+ }
345
+ }
346
+
347
+ public async withGatewayControlLock<T>(
348
+ lockPath: string,
349
+ workspaceRoot: string,
350
+ operation: () => Promise<T>,
351
+ ): Promise<T> {
352
+ const handle = await this.acquireGatewayControlLock(lockPath, workspaceRoot);
353
+ try {
354
+ return await operation();
355
+ } finally {
356
+ handle.release();
357
+ }
358
+ }
359
+
360
+ public async waitForPidExit(
361
+ pid: number,
362
+ timeoutMs: number,
363
+ pollMs = DEFAULT_GATEWAY_STOP_POLL_MS,
364
+ ): Promise<boolean> {
365
+ const startedAt = Date.now();
366
+ while (Date.now() - startedAt < timeoutMs) {
367
+ if (!this.isPidRunning(pid)) {
368
+ return true;
369
+ }
370
+ await delay(pollMs);
371
+ }
372
+ return !this.isPidRunning(pid);
373
+ }
374
+
375
+ public async waitForFileExists(
376
+ filePath: string,
377
+ timeoutMs: number,
378
+ pollMs = DEFAULT_GATEWAY_STOP_POLL_MS,
379
+ ): Promise<boolean> {
380
+ const startedAt = Date.now();
381
+ while (Date.now() - startedAt < timeoutMs) {
382
+ if (existsSync(filePath)) {
383
+ return true;
384
+ }
385
+ await delay(pollMs);
386
+ }
387
+ return existsSync(filePath);
388
+ }
389
+
390
+ public signalPidWithOptionalProcessGroup(
391
+ pid: number,
392
+ signal: NodeJS.Signals,
393
+ includeProcessGroup: boolean,
394
+ ): boolean {
395
+ let sent = false;
396
+ if (includeProcessGroup && pid > 1) {
397
+ try {
398
+ process.kill(-pid, signal);
399
+ sent = true;
400
+ } catch (error: unknown) {
401
+ const code = (error as NodeJS.ErrnoException).code;
402
+ if (code !== 'ESRCH') {
403
+ throw error;
404
+ }
405
+ }
406
+ }
407
+
408
+ try {
409
+ process.kill(pid, signal);
410
+ sent = true;
411
+ } catch (error: unknown) {
412
+ const code = (error as NodeJS.ErrnoException).code;
413
+ if (code !== 'ESRCH') {
414
+ throw error;
415
+ }
416
+ }
417
+
418
+ return sent;
419
+ }
420
+
421
+ private readProcessTable(): readonly ProcessTableEntry[] {
422
+ const output = execFileSync('ps', ['-axww', '-o', 'pid=,ppid=,command='], {
423
+ encoding: 'utf8',
424
+ });
425
+ const lines = output.split('\n');
426
+ const entries: ProcessTableEntry[] = [];
427
+ for (const line of lines) {
428
+ const trimmed = line.trim();
429
+ if (trimmed.length === 0) {
430
+ continue;
431
+ }
432
+ const match = /^(\d+)\s+(\d+)\s+(.*)$/u.exec(trimmed);
433
+ if (match === null) {
434
+ continue;
435
+ }
436
+ const pid = Number.parseInt(match[1] ?? '', 10);
437
+ const ppid = Number.parseInt(match[2] ?? '', 10);
438
+ const command = match[3] ?? '';
439
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid < 0) {
440
+ continue;
441
+ }
442
+ entries.push({
443
+ pid,
444
+ ppid,
445
+ command,
446
+ });
447
+ }
448
+ return entries;
449
+ }
450
+
451
+ private tokenizeProcessCommand(command: string): readonly string[] {
452
+ const trimmed = command.trim();
453
+ return trimmed.length === 0 ? [] : trimmed.split(/\s+/u);
454
+ }
455
+
456
+ private readCommandFlagValue(tokens: readonly string[], flag: string): string | null {
457
+ for (let index = 0; index < tokens.length; index += 1) {
458
+ const token = tokens[index]!;
459
+ if (token === flag) {
460
+ const value = tokens[index + 1];
461
+ return value === undefined ? null : value;
462
+ }
463
+ if (token.startsWith(`${flag}=`)) {
464
+ const value = token.slice(flag.length + 1);
465
+ return value.length === 0 ? null : value;
466
+ }
467
+ }
468
+ return null;
469
+ }
470
+
471
+ private parseGatewayDaemonProcessEntry(
472
+ entry: ProcessTableEntry,
473
+ ): ParsedGatewayDaemonEntry | null {
474
+ if (!/\bcontrol-plane-daemon\.(?:ts|js)\b/u.test(entry.command)) {
475
+ return null;
476
+ }
477
+ const tokens = this.tokenizeProcessCommand(entry.command);
478
+ const host = this.readCommandFlagValue(tokens, '--host');
479
+ const portRaw = this.readCommandFlagValue(tokens, '--port');
480
+ const stateDbPath = this.readCommandFlagValue(tokens, '--state-db-path');
481
+ const authToken = this.readCommandFlagValue(tokens, '--auth-token');
482
+ if (host === null || portRaw === null || stateDbPath === null) {
483
+ return null;
484
+ }
485
+ const port = Number.parseInt(portRaw, 10);
486
+ if (!Number.isFinite(port) || !Number.isInteger(port) || port <= 0 || port > 65535) {
487
+ return null;
488
+ }
489
+ return {
490
+ pid: entry.pid,
491
+ host,
492
+ port,
493
+ authToken,
494
+ stateDbPath: resolve(stateDbPath),
495
+ };
496
+ }
497
+
498
+ public listGatewayDaemonProcesses(): readonly ParsedGatewayDaemonEntry[] {
499
+ const parsed: ParsedGatewayDaemonEntry[] = [];
500
+ for (const entry of this.readProcessTable()) {
501
+ const daemon = this.parseGatewayDaemonProcessEntry(entry);
502
+ if (daemon !== null) {
503
+ parsed.push(daemon);
504
+ }
505
+ }
506
+ return parsed;
507
+ }
508
+
509
+ public isPathWithinWorkspaceRuntimeScope(
510
+ pathValue: string,
511
+ invocationDirectory: string,
512
+ ): boolean {
513
+ const runtimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, this.env());
514
+ const normalizedRoot = resolve(runtimeRoot);
515
+ const normalizedPath = resolve(pathValue);
516
+ return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
517
+ }
518
+
519
+ private findOrphanSqlitePidsForDbPath(stateDbPath: string): readonly number[] {
520
+ const normalizedDbPath = resolve(stateDbPath);
521
+ return this.readProcessTable()
522
+ .filter((entry) => entry.ppid === 1)
523
+ .filter((entry) => entry.pid !== process.pid)
524
+ .filter((entry) => /\bsqlite3\b/u.test(entry.command))
525
+ .filter((entry) => entry.command.includes(normalizedDbPath))
526
+ .map((entry) => entry.pid);
527
+ }
528
+
529
+ private dedupePids(pids: readonly number[]): readonly number[] {
530
+ return [...new Set(pids)];
531
+ }
532
+
533
+ private resolvePtyHelperPathCandidates(invocationDirectory: string): readonly string[] {
534
+ return [
535
+ resolve(invocationDirectory, 'native/ptyd/target/release/ptyd'),
536
+ resolve(invocationDirectory, 'bin/ptyd'),
537
+ ];
538
+ }
539
+
540
+ private findOrphanGatewayDaemonPids(
541
+ stateDbPath: string,
542
+ daemonScriptPath: string,
543
+ ): readonly number[] {
544
+ const normalizedDbPath = resolve(stateDbPath);
545
+ const normalizedDaemonScriptPath = resolve(daemonScriptPath);
546
+ return this.dedupePids(
547
+ this.readProcessTable()
548
+ .filter((entry) => entry.ppid === 1)
549
+ .filter((entry) => entry.pid !== process.pid)
550
+ .filter((entry) => entry.command.includes('--state-db-path'))
551
+ .filter((entry) => {
552
+ if (entry.command.includes(normalizedDaemonScriptPath)) {
553
+ return true;
554
+ }
555
+ return (
556
+ /\bcontrol-plane-daemon\.(?:ts|js)\b/u.test(entry.command) &&
557
+ entry.command.includes(normalizedDbPath)
558
+ );
559
+ })
560
+ .map((entry) => entry.pid),
561
+ );
562
+ }
563
+
564
+ private findOrphanPtyHelperPidsForWorkspace(invocationDirectory: string): readonly number[] {
565
+ const helperPathCandidates = this.resolvePtyHelperPathCandidates(invocationDirectory);
566
+ return this.readProcessTable()
567
+ .filter((entry) => entry.ppid === 1)
568
+ .filter((entry) => entry.pid !== process.pid)
569
+ .filter((entry) =>
570
+ helperPathCandidates.some((candidate) => entry.command.includes(candidate)),
571
+ )
572
+ .map((entry) => entry.pid);
573
+ }
574
+
575
+ private findOrphanRelayLinkedAgentPidsForWorkspace(
576
+ invocationDirectory: string,
577
+ ): readonly number[] {
578
+ const relayScriptPath = resolve(invocationDirectory, 'scripts/codex-notify-relay.ts');
579
+ return this.readProcessTable()
580
+ .filter((entry) => entry.ppid === 1)
581
+ .filter((entry) => entry.pid !== process.pid)
582
+ .filter((entry) => entry.command.includes(relayScriptPath))
583
+ .map((entry) => entry.pid);
584
+ }
585
+
586
+ public formatOrphanProcessCleanupResult(
587
+ label: string,
588
+ result: OrphanProcessCleanupResult,
589
+ ): string {
590
+ if (result.errorMessage !== null) {
591
+ return `${label} cleanup error: ${result.errorMessage}`;
592
+ }
593
+ if (result.matchedPids.length === 0) {
594
+ return `${label} cleanup: none found`;
595
+ }
596
+ if (result.failedPids.length === 0) {
597
+ return `${label} cleanup: terminated ${String(result.terminatedPids.length)} process(es)`;
598
+ }
599
+ return [
600
+ `${label} cleanup:`,
601
+ `matched=${String(result.matchedPids.length)}`,
602
+ `terminated=${String(result.terminatedPids.length)}`,
603
+ `failed=${String(result.failedPids.length)}`,
604
+ ].join(' ');
605
+ }
606
+
607
+ private async cleanupOrphanPids(
608
+ matchedPids: readonly number[],
609
+ options: GatewayStopProcessOptions,
610
+ killProcessGroup = false,
611
+ ): Promise<OrphanProcessCleanupResult> {
612
+ const terminatedPids: number[] = [];
613
+ const failedPids: number[] = [];
614
+
615
+ for (const pid of matchedPids) {
616
+ if (!this.isPidRunning(pid)) {
617
+ continue;
618
+ }
619
+ const signaledTerm = this.signalPidWithOptionalProcessGroup(pid, 'SIGTERM', killProcessGroup);
620
+ if (!signaledTerm) {
621
+ terminatedPids.push(pid);
622
+ continue;
623
+ }
624
+
625
+ const exitedAfterTerm = await this.waitForPidExit(pid, options.timeoutMs);
626
+ if (exitedAfterTerm) {
627
+ terminatedPids.push(pid);
628
+ continue;
629
+ }
630
+
631
+ if (!options.force) {
632
+ failedPids.push(pid);
633
+ continue;
634
+ }
635
+
636
+ const signaledKill = this.signalPidWithOptionalProcessGroup(pid, 'SIGKILL', killProcessGroup);
637
+ if (!signaledKill) {
638
+ terminatedPids.push(pid);
639
+ continue;
640
+ }
641
+
642
+ if (await this.waitForPidExit(pid, options.timeoutMs)) {
643
+ terminatedPids.push(pid);
644
+ } else {
645
+ failedPids.push(pid);
646
+ }
647
+ }
648
+
649
+ return {
650
+ matchedPids,
651
+ terminatedPids,
652
+ failedPids,
653
+ errorMessage: null,
654
+ };
655
+ }
656
+
657
+ public async cleanupOrphanSqliteProcessesForDbPath(
658
+ stateDbPath: string,
659
+ options: GatewayStopProcessOptions,
660
+ ): Promise<OrphanProcessCleanupResult> {
661
+ let matchedPids: readonly number[] = [];
662
+ try {
663
+ matchedPids = this.findOrphanSqlitePidsForDbPath(stateDbPath);
664
+ } catch (error: unknown) {
665
+ return {
666
+ matchedPids: [],
667
+ terminatedPids: [],
668
+ failedPids: [],
669
+ errorMessage: error instanceof Error ? error.message : String(error),
670
+ };
671
+ }
672
+ return await this.cleanupOrphanPids(matchedPids, options, false);
673
+ }
674
+
675
+ public async cleanupOrphanGatewayDaemons(
676
+ stateDbPath: string,
677
+ daemonScriptPath: string,
678
+ options: GatewayStopProcessOptions,
679
+ ): Promise<OrphanProcessCleanupResult> {
680
+ let matchedPids: readonly number[] = [];
681
+ try {
682
+ matchedPids = this.findOrphanGatewayDaemonPids(stateDbPath, daemonScriptPath);
683
+ } catch (error: unknown) {
684
+ return {
685
+ matchedPids: [],
686
+ terminatedPids: [],
687
+ failedPids: [],
688
+ errorMessage: error instanceof Error ? error.message : String(error),
689
+ };
690
+ }
691
+ return await this.cleanupOrphanPids(matchedPids, options, true);
692
+ }
693
+
694
+ public async cleanupOrphanPtyHelpersForWorkspace(
695
+ invocationDirectory: string,
696
+ options: GatewayStopProcessOptions,
697
+ ): Promise<OrphanProcessCleanupResult> {
698
+ let matchedPids: readonly number[] = [];
699
+ try {
700
+ matchedPids = this.findOrphanPtyHelperPidsForWorkspace(invocationDirectory);
701
+ } catch (error: unknown) {
702
+ return {
703
+ matchedPids: [],
704
+ terminatedPids: [],
705
+ failedPids: [],
706
+ errorMessage: error instanceof Error ? error.message : String(error),
707
+ };
708
+ }
709
+ return await this.cleanupOrphanPids(matchedPids, options, false);
710
+ }
711
+
712
+ public async cleanupOrphanRelayLinkedAgentsForWorkspace(
713
+ invocationDirectory: string,
714
+ options: GatewayStopProcessOptions,
715
+ ): Promise<OrphanProcessCleanupResult> {
716
+ let matchedPids: readonly number[] = [];
717
+ try {
718
+ matchedPids = this.findOrphanRelayLinkedAgentPidsForWorkspace(invocationDirectory);
719
+ } catch (error: unknown) {
720
+ return {
721
+ matchedPids: [],
722
+ terminatedPids: [],
723
+ failedPids: [],
724
+ errorMessage: error instanceof Error ? error.message : String(error),
725
+ };
726
+ }
727
+ return await this.cleanupOrphanPids(matchedPids, options, false);
728
+ }
729
+ }