@jmoyers/harness 0.1.8 → 0.1.10

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 (41) hide show
  1. package/README.md +33 -155
  2. package/package.json +5 -1
  3. package/packages/harness-ai/src/anthropic-client.ts +99 -0
  4. package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
  5. package/packages/harness-ai/src/anthropic-provider.ts +82 -0
  6. package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
  7. package/packages/harness-ai/src/index.ts +36 -0
  8. package/packages/harness-ai/src/json-parse.ts +66 -0
  9. package/packages/harness-ai/src/sse.ts +80 -0
  10. package/packages/harness-ai/src/stream-object.ts +96 -0
  11. package/packages/harness-ai/src/stream-text.ts +1340 -0
  12. package/packages/harness-ai/src/types.ts +330 -0
  13. package/packages/harness-ai/src/ui-stream.ts +217 -0
  14. package/scripts/codex-live-mux-runtime.ts +123 -7
  15. package/scripts/control-plane-daemon.ts +20 -3
  16. package/scripts/harness.ts +566 -133
  17. package/src/cli/gateway-record.ts +16 -1
  18. package/src/control-plane/agent-realtime-api.ts +4 -0
  19. package/src/control-plane/prompt/agent-prompt-extractor.ts +191 -0
  20. package/src/control-plane/prompt/extractors/claude-prompt-extractor.ts +53 -0
  21. package/src/control-plane/prompt/extractors/codex-prompt-extractor.ts +50 -0
  22. package/src/control-plane/prompt/extractors/cursor-prompt-extractor.ts +56 -0
  23. package/src/control-plane/prompt/session-prompt-engine.ts +69 -0
  24. package/src/control-plane/prompt/thread-title-namer.ts +290 -0
  25. package/src/control-plane/stream-command-parser.ts +12 -0
  26. package/src/control-plane/stream-protocol.ts +109 -0
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server-session-runtime.ts +12 -0
  29. package/src/control-plane/stream-server.ts +485 -19
  30. package/src/mux/input-shortcuts.ts +9 -0
  31. package/src/mux/live-mux/critique-review.ts +5 -1
  32. package/src/mux/live-mux/git-parsing.ts +24 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/render-frame.ts +1 -1
  35. package/src/pty/pty_host.ts +46 -1
  36. package/src/services/control-plane.ts +22 -0
  37. package/src/services/runtime-control-actions.ts +69 -0
  38. package/src/services/runtime-navigation-input.ts +4 -0
  39. package/src/services/runtime-rail-input.ts +4 -0
  40. package/src/services/runtime-workspace-actions.ts +5 -0
  41. package/src/ui/global-shortcut-input.ts +2 -0
@@ -26,6 +26,7 @@ import {
26
26
  normalizeGatewayHost,
27
27
  normalizeGatewayPort,
28
28
  normalizeGatewayStateDbPath,
29
+ resolveGatewayLockPath,
29
30
  parseGatewayRecordText,
30
31
  resolveGatewayLogPath,
31
32
  resolveGatewayRecordPath,
@@ -78,6 +79,9 @@ const DEFAULT_GATEWAY_START_RETRY_WINDOW_MS = 6000;
78
79
  const DEFAULT_GATEWAY_START_RETRY_DELAY_MS = 40;
79
80
  const DEFAULT_GATEWAY_STOP_TIMEOUT_MS = 5000;
80
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;
81
85
  const DEFAULT_PROFILE_ROOT_PATH = 'profiles';
82
86
  const DEFAULT_SESSION_ROOT_PATH = 'sessions';
83
87
  const PROFILE_STATE_FILE_NAME = 'active-profile.json';
@@ -176,6 +180,7 @@ interface RuntimeInspectOptions {
176
180
  interface SessionPaths {
177
181
  recordPath: string;
178
182
  logPath: string;
183
+ lockPath: string;
179
184
  defaultStateDbPath: string;
180
185
  profileDir: string;
181
186
  profileStatePath: string;
@@ -222,6 +227,33 @@ interface OrphanProcessCleanupResult {
222
227
  errorMessage: string | null;
223
228
  }
224
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
+
225
257
  interface ActiveProfileState {
226
258
  version: number;
227
259
  mode: typeof PROFILE_LIVE_INSPECT_MODE;
@@ -348,6 +380,7 @@ function resolveSessionPaths(
348
380
  return {
349
381
  recordPath: resolveGatewayRecordPath(invocationDirectory, process.env),
350
382
  logPath: resolveGatewayLogPath(invocationDirectory, process.env),
383
+ lockPath: resolveGatewayLockPath(invocationDirectory, process.env),
351
384
  defaultStateDbPath: resolveHarnessRuntimePath(
352
385
  invocationDirectory,
353
386
  DEFAULT_GATEWAY_DB_PATH,
@@ -365,6 +398,7 @@ function resolveSessionPaths(
365
398
  return {
366
399
  recordPath: resolve(sessionRoot, 'gateway.json'),
367
400
  logPath: resolve(sessionRoot, 'gateway.log'),
401
+ lockPath: resolve(sessionRoot, 'gateway.lock'),
368
402
  defaultStateDbPath: resolve(sessionRoot, 'control-plane.sqlite'),
369
403
  profileDir: resolve(workspaceDirectory, DEFAULT_PROFILE_ROOT_PATH, sessionName),
370
404
  profileStatePath: resolve(sessionRoot, PROFILE_STATE_FILE_NAME),
@@ -939,6 +973,210 @@ function removeGatewayRecord(recordPath: string): void {
939
973
  }
940
974
  }
941
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
+
942
1180
  function parseActiveProfileState(raw: unknown): ActiveProfileState | null {
943
1181
  if (typeof raw !== 'object' || raw === null) {
944
1182
  return null;
@@ -1176,6 +1414,72 @@ function readProcessTable(): readonly ProcessTableEntry[] {
1176
1414
  return entries;
1177
1415
  }
1178
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
+
1179
1483
  function findOrphanSqlitePidsForDbPath(stateDbPath: string): readonly number[] {
1180
1484
  const normalizedDbPath = resolve(stateDbPath);
1181
1485
  return readProcessTable()
@@ -1398,10 +1702,9 @@ function resolveGatewaySettings(
1398
1702
  const port = normalizeGatewayPort(
1399
1703
  overrides.port ?? record?.port ?? env.HARNESS_CONTROL_PLANE_PORT,
1400
1704
  );
1401
- const stateDbPathRaw = normalizeGatewayStateDbPath(
1402
- overrides.stateDbPath ?? record?.stateDbPath ?? env.HARNESS_CONTROL_PLANE_DB_PATH,
1403
- defaultStateDbPath,
1404
- );
1705
+ const configuredStateDbPath =
1706
+ overrides.stateDbPath ?? env.HARNESS_CONTROL_PLANE_DB_PATH ?? defaultStateDbPath;
1707
+ const stateDbPathRaw = normalizeGatewayStateDbPath(configuredStateDbPath, defaultStateDbPath);
1405
1708
  const stateDbPath = resolveHarnessRuntimePath(invocationDirectory, stateDbPathRaw, env);
1406
1709
 
1407
1710
  const envToken =
@@ -1424,14 +1727,18 @@ function resolveGatewaySettings(
1424
1727
  };
1425
1728
  }
1426
1729
 
1427
- async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult> {
1730
+ async function probeGatewayEndpoint(
1731
+ host: string,
1732
+ port: number,
1733
+ authToken: string | null,
1734
+ ): Promise<GatewayProbeResult> {
1428
1735
  try {
1429
1736
  const client = await connectControlPlaneStreamClient({
1430
- host: record.host,
1431
- port: record.port,
1432
- ...(record.authToken !== null
1737
+ host,
1738
+ port,
1739
+ ...(authToken !== null
1433
1740
  ? {
1434
- authToken: record.authToken,
1741
+ authToken,
1435
1742
  }
1436
1743
  : {}),
1437
1744
  });
@@ -1477,6 +1784,10 @@ async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult>
1477
1784
  }
1478
1785
  }
1479
1786
 
1787
+ async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult> {
1788
+ return await probeGatewayEndpoint(record.host, record.port, record.authToken);
1789
+ }
1790
+
1480
1791
  async function waitForGatewayReady(record: GatewayRecord): Promise<void> {
1481
1792
  const client = await connectControlPlaneStreamClient({
1482
1793
  host: record.host,
@@ -1509,6 +1820,7 @@ async function startDetachedGateway(
1509
1820
  ): Promise<GatewayRecord> {
1510
1821
  mkdirSync(dirname(logPath), { recursive: true });
1511
1822
  const logFd = openSync(logPath, 'a');
1823
+ const gatewayRunId = randomUUID();
1512
1824
  const daemonArgs = tsRuntimeArgs(
1513
1825
  daemonScriptPath,
1514
1826
  [
@@ -1530,6 +1842,7 @@ async function startDetachedGateway(
1530
1842
  env: {
1531
1843
  ...process.env,
1532
1844
  HARNESS_INVOKE_CWD: invocationDirectory,
1845
+ HARNESS_GATEWAY_RUN_ID: gatewayRunId,
1533
1846
  },
1534
1847
  });
1535
1848
  closeSync(logFd);
@@ -1547,10 +1860,16 @@ async function startDetachedGateway(
1547
1860
  stateDbPath: settings.stateDbPath,
1548
1861
  startedAt: new Date().toISOString(),
1549
1862
  workspaceRoot: invocationDirectory,
1863
+ gatewayRunId,
1550
1864
  };
1551
1865
 
1552
1866
  try {
1553
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
+ }
1554
1873
  } catch (error: unknown) {
1555
1874
  try {
1556
1875
  process.kill(child.pid, 'SIGTERM');
@@ -1565,6 +1884,47 @@ async function startDetachedGateway(
1565
1884
  return record;
1566
1885
  }
1567
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
+
1568
1928
  async function ensureGatewayRunning(
1569
1929
  invocationDirectory: string,
1570
1930
  recordPath: string,
@@ -1598,6 +1958,33 @@ async function ensureGatewayRunning(
1598
1958
  process.env,
1599
1959
  defaultStateDbPath,
1600
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
+ }
1601
1988
  const record = await startDetachedGateway(
1602
1989
  invocationDirectory,
1603
1990
  recordPath,
@@ -1749,6 +2136,7 @@ async function runGatewayForeground(
1749
2136
  settings: ResolvedGatewaySettings,
1750
2137
  runtimeArgs: readonly string[] = [],
1751
2138
  ): Promise<number> {
2139
+ const gatewayRunId = randomUUID();
1752
2140
  const existingRecord = readGatewayRecord(recordPath);
1753
2141
  if (existingRecord !== null) {
1754
2142
  const probe = await probeGateway(existingRecord);
@@ -1779,6 +2167,7 @@ async function runGatewayForeground(
1779
2167
  env: {
1780
2168
  ...process.env,
1781
2169
  HARNESS_INVOKE_CWD: invocationDirectory,
2170
+ HARNESS_GATEWAY_RUN_ID: gatewayRunId,
1782
2171
  },
1783
2172
  });
1784
2173
  if (child.pid !== undefined) {
@@ -1791,6 +2180,7 @@ async function runGatewayForeground(
1791
2180
  stateDbPath: settings.stateDbPath,
1792
2181
  startedAt: new Date().toISOString(),
1793
2182
  workspaceRoot: invocationDirectory,
2183
+ gatewayRunId,
1794
2184
  });
1795
2185
  }
1796
2186
 
@@ -1847,37 +2237,48 @@ async function runGatewayCommandEntry(
1847
2237
  command: ParsedGatewayCommand,
1848
2238
  invocationDirectory: string,
1849
2239
  daemonScriptPath: string,
2240
+ lockPath: string,
1850
2241
  recordPath: string,
1851
2242
  logPath: string,
1852
2243
  defaultStateDbPath: string,
1853
2244
  runtimeOptions: RuntimeInspectOptions,
1854
2245
  ): Promise<number> {
2246
+ const withLock = async <T>(operation: () => Promise<T>): Promise<T> => {
2247
+ return await withGatewayControlLock(lockPath, invocationDirectory, operation);
2248
+ };
2249
+
1855
2250
  if (command.type === 'status') {
1856
- const record = readGatewayRecord(recordPath);
1857
- if (record === null) {
1858
- process.stdout.write('gateway status: stopped\n');
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
+ }
1859
2280
  return 0;
1860
- }
1861
- const pidRunning = isPidRunning(record.pid);
1862
- const probe = await probeGateway(record);
1863
- process.stdout.write(`gateway status: ${probe.connected ? 'running' : 'unreachable'}\n`);
1864
- process.stdout.write(`record: ${recordPath}\n`);
1865
- process.stdout.write(
1866
- `pid: ${String(record.pid)} (${pidRunning ? 'running' : 'not-running'})\n`,
1867
- );
1868
- process.stdout.write(`host: ${record.host}\n`);
1869
- process.stdout.write(`port: ${String(record.port)}\n`);
1870
- process.stdout.write(`auth: ${record.authToken === null ? 'off' : 'on'}\n`);
1871
- process.stdout.write(`db: ${record.stateDbPath}\n`);
1872
- process.stdout.write(`startedAt: ${record.startedAt}\n`);
1873
- process.stdout.write(
1874
- `sessions: total=${String(probe.sessionCount)} live=${String(probe.liveSessionCount)}\n`,
1875
- );
1876
- if (!probe.connected) {
1877
- process.stdout.write(`lastError: ${probe.error ?? 'unknown'}\n`);
1878
- return 1;
1879
- }
1880
- return 0;
2281
+ });
1881
2282
  }
1882
2283
 
1883
2284
  if (command.type === 'stop') {
@@ -1886,26 +2287,32 @@ async function runGatewayCommandEntry(
1886
2287
  timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
1887
2288
  cleanupOrphans: true,
1888
2289
  };
1889
- const stopped = await stopGateway(
1890
- invocationDirectory,
1891
- daemonScriptPath,
1892
- recordPath,
1893
- defaultStateDbPath,
1894
- stopOptions,
2290
+ const stopped = await withLock(
2291
+ async () =>
2292
+ await stopGateway(
2293
+ invocationDirectory,
2294
+ daemonScriptPath,
2295
+ recordPath,
2296
+ defaultStateDbPath,
2297
+ stopOptions,
2298
+ ),
1895
2299
  );
1896
2300
  process.stdout.write(`${stopped.message}\n`);
1897
2301
  return stopped.stopped ? 0 : 1;
1898
2302
  }
1899
2303
 
1900
2304
  if (command.type === 'start') {
1901
- const ensured = await ensureGatewayRunning(
1902
- invocationDirectory,
1903
- recordPath,
1904
- logPath,
1905
- daemonScriptPath,
1906
- defaultStateDbPath,
1907
- command.startOptions ?? {},
1908
- runtimeOptions.gatewayRuntimeArgs,
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
+ ),
1909
2316
  );
1910
2317
  if (ensured.started) {
1911
2318
  process.stdout.write(
@@ -1918,61 +2325,66 @@ async function runGatewayCommandEntry(
1918
2325
  }
1919
2326
  process.stdout.write(`record: ${recordPath}\n`);
1920
2327
  process.stdout.write(`log: ${logPath}\n`);
2328
+ process.stdout.write(`lock: ${lockPath}\n`);
1921
2329
  return 0;
1922
2330
  }
1923
2331
 
1924
2332
  if (command.type === 'restart') {
1925
- const stopResult = await stopGateway(
1926
- invocationDirectory,
1927
- daemonScriptPath,
1928
- recordPath,
1929
- defaultStateDbPath,
1930
- {
1931
- force: true,
1932
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
1933
- cleanupOrphans: true,
1934
- },
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
+ }),
1935
2340
  );
1936
2341
  process.stdout.write(`${stopResult.message}\n`);
1937
- const ensured = await ensureGatewayRunning(
1938
- invocationDirectory,
1939
- recordPath,
1940
- logPath,
1941
- daemonScriptPath,
1942
- defaultStateDbPath,
1943
- command.startOptions ?? {},
1944
- runtimeOptions.gatewayRuntimeArgs,
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
+ ),
1945
2353
  );
1946
2354
  process.stdout.write(
1947
2355
  `gateway restarted pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
1948
2356
  );
1949
2357
  process.stdout.write(`record: ${recordPath}\n`);
1950
2358
  process.stdout.write(`log: ${logPath}\n`);
2359
+ process.stdout.write(`lock: ${lockPath}\n`);
1951
2360
  return 0;
1952
2361
  }
1953
2362
 
1954
2363
  if (command.type === 'run') {
1955
- const existingRecord = readGatewayRecord(recordPath);
1956
- const settings = resolveGatewaySettings(
1957
- invocationDirectory,
1958
- existingRecord,
1959
- command.startOptions ?? {},
1960
- process.env,
1961
- defaultStateDbPath,
1962
- );
1963
- process.stdout.write(
1964
- `gateway foreground run host=${settings.host} port=${String(settings.port)} db=${settings.stateDbPath}\n`,
1965
- );
1966
- return await runGatewayForeground(
1967
- daemonScriptPath,
1968
- invocationDirectory,
1969
- recordPath,
1970
- settings,
1971
- runtimeOptions.gatewayRuntimeArgs,
1972
- );
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
+ });
1973
2385
  }
1974
2386
 
1975
- const record = readGatewayRecord(recordPath);
2387
+ const record = await withLock(async () => readGatewayRecord(recordPath));
1976
2388
  if (record === null) {
1977
2389
  throw new Error('gateway not running; start it first');
1978
2390
  }
@@ -1986,6 +2398,7 @@ async function runDefaultClient(
1986
2398
  invocationDirectory: string,
1987
2399
  daemonScriptPath: string,
1988
2400
  muxScriptPath: string,
2401
+ lockPath: string,
1989
2402
  recordPath: string,
1990
2403
  logPath: string,
1991
2404
  defaultStateDbPath: string,
@@ -1993,14 +2406,19 @@ async function runDefaultClient(
1993
2406
  sessionName: string | null,
1994
2407
  runtimeOptions: RuntimeInspectOptions,
1995
2408
  ): Promise<number> {
1996
- const ensured = await ensureGatewayRunning(
2409
+ const ensured = await withGatewayControlLock(
2410
+ lockPath,
1997
2411
  invocationDirectory,
1998
- recordPath,
1999
- logPath,
2000
- daemonScriptPath,
2001
- defaultStateDbPath,
2002
- {},
2003
- runtimeOptions.gatewayRuntimeArgs,
2412
+ async () =>
2413
+ await ensureGatewayRunning(
2414
+ invocationDirectory,
2415
+ recordPath,
2416
+ logPath,
2417
+ daemonScriptPath,
2418
+ defaultStateDbPath,
2419
+ {},
2420
+ runtimeOptions.gatewayRuntimeArgs,
2421
+ ),
2004
2422
  );
2005
2423
  if (ensured.started) {
2006
2424
  process.stdout.write(
@@ -2047,41 +2465,49 @@ async function runProfileRun(
2047
2465
  removeActiveProfileState(sessionPaths.profileStatePath);
2048
2466
  }
2049
2467
 
2050
- const existingRecord = readGatewayRecord(sessionPaths.recordPath);
2051
- if (existingRecord !== null) {
2052
- const existingProbe = await probeGateway(existingRecord);
2053
- if (existingProbe.connected || isPidRunning(existingRecord.pid)) {
2054
- throw new Error('profile command requires the target session gateway to be stopped first');
2055
- }
2056
- removeGatewayRecord(sessionPaths.recordPath);
2057
- }
2058
-
2059
- const host = normalizeGatewayHost(process.env.HARNESS_CONTROL_PLANE_HOST);
2060
- const reservedPort = await reservePort(host);
2061
- const settings = resolveGatewaySettings(
2468
+ const gateway = await withGatewayControlLock(
2469
+ sessionPaths.lockPath,
2062
2470
  invocationDirectory,
2063
- null,
2064
- {
2065
- port: reservedPort,
2066
- stateDbPath: sessionPaths.defaultStateDbPath,
2067
- },
2068
- process.env,
2069
- sessionPaths.defaultStateDbPath,
2070
- );
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
+ }
2071
2482
 
2072
- const gateway = await startDetachedGateway(
2073
- invocationDirectory,
2074
- sessionPaths.recordPath,
2075
- sessionPaths.logPath,
2076
- settings,
2077
- daemonScriptPath,
2078
- [
2079
- ...runtimeOptions.gatewayRuntimeArgs,
2080
- ...buildCpuProfileRuntimeArgs({
2081
- cpuProfileDir: profileDir,
2082
- cpuProfileName: PROFILE_GATEWAY_FILE_NAME,
2083
- }),
2084
- ],
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
+ },
2085
2511
  );
2086
2512
 
2087
2513
  let clientExitCode = 1;
@@ -2105,16 +2531,21 @@ async function runProfileRun(
2105
2531
  clientError = error instanceof Error ? error : new Error(String(error));
2106
2532
  }
2107
2533
 
2108
- const stopped = await stopGateway(
2534
+ const stopped = await withGatewayControlLock(
2535
+ sessionPaths.lockPath,
2109
2536
  invocationDirectory,
2110
- daemonScriptPath,
2111
- sessionPaths.recordPath,
2112
- sessionPaths.defaultStateDbPath,
2113
- {
2114
- force: true,
2115
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2116
- cleanupOrphans: true,
2117
- },
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
+ ),
2118
2549
  );
2119
2550
  process.stdout.write(`${stopped.message}\n`);
2120
2551
  if (!stopped.stopped) {
@@ -2519,6 +2950,7 @@ async function main(): Promise<number> {
2519
2950
  command,
2520
2951
  invocationDirectory,
2521
2952
  daemonScriptPath,
2953
+ sessionPaths.lockPath,
2522
2954
  sessionPaths.recordPath,
2523
2955
  sessionPaths.logPath,
2524
2956
  sessionPaths.defaultStateDbPath,
@@ -2577,6 +3009,7 @@ async function main(): Promise<number> {
2577
3009
  invocationDirectory,
2578
3010
  daemonScriptPath,
2579
3011
  muxScriptPath,
3012
+ sessionPaths.lockPath,
2580
3013
  sessionPaths.recordPath,
2581
3014
  sessionPaths.logPath,
2582
3015
  sessionPaths.defaultStateDbPath,