@jmoyers/harness 0.1.9 → 0.1.11

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 (54) hide show
  1. package/README.md +36 -155
  2. package/package.json +3 -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 +265 -14
  15. package/scripts/control-plane-daemon.ts +33 -5
  16. package/scripts/harness.ts +579 -134
  17. package/src/cli/default-gateway-pointer.ts +193 -0
  18. package/src/cli/gateway-record.ts +16 -1
  19. package/src/config/config-core.ts +13 -2
  20. package/src/config/harness-paths.ts +4 -7
  21. package/src/config/harness-runtime-migration.ts +142 -19
  22. package/src/config/secrets-core.ts +92 -4
  23. package/src/control-plane/prompt/thread-title-namer.ts +316 -0
  24. package/src/control-plane/stream-command-parser.ts +12 -0
  25. package/src/control-plane/stream-protocol.ts +6 -0
  26. package/src/control-plane/stream-server-background.ts +18 -2
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server.ts +460 -28
  29. package/src/domain/conversations.ts +11 -7
  30. package/src/domain/workspace.ts +9 -0
  31. package/src/mux/input-shortcuts.ts +38 -1
  32. package/src/mux/live-mux/git-parsing.ts +40 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
  35. package/src/mux/live-mux/modal-input-reducers.ts +34 -1
  36. package/src/mux/live-mux/modal-overlays.ts +45 -0
  37. package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
  38. package/src/mux/render-frame.ts +1 -1
  39. package/src/mux/task-screen-keybindings.ts +29 -1
  40. package/src/services/control-plane.ts +22 -0
  41. package/src/services/runtime-control-actions.ts +69 -0
  42. package/src/services/runtime-conversation-activation.ts +25 -0
  43. package/src/services/runtime-conversation-starter.ts +31 -7
  44. package/src/services/runtime-input-router.ts +6 -0
  45. package/src/services/runtime-modal-input.ts +18 -0
  46. package/src/services/runtime-navigation-input.ts +4 -0
  47. package/src/services/runtime-rail-input.ts +5 -0
  48. package/src/services/runtime-repository-actions.ts +2 -0
  49. package/src/services/runtime-workspace-actions.ts +5 -0
  50. package/src/store/control-plane-store.ts +36 -0
  51. package/src/store/event-store.ts +36 -0
  52. package/src/ui/global-shortcut-input.ts +2 -0
  53. package/src/ui/input.ts +31 -0
  54. package/src/ui/modals/manager.ts +26 -0
@@ -19,6 +19,10 @@ import { connectControlPlaneStreamClient } from '../src/control-plane/stream-cli
19
19
  import { parseStreamCommand } from '../src/control-plane/stream-command-parser.ts';
20
20
  import type { StreamCommand } from '../src/control-plane/stream-protocol.ts';
21
21
  import { runHarnessAnimate } from './harness-animate.ts';
22
+ import {
23
+ clearDefaultGatewayPointerForRecordPath,
24
+ writeDefaultGatewayPointerFromGatewayRecord,
25
+ } from '../src/cli/default-gateway-pointer.ts';
22
26
  import {
23
27
  GATEWAY_RECORD_VERSION,
24
28
  DEFAULT_GATEWAY_DB_PATH,
@@ -26,6 +30,7 @@ import {
26
30
  normalizeGatewayHost,
27
31
  normalizeGatewayPort,
28
32
  normalizeGatewayStateDbPath,
33
+ resolveGatewayLockPath,
29
34
  parseGatewayRecordText,
30
35
  resolveGatewayLogPath,
31
36
  resolveGatewayRecordPath,
@@ -78,6 +83,9 @@ const DEFAULT_GATEWAY_START_RETRY_WINDOW_MS = 6000;
78
83
  const DEFAULT_GATEWAY_START_RETRY_DELAY_MS = 40;
79
84
  const DEFAULT_GATEWAY_STOP_TIMEOUT_MS = 5000;
80
85
  const DEFAULT_GATEWAY_STOP_POLL_MS = 50;
86
+ const DEFAULT_GATEWAY_LOCK_TIMEOUT_MS = 7000;
87
+ const DEFAULT_GATEWAY_LOCK_POLL_MS = 40;
88
+ const GATEWAY_LOCK_VERSION = 1;
81
89
  const DEFAULT_PROFILE_ROOT_PATH = 'profiles';
82
90
  const DEFAULT_SESSION_ROOT_PATH = 'sessions';
83
91
  const PROFILE_STATE_FILE_NAME = 'active-profile.json';
@@ -176,6 +184,7 @@ interface RuntimeInspectOptions {
176
184
  interface SessionPaths {
177
185
  recordPath: string;
178
186
  logPath: string;
187
+ lockPath: string;
179
188
  defaultStateDbPath: string;
180
189
  profileDir: string;
181
190
  profileStatePath: string;
@@ -222,6 +231,33 @@ interface OrphanProcessCleanupResult {
222
231
  errorMessage: string | null;
223
232
  }
224
233
 
234
+ interface GatewayProcessIdentity {
235
+ pid: number;
236
+ startedAt: string;
237
+ }
238
+
239
+ interface GatewayControlLockRecord {
240
+ version: number;
241
+ owner: GatewayProcessIdentity;
242
+ acquiredAt: string;
243
+ workspaceRoot: string;
244
+ token: string;
245
+ }
246
+
247
+ interface GatewayControlLockHandle {
248
+ lockPath: string;
249
+ record: GatewayControlLockRecord;
250
+ release: () => void;
251
+ }
252
+
253
+ interface ParsedGatewayDaemonEntry {
254
+ pid: number;
255
+ host: string;
256
+ port: number;
257
+ authToken: string | null;
258
+ stateDbPath: string;
259
+ }
260
+
225
261
  interface ActiveProfileState {
226
262
  version: number;
227
263
  mode: typeof PROFILE_LIVE_INSPECT_MODE;
@@ -348,6 +384,7 @@ function resolveSessionPaths(
348
384
  return {
349
385
  recordPath: resolveGatewayRecordPath(invocationDirectory, process.env),
350
386
  logPath: resolveGatewayLogPath(invocationDirectory, process.env),
387
+ lockPath: resolveGatewayLockPath(invocationDirectory, process.env),
351
388
  defaultStateDbPath: resolveHarnessRuntimePath(
352
389
  invocationDirectory,
353
390
  DEFAULT_GATEWAY_DB_PATH,
@@ -365,6 +402,7 @@ function resolveSessionPaths(
365
402
  return {
366
403
  recordPath: resolve(sessionRoot, 'gateway.json'),
367
404
  logPath: resolve(sessionRoot, 'gateway.log'),
405
+ lockPath: resolve(sessionRoot, 'gateway.lock'),
368
406
  defaultStateDbPath: resolve(sessionRoot, 'control-plane.sqlite'),
369
407
  profileDir: resolve(workspaceDirectory, DEFAULT_PROFILE_ROOT_PATH, sessionName),
370
408
  profileStatePath: resolve(sessionRoot, PROFILE_STATE_FILE_NAME),
@@ -926,6 +964,7 @@ function writeTextFileAtomically(filePath: string, text: string): void {
926
964
 
927
965
  function writeGatewayRecord(recordPath: string, record: GatewayRecord): void {
928
966
  writeTextFileAtomically(recordPath, serializeGatewayRecord(record));
967
+ writeDefaultGatewayPointerFromGatewayRecord(recordPath, record, process.env);
929
968
  }
930
969
 
931
970
  function removeGatewayRecord(recordPath: string): void {
@@ -937,6 +976,211 @@ function removeGatewayRecord(recordPath: string): void {
937
976
  throw error;
938
977
  }
939
978
  }
979
+ clearDefaultGatewayPointerForRecordPath(recordPath, process.cwd(), process.env);
980
+ }
981
+
982
+ function readProcessStartedAt(pid: number): string | null {
983
+ if (!Number.isInteger(pid) || pid <= 0) {
984
+ return null;
985
+ }
986
+ try {
987
+ const output = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
988
+ encoding: 'utf8',
989
+ }).trim();
990
+ return output.length > 0 ? output : null;
991
+ } catch {
992
+ return null;
993
+ }
994
+ }
995
+
996
+ function resolveCurrentProcessIdentity(): GatewayProcessIdentity {
997
+ const startedAt = readProcessStartedAt(process.pid);
998
+ if (startedAt === null) {
999
+ throw new Error(
1000
+ `failed to resolve current process start timestamp for pid=${String(process.pid)}`,
1001
+ );
1002
+ }
1003
+ return {
1004
+ pid: process.pid,
1005
+ startedAt,
1006
+ };
1007
+ }
1008
+
1009
+ function parseGatewayControlLockText(text: string): GatewayControlLockRecord | null {
1010
+ let parsed: unknown;
1011
+ try {
1012
+ parsed = JSON.parse(text);
1013
+ } catch {
1014
+ return null;
1015
+ }
1016
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1017
+ return null;
1018
+ }
1019
+ const candidate = parsed as Record<string, unknown>;
1020
+ if (candidate['version'] !== GATEWAY_LOCK_VERSION) {
1021
+ return null;
1022
+ }
1023
+ if (typeof candidate['acquiredAt'] !== 'string' || candidate['acquiredAt'].trim().length === 0) {
1024
+ return null;
1025
+ }
1026
+ if (
1027
+ typeof candidate['workspaceRoot'] !== 'string' ||
1028
+ candidate['workspaceRoot'].trim().length === 0
1029
+ ) {
1030
+ return null;
1031
+ }
1032
+ if (typeof candidate['token'] !== 'string' || candidate['token'].trim().length === 0) {
1033
+ return null;
1034
+ }
1035
+ const owner = candidate['owner'];
1036
+ if (typeof owner !== 'object' || owner === null || Array.isArray(owner)) {
1037
+ return null;
1038
+ }
1039
+ const ownerRecord = owner as Record<string, unknown>;
1040
+ const pid = ownerRecord['pid'];
1041
+ const startedAt = ownerRecord['startedAt'];
1042
+ if (!Number.isInteger(pid) || (pid as number) <= 0) {
1043
+ return null;
1044
+ }
1045
+ if (typeof startedAt !== 'string' || startedAt.trim().length === 0) {
1046
+ return null;
1047
+ }
1048
+ return {
1049
+ version: GATEWAY_LOCK_VERSION,
1050
+ owner: {
1051
+ pid: pid as number,
1052
+ startedAt,
1053
+ },
1054
+ acquiredAt: candidate['acquiredAt'] as string,
1055
+ workspaceRoot: candidate['workspaceRoot'] as string,
1056
+ token: candidate['token'] as string,
1057
+ };
1058
+ }
1059
+
1060
+ function readGatewayControlLock(lockPath: string): GatewayControlLockRecord | null {
1061
+ if (!existsSync(lockPath)) {
1062
+ return null;
1063
+ }
1064
+ try {
1065
+ return parseGatewayControlLockText(readFileSync(lockPath, 'utf8'));
1066
+ } catch {
1067
+ return null;
1068
+ }
1069
+ }
1070
+
1071
+ function removeGatewayControlLock(lockPath: string): void {
1072
+ try {
1073
+ unlinkSync(lockPath);
1074
+ } catch (error: unknown) {
1075
+ const code = (error as NodeJS.ErrnoException).code;
1076
+ if (code !== 'ENOENT') {
1077
+ throw error;
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ function isGatewayControlLockOwnerAlive(record: GatewayControlLockRecord): boolean {
1083
+ if (!isPidRunning(record.owner.pid)) {
1084
+ return false;
1085
+ }
1086
+ const startedAt = readProcessStartedAt(record.owner.pid);
1087
+ if (startedAt === null) {
1088
+ return false;
1089
+ }
1090
+ return startedAt === record.owner.startedAt;
1091
+ }
1092
+
1093
+ function createGatewayControlLockHandle(
1094
+ lockPath: string,
1095
+ record: GatewayControlLockRecord,
1096
+ ): GatewayControlLockHandle {
1097
+ return {
1098
+ lockPath,
1099
+ record,
1100
+ release: () => {
1101
+ const current = readGatewayControlLock(lockPath);
1102
+ if (current === null) {
1103
+ return;
1104
+ }
1105
+ if (
1106
+ current.token !== record.token ||
1107
+ current.owner.pid !== record.owner.pid ||
1108
+ current.owner.startedAt !== record.owner.startedAt
1109
+ ) {
1110
+ return;
1111
+ }
1112
+ removeGatewayControlLock(lockPath);
1113
+ },
1114
+ };
1115
+ }
1116
+
1117
+ async function acquireGatewayControlLock(
1118
+ lockPath: string,
1119
+ workspaceRoot: string,
1120
+ timeoutMs = DEFAULT_GATEWAY_LOCK_TIMEOUT_MS,
1121
+ ): Promise<GatewayControlLockHandle> {
1122
+ const owner = resolveCurrentProcessIdentity();
1123
+ const deadlineMs = Date.now() + timeoutMs;
1124
+ const candidate: GatewayControlLockRecord = {
1125
+ version: GATEWAY_LOCK_VERSION,
1126
+ owner,
1127
+ acquiredAt: new Date().toISOString(),
1128
+ workspaceRoot,
1129
+ token: randomUUID(),
1130
+ };
1131
+
1132
+ while (true) {
1133
+ mkdirSync(dirname(lockPath), { recursive: true });
1134
+ try {
1135
+ const fd = openSync(lockPath, 'wx');
1136
+ try {
1137
+ writeFileSync(fd, `${JSON.stringify(candidate, null, 2)}\n`, 'utf8');
1138
+ } finally {
1139
+ closeSync(fd);
1140
+ }
1141
+ return createGatewayControlLockHandle(lockPath, candidate);
1142
+ } catch (error: unknown) {
1143
+ const code = (error as NodeJS.ErrnoException).code;
1144
+ if (code !== 'EEXIST') {
1145
+ throw error;
1146
+ }
1147
+ }
1148
+
1149
+ const existing = readGatewayControlLock(lockPath);
1150
+ if (existing === null) {
1151
+ removeGatewayControlLock(lockPath);
1152
+ continue;
1153
+ }
1154
+
1155
+ if (existing.owner.pid === owner.pid && existing.owner.startedAt === owner.startedAt) {
1156
+ return createGatewayControlLockHandle(lockPath, existing);
1157
+ }
1158
+
1159
+ if (!isGatewayControlLockOwnerAlive(existing)) {
1160
+ removeGatewayControlLock(lockPath);
1161
+ continue;
1162
+ }
1163
+
1164
+ if (Date.now() >= deadlineMs) {
1165
+ throw new Error(
1166
+ `timed out waiting for gateway control lock: lockPath=${lockPath} ownerPid=${String(existing.owner.pid)} acquiredAt=${existing.acquiredAt}`,
1167
+ );
1168
+ }
1169
+ await delay(DEFAULT_GATEWAY_LOCK_POLL_MS);
1170
+ }
1171
+ }
1172
+
1173
+ async function withGatewayControlLock<T>(
1174
+ lockPath: string,
1175
+ workspaceRoot: string,
1176
+ operation: () => Promise<T>,
1177
+ ): Promise<T> {
1178
+ const handle = await acquireGatewayControlLock(lockPath, workspaceRoot);
1179
+ try {
1180
+ return await operation();
1181
+ } finally {
1182
+ handle.release();
1183
+ }
940
1184
  }
941
1185
 
942
1186
  function parseActiveProfileState(raw: unknown): ActiveProfileState | null {
@@ -1176,6 +1420,73 @@ function readProcessTable(): readonly ProcessTableEntry[] {
1176
1420
  return entries;
1177
1421
  }
1178
1422
 
1423
+ function tokenizeProcessCommand(command: string): readonly string[] {
1424
+ const trimmed = command.trim();
1425
+ return trimmed.length === 0 ? [] : trimmed.split(/\s+/u);
1426
+ }
1427
+
1428
+ function readCommandFlagValue(tokens: readonly string[], flag: string): string | null {
1429
+ for (let index = 0; index < tokens.length; index += 1) {
1430
+ const token = tokens[index]!;
1431
+ if (token === flag) {
1432
+ const value = tokens[index + 1];
1433
+ return value === undefined ? null : value;
1434
+ }
1435
+ if (token.startsWith(`${flag}=`)) {
1436
+ const value = token.slice(flag.length + 1);
1437
+ return value.length === 0 ? null : value;
1438
+ }
1439
+ }
1440
+ return null;
1441
+ }
1442
+
1443
+ function parseGatewayDaemonProcessEntry(entry: ProcessTableEntry): ParsedGatewayDaemonEntry | null {
1444
+ if (!/\bcontrol-plane-daemon\.(?:ts|js)\b/u.test(entry.command)) {
1445
+ return null;
1446
+ }
1447
+ const tokens = tokenizeProcessCommand(entry.command);
1448
+ const host = readCommandFlagValue(tokens, '--host');
1449
+ const portRaw = readCommandFlagValue(tokens, '--port');
1450
+ const stateDbPath = readCommandFlagValue(tokens, '--state-db-path');
1451
+ const authToken = readCommandFlagValue(tokens, '--auth-token');
1452
+ if (host === null || portRaw === null || stateDbPath === null) {
1453
+ return null;
1454
+ }
1455
+ const port = Number.parseInt(portRaw, 10);
1456
+ if (!Number.isFinite(port) || !Number.isInteger(port) || port <= 0 || port > 65535) {
1457
+ return null;
1458
+ }
1459
+ return {
1460
+ pid: entry.pid,
1461
+ host,
1462
+ port,
1463
+ authToken,
1464
+ stateDbPath: resolve(stateDbPath),
1465
+ };
1466
+ }
1467
+
1468
+ function listGatewayDaemonProcesses(): readonly ParsedGatewayDaemonEntry[] {
1469
+ const parsed: ParsedGatewayDaemonEntry[] = [];
1470
+ for (const entry of readProcessTable()) {
1471
+ const daemon = parseGatewayDaemonProcessEntry(entry);
1472
+ if (daemon !== null) {
1473
+ parsed.push(daemon);
1474
+ }
1475
+ }
1476
+ return parsed;
1477
+ }
1478
+
1479
+ function isPathWithinWorkspaceRuntimeScope(
1480
+ pathValue: string,
1481
+ invocationDirectory: string,
1482
+ env: NodeJS.ProcessEnv = process.env,
1483
+ ): boolean {
1484
+ const runtimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
1485
+ const normalizedRoot = resolve(runtimeRoot);
1486
+ const normalizedPath = resolve(pathValue);
1487
+ return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
1488
+ }
1489
+
1179
1490
  function findOrphanSqlitePidsForDbPath(stateDbPath: string): readonly number[] {
1180
1491
  const normalizedDbPath = resolve(stateDbPath);
1181
1492
  return readProcessTable()
@@ -1398,11 +1709,15 @@ function resolveGatewaySettings(
1398
1709
  const port = normalizeGatewayPort(
1399
1710
  overrides.port ?? record?.port ?? env.HARNESS_CONTROL_PLANE_PORT,
1400
1711
  );
1401
- const stateDbPathRaw = normalizeGatewayStateDbPath(
1402
- overrides.stateDbPath ?? record?.stateDbPath ?? env.HARNESS_CONTROL_PLANE_DB_PATH,
1403
- defaultStateDbPath,
1404
- );
1712
+ const configuredStateDbPath = overrides.stateDbPath ?? defaultStateDbPath;
1713
+ const stateDbPathRaw = normalizeGatewayStateDbPath(configuredStateDbPath, defaultStateDbPath);
1405
1714
  const stateDbPath = resolveHarnessRuntimePath(invocationDirectory, stateDbPathRaw, env);
1715
+ if (!isPathWithinWorkspaceRuntimeScope(stateDbPath, invocationDirectory, env)) {
1716
+ const runtimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
1717
+ throw new Error(
1718
+ `invalid --state-db-path: ${stateDbPath}. state db path must be under workspace runtime root ${runtimeRoot}`,
1719
+ );
1720
+ }
1406
1721
 
1407
1722
  const envToken =
1408
1723
  typeof env.HARNESS_CONTROL_PLANE_AUTH_TOKEN === 'string' &&
@@ -1424,14 +1739,18 @@ function resolveGatewaySettings(
1424
1739
  };
1425
1740
  }
1426
1741
 
1427
- async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult> {
1742
+ async function probeGatewayEndpoint(
1743
+ host: string,
1744
+ port: number,
1745
+ authToken: string | null,
1746
+ ): Promise<GatewayProbeResult> {
1428
1747
  try {
1429
1748
  const client = await connectControlPlaneStreamClient({
1430
- host: record.host,
1431
- port: record.port,
1432
- ...(record.authToken !== null
1749
+ host,
1750
+ port,
1751
+ ...(authToken !== null
1433
1752
  ? {
1434
- authToken: record.authToken,
1753
+ authToken,
1435
1754
  }
1436
1755
  : {}),
1437
1756
  });
@@ -1477,6 +1796,10 @@ async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult>
1477
1796
  }
1478
1797
  }
1479
1798
 
1799
+ async function probeGateway(record: GatewayRecord): Promise<GatewayProbeResult> {
1800
+ return await probeGatewayEndpoint(record.host, record.port, record.authToken);
1801
+ }
1802
+
1480
1803
  async function waitForGatewayReady(record: GatewayRecord): Promise<void> {
1481
1804
  const client = await connectControlPlaneStreamClient({
1482
1805
  host: record.host,
@@ -1509,6 +1832,7 @@ async function startDetachedGateway(
1509
1832
  ): Promise<GatewayRecord> {
1510
1833
  mkdirSync(dirname(logPath), { recursive: true });
1511
1834
  const logFd = openSync(logPath, 'a');
1835
+ const gatewayRunId = randomUUID();
1512
1836
  const daemonArgs = tsRuntimeArgs(
1513
1837
  daemonScriptPath,
1514
1838
  [
@@ -1530,6 +1854,7 @@ async function startDetachedGateway(
1530
1854
  env: {
1531
1855
  ...process.env,
1532
1856
  HARNESS_INVOKE_CWD: invocationDirectory,
1857
+ HARNESS_GATEWAY_RUN_ID: gatewayRunId,
1533
1858
  },
1534
1859
  });
1535
1860
  closeSync(logFd);
@@ -1547,10 +1872,16 @@ async function startDetachedGateway(
1547
1872
  stateDbPath: settings.stateDbPath,
1548
1873
  startedAt: new Date().toISOString(),
1549
1874
  workspaceRoot: invocationDirectory,
1875
+ gatewayRunId,
1550
1876
  };
1551
1877
 
1552
1878
  try {
1553
1879
  await waitForGatewayReady(record);
1880
+ if (!isPidRunning(child.pid)) {
1881
+ throw new Error(
1882
+ `gateway daemon exited during startup (pid=${String(child.pid)}); possible duplicate start or port collision`,
1883
+ );
1884
+ }
1554
1885
  } catch (error: unknown) {
1555
1886
  try {
1556
1887
  process.kill(child.pid, 'SIGTERM');
@@ -1565,6 +1896,47 @@ async function startDetachedGateway(
1565
1896
  return record;
1566
1897
  }
1567
1898
 
1899
+ function authTokenMatches(
1900
+ candidate: ParsedGatewayDaemonEntry,
1901
+ expectedAuthToken: string | null,
1902
+ ): boolean {
1903
+ if (expectedAuthToken === null) {
1904
+ return candidate.authToken === null;
1905
+ }
1906
+ return candidate.authToken === expectedAuthToken;
1907
+ }
1908
+
1909
+ function findReachableGatewayDaemonCandidates(
1910
+ invocationDirectory: string,
1911
+ settings: ResolvedGatewaySettings,
1912
+ ): readonly ParsedGatewayDaemonEntry[] {
1913
+ return listGatewayDaemonProcesses().filter((candidate) => {
1914
+ if (candidate.host !== settings.host || candidate.port !== settings.port) {
1915
+ return false;
1916
+ }
1917
+ if (!authTokenMatches(candidate, settings.authToken)) {
1918
+ return false;
1919
+ }
1920
+ return isPathWithinWorkspaceRuntimeScope(candidate.stateDbPath, invocationDirectory);
1921
+ });
1922
+ }
1923
+
1924
+ function createAdoptedGatewayRecord(
1925
+ invocationDirectory: string,
1926
+ daemon: ParsedGatewayDaemonEntry,
1927
+ ): GatewayRecord {
1928
+ return {
1929
+ version: GATEWAY_RECORD_VERSION,
1930
+ pid: daemon.pid,
1931
+ host: daemon.host,
1932
+ port: daemon.port,
1933
+ authToken: daemon.authToken,
1934
+ stateDbPath: daemon.stateDbPath,
1935
+ startedAt: new Date().toISOString(),
1936
+ workspaceRoot: invocationDirectory,
1937
+ };
1938
+ }
1939
+
1568
1940
  async function ensureGatewayRunning(
1569
1941
  invocationDirectory: string,
1570
1942
  recordPath: string,
@@ -1598,6 +1970,33 @@ async function ensureGatewayRunning(
1598
1970
  process.env,
1599
1971
  defaultStateDbPath,
1600
1972
  );
1973
+ if (existingRecord === null) {
1974
+ const endpointProbe = await probeGatewayEndpoint(
1975
+ settings.host,
1976
+ settings.port,
1977
+ settings.authToken,
1978
+ );
1979
+ if (endpointProbe.connected) {
1980
+ const candidates = findReachableGatewayDaemonCandidates(invocationDirectory, settings);
1981
+ if (candidates.length === 1) {
1982
+ const adopted = createAdoptedGatewayRecord(invocationDirectory, candidates[0]!);
1983
+ writeGatewayRecord(recordPath, adopted);
1984
+ return {
1985
+ record: adopted,
1986
+ started: false,
1987
+ };
1988
+ }
1989
+ if (candidates.length > 1) {
1990
+ const pidList = candidates.map((candidate) => String(candidate.pid)).join(', ');
1991
+ throw new Error(
1992
+ `gateway endpoint reachable with multiple daemon candidates (${pidList}); stop with \`harness gateway stop --force\` and retry`,
1993
+ );
1994
+ }
1995
+ throw new Error(
1996
+ 'gateway endpoint is reachable but no matching daemon could be adopted; stop with `harness gateway stop --force` and retry',
1997
+ );
1998
+ }
1999
+ }
1601
2000
  const record = await startDetachedGateway(
1602
2001
  invocationDirectory,
1603
2002
  recordPath,
@@ -1749,6 +2148,7 @@ async function runGatewayForeground(
1749
2148
  settings: ResolvedGatewaySettings,
1750
2149
  runtimeArgs: readonly string[] = [],
1751
2150
  ): Promise<number> {
2151
+ const gatewayRunId = randomUUID();
1752
2152
  const existingRecord = readGatewayRecord(recordPath);
1753
2153
  if (existingRecord !== null) {
1754
2154
  const probe = await probeGateway(existingRecord);
@@ -1779,6 +2179,7 @@ async function runGatewayForeground(
1779
2179
  env: {
1780
2180
  ...process.env,
1781
2181
  HARNESS_INVOKE_CWD: invocationDirectory,
2182
+ HARNESS_GATEWAY_RUN_ID: gatewayRunId,
1782
2183
  },
1783
2184
  });
1784
2185
  if (child.pid !== undefined) {
@@ -1791,6 +2192,7 @@ async function runGatewayForeground(
1791
2192
  stateDbPath: settings.stateDbPath,
1792
2193
  startedAt: new Date().toISOString(),
1793
2194
  workspaceRoot: invocationDirectory,
2195
+ gatewayRunId,
1794
2196
  });
1795
2197
  }
1796
2198
 
@@ -1847,37 +2249,48 @@ async function runGatewayCommandEntry(
1847
2249
  command: ParsedGatewayCommand,
1848
2250
  invocationDirectory: string,
1849
2251
  daemonScriptPath: string,
2252
+ lockPath: string,
1850
2253
  recordPath: string,
1851
2254
  logPath: string,
1852
2255
  defaultStateDbPath: string,
1853
2256
  runtimeOptions: RuntimeInspectOptions,
1854
2257
  ): Promise<number> {
2258
+ const withLock = async <T>(operation: () => Promise<T>): Promise<T> => {
2259
+ return await withGatewayControlLock(lockPath, invocationDirectory, operation);
2260
+ };
2261
+
1855
2262
  if (command.type === 'status') {
1856
- const record = readGatewayRecord(recordPath);
1857
- if (record === null) {
1858
- process.stdout.write('gateway status: stopped\n');
2263
+ return await withLock(async () => {
2264
+ const record = readGatewayRecord(recordPath);
2265
+ if (record === null) {
2266
+ process.stdout.write('gateway status: stopped\n');
2267
+ return 0;
2268
+ }
2269
+ const pidRunning = isPidRunning(record.pid);
2270
+ const probe = await probeGateway(record);
2271
+ process.stdout.write(`gateway status: ${probe.connected ? 'running' : 'unreachable'}\n`);
2272
+ process.stdout.write(`record: ${recordPath}\n`);
2273
+ process.stdout.write(`lock: ${lockPath}\n`);
2274
+ process.stdout.write(
2275
+ `pid: ${String(record.pid)} (${pidRunning ? 'running' : 'not-running'})\n`,
2276
+ );
2277
+ process.stdout.write(`host: ${record.host}\n`);
2278
+ process.stdout.write(`port: ${String(record.port)}\n`);
2279
+ process.stdout.write(`auth: ${record.authToken === null ? 'off' : 'on'}\n`);
2280
+ process.stdout.write(`db: ${record.stateDbPath}\n`);
2281
+ process.stdout.write(`startedAt: ${record.startedAt}\n`);
2282
+ if (typeof record.gatewayRunId === 'string' && record.gatewayRunId.length > 0) {
2283
+ process.stdout.write(`runId: ${record.gatewayRunId}\n`);
2284
+ }
2285
+ process.stdout.write(
2286
+ `sessions: total=${String(probe.sessionCount)} live=${String(probe.liveSessionCount)}\n`,
2287
+ );
2288
+ if (!probe.connected) {
2289
+ process.stdout.write(`lastError: ${probe.error ?? 'unknown'}\n`);
2290
+ return 1;
2291
+ }
1859
2292
  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;
2293
+ });
1881
2294
  }
1882
2295
 
1883
2296
  if (command.type === 'stop') {
@@ -1886,26 +2299,32 @@ async function runGatewayCommandEntry(
1886
2299
  timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
1887
2300
  cleanupOrphans: true,
1888
2301
  };
1889
- const stopped = await stopGateway(
1890
- invocationDirectory,
1891
- daemonScriptPath,
1892
- recordPath,
1893
- defaultStateDbPath,
1894
- stopOptions,
2302
+ const stopped = await withLock(
2303
+ async () =>
2304
+ await stopGateway(
2305
+ invocationDirectory,
2306
+ daemonScriptPath,
2307
+ recordPath,
2308
+ defaultStateDbPath,
2309
+ stopOptions,
2310
+ ),
1895
2311
  );
1896
2312
  process.stdout.write(`${stopped.message}\n`);
1897
2313
  return stopped.stopped ? 0 : 1;
1898
2314
  }
1899
2315
 
1900
2316
  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,
2317
+ const ensured = await withLock(
2318
+ async () =>
2319
+ await ensureGatewayRunning(
2320
+ invocationDirectory,
2321
+ recordPath,
2322
+ logPath,
2323
+ daemonScriptPath,
2324
+ defaultStateDbPath,
2325
+ command.startOptions ?? {},
2326
+ runtimeOptions.gatewayRuntimeArgs,
2327
+ ),
1909
2328
  );
1910
2329
  if (ensured.started) {
1911
2330
  process.stdout.write(
@@ -1918,61 +2337,66 @@ async function runGatewayCommandEntry(
1918
2337
  }
1919
2338
  process.stdout.write(`record: ${recordPath}\n`);
1920
2339
  process.stdout.write(`log: ${logPath}\n`);
2340
+ process.stdout.write(`lock: ${lockPath}\n`);
1921
2341
  return 0;
1922
2342
  }
1923
2343
 
1924
2344
  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
- },
2345
+ const stopResult = await withLock(
2346
+ async () =>
2347
+ await stopGateway(invocationDirectory, daemonScriptPath, recordPath, defaultStateDbPath, {
2348
+ force: true,
2349
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2350
+ cleanupOrphans: true,
2351
+ }),
1935
2352
  );
1936
2353
  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,
2354
+ const ensured = await withLock(
2355
+ async () =>
2356
+ await ensureGatewayRunning(
2357
+ invocationDirectory,
2358
+ recordPath,
2359
+ logPath,
2360
+ daemonScriptPath,
2361
+ defaultStateDbPath,
2362
+ command.startOptions ?? {},
2363
+ runtimeOptions.gatewayRuntimeArgs,
2364
+ ),
1945
2365
  );
1946
2366
  process.stdout.write(
1947
2367
  `gateway restarted pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
1948
2368
  );
1949
2369
  process.stdout.write(`record: ${recordPath}\n`);
1950
2370
  process.stdout.write(`log: ${logPath}\n`);
2371
+ process.stdout.write(`lock: ${lockPath}\n`);
1951
2372
  return 0;
1952
2373
  }
1953
2374
 
1954
2375
  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
- );
2376
+ return await withLock(async () => {
2377
+ const existingRecord = readGatewayRecord(recordPath);
2378
+ const settings = resolveGatewaySettings(
2379
+ invocationDirectory,
2380
+ existingRecord,
2381
+ command.startOptions ?? {},
2382
+ process.env,
2383
+ defaultStateDbPath,
2384
+ );
2385
+ process.stdout.write(
2386
+ `gateway foreground run host=${settings.host} port=${String(settings.port)} db=${settings.stateDbPath}\n`,
2387
+ );
2388
+ process.stdout.write(`lock: ${lockPath}\n`);
2389
+ return await runGatewayForeground(
2390
+ daemonScriptPath,
2391
+ invocationDirectory,
2392
+ recordPath,
2393
+ settings,
2394
+ runtimeOptions.gatewayRuntimeArgs,
2395
+ );
2396
+ });
1973
2397
  }
1974
2398
 
1975
- const record = readGatewayRecord(recordPath);
2399
+ const record = await withLock(async () => readGatewayRecord(recordPath));
1976
2400
  if (record === null) {
1977
2401
  throw new Error('gateway not running; start it first');
1978
2402
  }
@@ -1986,6 +2410,7 @@ async function runDefaultClient(
1986
2410
  invocationDirectory: string,
1987
2411
  daemonScriptPath: string,
1988
2412
  muxScriptPath: string,
2413
+ lockPath: string,
1989
2414
  recordPath: string,
1990
2415
  logPath: string,
1991
2416
  defaultStateDbPath: string,
@@ -1993,14 +2418,19 @@ async function runDefaultClient(
1993
2418
  sessionName: string | null,
1994
2419
  runtimeOptions: RuntimeInspectOptions,
1995
2420
  ): Promise<number> {
1996
- const ensured = await ensureGatewayRunning(
2421
+ const ensured = await withGatewayControlLock(
2422
+ lockPath,
1997
2423
  invocationDirectory,
1998
- recordPath,
1999
- logPath,
2000
- daemonScriptPath,
2001
- defaultStateDbPath,
2002
- {},
2003
- runtimeOptions.gatewayRuntimeArgs,
2424
+ async () =>
2425
+ await ensureGatewayRunning(
2426
+ invocationDirectory,
2427
+ recordPath,
2428
+ logPath,
2429
+ daemonScriptPath,
2430
+ defaultStateDbPath,
2431
+ {},
2432
+ runtimeOptions.gatewayRuntimeArgs,
2433
+ ),
2004
2434
  );
2005
2435
  if (ensured.started) {
2006
2436
  process.stdout.write(
@@ -2047,41 +2477,49 @@ async function runProfileRun(
2047
2477
  removeActiveProfileState(sessionPaths.profileStatePath);
2048
2478
  }
2049
2479
 
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(
2480
+ const gateway = await withGatewayControlLock(
2481
+ sessionPaths.lockPath,
2062
2482
  invocationDirectory,
2063
- null,
2064
- {
2065
- port: reservedPort,
2066
- stateDbPath: sessionPaths.defaultStateDbPath,
2067
- },
2068
- process.env,
2069
- sessionPaths.defaultStateDbPath,
2070
- );
2483
+ async () => {
2484
+ const existingRecord = readGatewayRecord(sessionPaths.recordPath);
2485
+ if (existingRecord !== null) {
2486
+ const existingProbe = await probeGateway(existingRecord);
2487
+ if (existingProbe.connected || isPidRunning(existingRecord.pid)) {
2488
+ throw new Error(
2489
+ 'profile command requires the target session gateway to be stopped first',
2490
+ );
2491
+ }
2492
+ removeGatewayRecord(sessionPaths.recordPath);
2493
+ }
2071
2494
 
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
- ],
2495
+ const host = normalizeGatewayHost(process.env.HARNESS_CONTROL_PLANE_HOST);
2496
+ const reservedPort = await reservePort(host);
2497
+ const settings = resolveGatewaySettings(
2498
+ invocationDirectory,
2499
+ null,
2500
+ {
2501
+ port: reservedPort,
2502
+ stateDbPath: sessionPaths.defaultStateDbPath,
2503
+ },
2504
+ process.env,
2505
+ sessionPaths.defaultStateDbPath,
2506
+ );
2507
+
2508
+ return await startDetachedGateway(
2509
+ invocationDirectory,
2510
+ sessionPaths.recordPath,
2511
+ sessionPaths.logPath,
2512
+ settings,
2513
+ daemonScriptPath,
2514
+ [
2515
+ ...runtimeOptions.gatewayRuntimeArgs,
2516
+ ...buildCpuProfileRuntimeArgs({
2517
+ cpuProfileDir: profileDir,
2518
+ cpuProfileName: PROFILE_GATEWAY_FILE_NAME,
2519
+ }),
2520
+ ],
2521
+ );
2522
+ },
2085
2523
  );
2086
2524
 
2087
2525
  let clientExitCode = 1;
@@ -2105,16 +2543,21 @@ async function runProfileRun(
2105
2543
  clientError = error instanceof Error ? error : new Error(String(error));
2106
2544
  }
2107
2545
 
2108
- const stopped = await stopGateway(
2546
+ const stopped = await withGatewayControlLock(
2547
+ sessionPaths.lockPath,
2109
2548
  invocationDirectory,
2110
- daemonScriptPath,
2111
- sessionPaths.recordPath,
2112
- sessionPaths.defaultStateDbPath,
2113
- {
2114
- force: true,
2115
- timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2116
- cleanupOrphans: true,
2117
- },
2549
+ async () =>
2550
+ await stopGateway(
2551
+ invocationDirectory,
2552
+ daemonScriptPath,
2553
+ sessionPaths.recordPath,
2554
+ sessionPaths.defaultStateDbPath,
2555
+ {
2556
+ force: true,
2557
+ timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
2558
+ cleanupOrphans: true,
2559
+ },
2560
+ ),
2118
2561
  );
2119
2562
  process.stdout.write(`${stopped.message}\n`);
2120
2563
  if (!stopped.stopped) {
@@ -2485,7 +2928,7 @@ async function main(): Promise<number> {
2485
2928
  const migration = migrateLegacyHarnessLayout(invocationDirectory, process.env);
2486
2929
  if (migration.migrated) {
2487
2930
  process.stdout.write(
2488
- `[migration] local .harness migrated to global runtime layout (${String(migration.migratedEntries)} entries, configCopied=${String(migration.configCopied)}, secretsCopied=${String(migration.secretsCopied)})\n`,
2931
+ `[migration] local .harness migrated to global runtime layout (${String(migration.migratedEntries)} entries, configCopied=${String(migration.configCopied)}, secretsCopied=${String(migration.secretsCopied)}, legacyRootRemoved=${String(migration.legacyRootRemoved)})\n`,
2489
2932
  );
2490
2933
  }
2491
2934
  loadHarnessSecrets({ cwd: invocationDirectory });
@@ -2519,6 +2962,7 @@ async function main(): Promise<number> {
2519
2962
  command,
2520
2963
  invocationDirectory,
2521
2964
  daemonScriptPath,
2965
+ sessionPaths.lockPath,
2522
2966
  sessionPaths.recordPath,
2523
2967
  sessionPaths.logPath,
2524
2968
  sessionPaths.defaultStateDbPath,
@@ -2577,6 +3021,7 @@ async function main(): Promise<number> {
2577
3021
  invocationDirectory,
2578
3022
  daemonScriptPath,
2579
3023
  muxScriptPath,
3024
+ sessionPaths.lockPath,
2580
3025
  sessionPaths.recordPath,
2581
3026
  sessionPaths.logPath,
2582
3027
  sessionPaths.defaultStateDbPath,