@hua-labs/tap 0.3.1 → 0.4.0

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.
package/dist/cli.mjs CHANGED
@@ -61,7 +61,7 @@ function findRepoRoot(startDir = process.cwd()) {
61
61
  function resolveCommsDir(args, repoRoot) {
62
62
  const idx = args.indexOf("--comms-dir");
63
63
  if (idx !== -1 && args[idx + 1]) {
64
- return path.resolve(args[idx + 1]);
64
+ return path.resolve(normalizeTapPath(args[idx + 1]));
65
65
  }
66
66
  const { config } = resolveConfig({}, repoRoot);
67
67
  return config.commsDir;
@@ -69,8 +69,8 @@ function resolveCommsDir(args, repoRoot) {
69
69
  function createAdapterContext(commsDir, repoRoot) {
70
70
  const { config } = resolveConfig({}, repoRoot);
71
71
  return {
72
- commsDir: path.resolve(commsDir),
73
- repoRoot: path.resolve(repoRoot),
72
+ commsDir: path.resolve(normalizeTapPath(commsDir)),
73
+ repoRoot: path.resolve(normalizeTapPath(repoRoot)),
74
74
  stateDir: config.stateDir,
75
75
  platform: detectPlatform()
76
76
  };
@@ -1446,6 +1446,7 @@ function readArtifactBackup(backupPath) {
1446
1446
  // src/adapters/codex.ts
1447
1447
  var MCP_SELECTOR = "mcp_servers.tap";
1448
1448
  var ENV_SELECTOR = "mcp_servers.tap.env";
1449
+ var SESSION_NEUTRAL_AGENT_NAME = "<set-per-session>";
1449
1450
  var OLD_MCP_SELECTOR = "mcp_servers.tap-comms";
1450
1451
  var OLD_ENV_SELECTOR = "mcp_servers.tap-comms.env";
1451
1452
  function findCodexConfigPath2() {
@@ -1490,9 +1491,26 @@ function writeTomlFile(filePath, content) {
1490
1491
  fs10.writeFileSync(tmp, content, "utf-8");
1491
1492
  fs10.renameSync(tmp, filePath);
1492
1493
  }
1494
+ function buildSessionNeutralCodexSpec(ctx) {
1495
+ const managed = buildManagedMcpServerSpec(ctx);
1496
+ const env = {
1497
+ ...managed.env,
1498
+ TAP_AGENT_NAME: SESSION_NEUTRAL_AGENT_NAME
1499
+ };
1500
+ delete env.TAP_AGENT_ID;
1501
+ return { ...managed, env };
1502
+ }
1503
+ function buildCodexEnvEntries(existingTable, managedEnv) {
1504
+ const preservedEnv = parseTomlAssignments(existingTable ?? "");
1505
+ delete preservedEnv.TAP_AGENT_ID;
1506
+ return {
1507
+ ...preservedEnv,
1508
+ ...managedEnv
1509
+ };
1510
+ }
1493
1511
  function verifyManagedToml(content, ctx, configPath) {
1494
1512
  const checks = [];
1495
- const managed = buildManagedMcpServerSpec(ctx);
1513
+ const managed = buildSessionNeutralCodexSpec(ctx);
1496
1514
  const mainTable = extractTomlTable(content, MCP_SELECTOR);
1497
1515
  const envTable = extractTomlTable(content, ENV_SELECTOR);
1498
1516
  checks.push({
@@ -1529,6 +1547,19 @@ function verifyManagedToml(content, ctx, configPath) {
1529
1547
  message: "Managed tap command/args do not match expected values"
1530
1548
  });
1531
1549
  }
1550
+ if (envTable) {
1551
+ const envValues = parseTomlAssignments(envTable);
1552
+ checks.push({
1553
+ name: "Managed TAP_AGENT_NAME is session-neutral",
1554
+ passed: envValues.TAP_AGENT_NAME === managed.env.TAP_AGENT_NAME,
1555
+ message: `TAP_AGENT_NAME should be "${SESSION_NEUTRAL_AGENT_NAME}"`
1556
+ });
1557
+ checks.push({
1558
+ name: "Managed TAP_AGENT_ID is omitted",
1559
+ passed: typeof envValues.TAP_AGENT_ID !== "string",
1560
+ message: "TAP_AGENT_ID should not be persisted in Codex config"
1561
+ });
1562
+ }
1532
1563
  return checks;
1533
1564
  }
1534
1565
  var codexAdapter = {
@@ -1612,7 +1643,7 @@ var codexAdapter = {
1612
1643
  const configPath = plan.operations[0]?.path ?? findCodexConfigPath2();
1613
1644
  const warnings = [];
1614
1645
  const changedFiles = [];
1615
- const managed = buildManagedMcpServerSpec(ctx, ctx.instanceId);
1646
+ const managed = buildSessionNeutralCodexSpec(ctx);
1616
1647
  warnings.push(...managed.warnings);
1617
1648
  if (managed.issues.length > 0 || !managed.command) {
1618
1649
  return {
@@ -1669,8 +1700,10 @@ var codexAdapter = {
1669
1700
  ENV_SELECTOR,
1670
1701
  renderTomlTable(
1671
1702
  ENV_SELECTOR,
1672
- managed.env,
1673
- extractTomlTable(existingContent, ENV_SELECTOR)
1703
+ buildCodexEnvEntries(
1704
+ extractTomlTable(existingContent, ENV_SELECTOR),
1705
+ managed.env
1706
+ )
1674
1707
  )
1675
1708
  );
1676
1709
  for (const target of getTrustTargets(ctx)) {
@@ -2437,14 +2470,55 @@ function findListeningProcessId(url, platform) {
2437
2470
  // src/engine/bridge-unix-spawn.ts
2438
2471
  import * as fs15 from "fs";
2439
2472
  import { spawn, spawnSync as spawnSync4 } from "child_process";
2440
- function startUnixDetachedProcess(command, args, repoRoot, logPath, env = process.env) {
2473
+ var DEFAULT_UNIX_PLATFORM = process.platform === "darwin" ? "darwin" : "linux";
2474
+ function resolveUnixSpawnCommand(command, args, platform) {
2475
+ if (platform === "linux") {
2476
+ return {
2477
+ command: "nohup",
2478
+ args: [command, ...args]
2479
+ };
2480
+ }
2481
+ return { command, args };
2482
+ }
2483
+ function findListeningPidWithLsof(port) {
2484
+ const result = spawnSync4(
2485
+ "lsof",
2486
+ ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
2487
+ {
2488
+ encoding: "utf-8",
2489
+ windowsHide: true
2490
+ }
2491
+ );
2492
+ if (!result || result.status !== 0) {
2493
+ return null;
2494
+ }
2495
+ const parsedPid = Number.parseInt((result.stdout ?? "").trim(), 10);
2496
+ return Number.isFinite(parsedPid) ? parsedPid : null;
2497
+ }
2498
+ function findListeningPidWithSs(port) {
2499
+ const result = spawnSync4("ss", ["-ltnpH", `sport = :${port}`], {
2500
+ encoding: "utf-8",
2501
+ windowsHide: true
2502
+ });
2503
+ if (!result || result.status !== 0) {
2504
+ return null;
2505
+ }
2506
+ const match = (result.stdout ?? "").match(/\bpid=(\d+)\b/);
2507
+ if (!match) {
2508
+ return null;
2509
+ }
2510
+ const parsedPid = Number.parseInt(match[1], 10);
2511
+ return Number.isFinite(parsedPid) ? parsedPid : null;
2512
+ }
2513
+ function startUnixDetachedProcess(command, args, repoRoot, logPath, env = process.env, platform = DEFAULT_UNIX_PLATFORM) {
2441
2514
  const stderrPath = stderrLogFilePath(logPath);
2442
2515
  let logFd = null;
2443
2516
  let stderrFd = null;
2444
2517
  try {
2445
2518
  logFd = fs15.openSync(logPath, "a");
2446
2519
  stderrFd = fs15.openSync(stderrPath, "a");
2447
- const child = spawn(command, args, {
2520
+ const launch = resolveUnixSpawnCommand(command, args, platform);
2521
+ const child = spawn(launch.command, launch.args, {
2448
2522
  cwd: repoRoot,
2449
2523
  detached: true,
2450
2524
  stdio: ["ignore", logFd, stderrFd],
@@ -2462,13 +2536,15 @@ function startUnixDetachedProcess(command, args, repoRoot, logPath, env = proces
2462
2536
  }
2463
2537
  }
2464
2538
  }
2465
- function startUnixCodexAppServer(command, url, repoRoot, logPath) {
2539
+ function startUnixCodexAppServer(command, url, repoRoot, logPath, platform = DEFAULT_UNIX_PLATFORM) {
2466
2540
  const { command: exe, prefixArgs } = splitResolvedCommand(command);
2467
2541
  return startUnixDetachedProcess(
2468
2542
  exe,
2469
2543
  [...prefixArgs, "app-server", "--listen", url],
2470
2544
  repoRoot,
2471
- logPath
2545
+ logPath,
2546
+ process.env,
2547
+ platform
2472
2548
  );
2473
2549
  }
2474
2550
  function findUnixListeningProcessId(url, platform) {
@@ -2485,23 +2561,17 @@ function findUnixListeningProcessId(url, platform) {
2485
2561
  if (port == null || !Number.isFinite(port)) {
2486
2562
  return null;
2487
2563
  }
2488
- const result = spawnSync4(
2489
- "lsof",
2490
- ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
2491
- {
2492
- encoding: "utf-8",
2493
- windowsHide: true
2564
+ if (platform === "linux") {
2565
+ const ssPid = findListeningPidWithSs(port);
2566
+ if (ssPid != null) {
2567
+ return ssPid;
2494
2568
  }
2495
- );
2496
- if (!result || result.status !== 0) {
2497
- return null;
2498
2569
  }
2499
- const parsedPid = Number.parseInt((result.stdout ?? "").trim(), 10);
2500
- return Number.isFinite(parsedPid) ? parsedPid : null;
2570
+ return findListeningPidWithLsof(port);
2501
2571
  }
2502
2572
 
2503
2573
  // src/engine/bridge-process-control.ts
2504
- import { execSync as execSync2 } from "child_process";
2574
+ import { execSync as execSync2, spawnSync as spawnSync5 } from "child_process";
2505
2575
  function isProcessAlive(pid) {
2506
2576
  try {
2507
2577
  process.kill(pid, 0);
@@ -2510,6 +2580,25 @@ function isProcessAlive(pid) {
2510
2580
  return false;
2511
2581
  }
2512
2582
  }
2583
+ function getUnixProcessGroupId(pid) {
2584
+ const result = spawnSync5("ps", ["-o", "pgid=", "-p", String(pid)], {
2585
+ encoding: "utf-8",
2586
+ windowsHide: true
2587
+ });
2588
+ if (!result || result.status !== 0) {
2589
+ return null;
2590
+ }
2591
+ const parsed = Number.parseInt((result.stdout ?? "").trim(), 10);
2592
+ return Number.isFinite(parsed) ? parsed : null;
2593
+ }
2594
+ function isUnixProcessGroupAlive(processGroupId) {
2595
+ try {
2596
+ process.kill(-processGroupId, 0);
2597
+ return true;
2598
+ } catch {
2599
+ return false;
2600
+ }
2601
+ }
2513
2602
  async function terminateProcess(pid, platform) {
2514
2603
  if (!isProcessAlive(pid)) {
2515
2604
  return false;
@@ -2518,11 +2607,16 @@ async function terminateProcess(pid, platform) {
2518
2607
  if (platform === "win32") {
2519
2608
  execSync2(`taskkill /PID ${pid} /F /T`, { stdio: "pipe" });
2520
2609
  } else {
2521
- process.kill(pid, "SIGTERM");
2610
+ const processGroupId = getUnixProcessGroupId(pid);
2611
+ const signalTarget = processGroupId != null ? -processGroupId : pid;
2612
+ const isTargetAlive = () => processGroupId != null ? isUnixProcessGroupAlive(processGroupId) : isProcessAlive(pid);
2613
+ process.kill(signalTarget, "SIGTERM");
2522
2614
  await delay(2e3);
2523
- if (isProcessAlive(pid)) {
2524
- process.kill(pid, "SIGKILL");
2615
+ if (isTargetAlive()) {
2616
+ process.kill(signalTarget, "SIGKILL");
2617
+ await delay(500);
2525
2618
  }
2619
+ return !isTargetAlive();
2526
2620
  }
2527
2621
  } catch {
2528
2622
  }
@@ -2712,8 +2806,10 @@ function rotateLog(logPath) {
2712
2806
  }
2713
2807
 
2714
2808
  // src/engine/bridge-app-server-health.ts
2809
+ import * as net2 from "net";
2715
2810
  var APP_SERVER_HEALTH_TIMEOUT_MS = 1500;
2716
2811
  var APP_SERVER_HEALTH_RETRY_MS = 250;
2812
+ var APP_SERVER_READYZ_PATH = "/readyz";
2717
2813
  var AUTH_SUBPROTOCOL_PREFIX = "tap-auth-";
2718
2814
  async function checkAppServerHealth(url, timeoutMs = APP_SERVER_HEALTH_TIMEOUT_MS, gatewayToken) {
2719
2815
  const WebSocket = getWebSocketCtor();
@@ -2747,10 +2843,100 @@ async function checkAppServerHealth(url, timeoutMs = APP_SERVER_HEALTH_TIMEOUT_M
2747
2843
  }
2748
2844
  });
2749
2845
  }
2750
- async function waitForAppServerHealth(url, timeoutMs, gatewayToken) {
2846
+ function buildAppServerReadyzUrl(url) {
2847
+ let parsed;
2848
+ try {
2849
+ parsed = new URL(url);
2850
+ } catch {
2851
+ return null;
2852
+ }
2853
+ if (parsed.protocol === "ws:") {
2854
+ parsed.protocol = "http:";
2855
+ } else if (parsed.protocol === "wss:") {
2856
+ parsed.protocol = "https:";
2857
+ } else if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2858
+ return null;
2859
+ }
2860
+ parsed.pathname = APP_SERVER_READYZ_PATH;
2861
+ parsed.search = "";
2862
+ parsed.hash = "";
2863
+ return parsed.toString();
2864
+ }
2865
+ async function checkAppServerReadyz(url, timeoutMs = APP_SERVER_HEALTH_TIMEOUT_MS) {
2866
+ const readyzUrl = buildAppServerReadyzUrl(url);
2867
+ if (!readyzUrl) {
2868
+ return "unsupported";
2869
+ }
2870
+ const controller = new AbortController();
2871
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2872
+ try {
2873
+ const response = await fetch(readyzUrl, {
2874
+ method: "GET",
2875
+ signal: controller.signal,
2876
+ headers: {
2877
+ accept: "application/json"
2878
+ }
2879
+ });
2880
+ if (response.ok) {
2881
+ return "ready";
2882
+ }
2883
+ if (response.status === 400 || response.status === 404 || response.status === 405 || response.status === 426 || response.status === 501) {
2884
+ return "unsupported";
2885
+ }
2886
+ return "not-ready";
2887
+ } catch {
2888
+ return "not-ready";
2889
+ } finally {
2890
+ clearTimeout(timer);
2891
+ }
2892
+ }
2893
+ async function checkTcpPortListening(url, timeoutMs = APP_SERVER_HEALTH_TIMEOUT_MS) {
2894
+ let hostname;
2895
+ let port;
2896
+ try {
2897
+ const parsed = new URL(url.replace(/^ws/, "http"));
2898
+ hostname = parsed.hostname;
2899
+ port = parseInt(parsed.port, 10);
2900
+ } catch {
2901
+ return false;
2902
+ }
2903
+ if (!port || !Number.isFinite(port)) return false;
2904
+ return new Promise((resolve14) => {
2905
+ const socket = net2.createConnection({ host: hostname, port });
2906
+ const timer = setTimeout(() => {
2907
+ socket.destroy();
2908
+ resolve14(false);
2909
+ }, timeoutMs);
2910
+ socket.once("connect", () => {
2911
+ clearTimeout(timer);
2912
+ socket.destroy();
2913
+ resolve14(true);
2914
+ });
2915
+ socket.once("error", () => {
2916
+ clearTimeout(timer);
2917
+ socket.destroy();
2918
+ resolve14(false);
2919
+ });
2920
+ });
2921
+ }
2922
+ async function checkManagedAppServerReady(url, timeoutMs = APP_SERVER_HEALTH_TIMEOUT_MS) {
2923
+ const readyzStatus = await checkAppServerReadyz(url, timeoutMs);
2924
+ if (readyzStatus === "ready") {
2925
+ return true;
2926
+ }
2927
+ if (readyzStatus === "unsupported") {
2928
+ return checkTcpPortListening(url, timeoutMs);
2929
+ }
2930
+ return false;
2931
+ }
2932
+ async function waitForManagedAppServerReady(url, timeoutMs) {
2751
2933
  const deadline = Date.now() + timeoutMs;
2752
2934
  while (Date.now() < deadline) {
2753
- if (await checkAppServerHealth(url, APP_SERVER_HEALTH_TIMEOUT_MS, gatewayToken)) {
2935
+ const remaining = Math.max(
2936
+ 1,
2937
+ Math.min(APP_SERVER_HEALTH_TIMEOUT_MS, deadline - Date.now())
2938
+ );
2939
+ if (await checkManagedAppServerReady(url, remaining)) {
2754
2940
  return true;
2755
2941
  }
2756
2942
  await delay(APP_SERVER_HEALTH_RETRY_MS);
@@ -3023,7 +3209,8 @@ async function createManagedAppServerAuth(options) {
3023
3209
  gatewayArgs,
3024
3210
  options.repoRoot,
3025
3211
  gatewayLogPath,
3026
- gatewayEnv
3212
+ gatewayEnv,
3213
+ options.platform
3027
3214
  );
3028
3215
  } catch (error) {
3029
3216
  removeFileIfExists(tokenPath);
@@ -3198,7 +3385,8 @@ Start it manually:
3198
3385
  resolvedCommand,
3199
3386
  effectiveUrl,
3200
3387
  options.repoRoot,
3201
- logPath
3388
+ logPath,
3389
+ options.platform
3202
3390
  );
3203
3391
  } catch (err) {
3204
3392
  throw new Error(
@@ -3216,7 +3404,7 @@ Start it manually:
3216
3404
  ${manualCommand2}`
3217
3405
  );
3218
3406
  }
3219
- const healthy2 = await waitForAppServerHealth(
3407
+ const healthy2 = await waitForManagedAppServerReady(
3220
3408
  effectiveUrl,
3221
3409
  APP_SERVER_START_TIMEOUT_MS
3222
3410
  );
@@ -3278,7 +3466,8 @@ Start it manually:
3278
3466
  resolvedCommand,
3279
3467
  auth.upstreamUrl,
3280
3468
  options.repoRoot,
3281
- logPath
3469
+ logPath,
3470
+ options.platform
3282
3471
  );
3283
3472
  } catch (err) {
3284
3473
  if (auth.gatewayPid != null) {
@@ -3304,7 +3493,7 @@ Start it manually:
3304
3493
  ${manualCommand}`
3305
3494
  );
3306
3495
  }
3307
- const healthy = await waitForAppServerHealth(
3496
+ const healthy = await waitForManagedAppServerReady(
3308
3497
  auth.upstreamUrl,
3309
3498
  APP_SERVER_START_TIMEOUT_MS
3310
3499
  );
@@ -3330,10 +3519,9 @@ Or start it manually:
3330
3519
  removeFileIfExists(auth.tokenPath);
3331
3520
  throw new Error("Tap auth gateway token is missing after startup.");
3332
3521
  }
3333
- const gatewayHealthy = await waitForAppServerHealth(
3522
+ const gatewayHealthy = await waitForManagedAppServerReady(
3334
3523
  effectiveUrl,
3335
- APP_SERVER_GATEWAY_START_TIMEOUT_MS,
3336
- gatewayToken
3524
+ APP_SERVER_GATEWAY_START_TIMEOUT_MS
3337
3525
  );
3338
3526
  if (!gatewayHealthy) {
3339
3527
  await terminateProcess(pid, options.platform);
@@ -3480,7 +3668,8 @@ async function startBridge(options) {
3480
3668
  [bridgeScript],
3481
3669
  repoRoot,
3482
3670
  logPath,
3483
- bridgeEnv
3671
+ bridgeEnv,
3672
+ options.platform
3484
3673
  );
3485
3674
  if (!bridgePid) {
3486
3675
  throw new Error(`Failed to spawn bridge process for ${instanceId}`);
@@ -3839,6 +4028,7 @@ async function addCommand(args) {
3839
4028
  );
3840
4029
  }
3841
4030
  let bridge = null;
4031
+ let effectivePort = port;
3842
4032
  if (mode === "app-server") {
3843
4033
  const bridgeScript = adapter.resolveBridgeScript?.(ctx);
3844
4034
  if (!bridgeScript) {
@@ -3846,8 +4036,19 @@ async function addCommand(args) {
3846
4036
  warnings.push("Bridge script not found. Run bridge manually.");
3847
4037
  } else {
3848
4038
  const { config: resolvedCfg } = resolveConfig({}, repoRoot);
4039
+ if (effectivePort == null && runtime === "codex") {
4040
+ const currentState = loadState(repoRoot);
4041
+ effectivePort = await findNextAvailableAppServerPort(
4042
+ currentState,
4043
+ resolvedCfg.appServerUrl,
4044
+ 4501,
4045
+ instanceId
4046
+ );
4047
+ log(`Auto-assigned port ${effectivePort} for ${instanceId}`);
4048
+ }
3849
4049
  log(`Starting bridge: ${bridgeScript}`);
3850
4050
  try {
4051
+ const manageAppServer = runtime === "codex";
3851
4052
  bridge = await startBridge({
3852
4053
  instanceId,
3853
4054
  runtime,
@@ -3859,7 +4060,8 @@ async function addCommand(args) {
3859
4060
  runtimeCommand: resolvedCfg.runtimeCommand,
3860
4061
  appServerUrl: resolvedCfg.appServerUrl,
3861
4062
  repoRoot,
3862
- port: port ?? void 0,
4063
+ port: effectivePort ?? void 0,
4064
+ manageAppServer,
3863
4065
  headless
3864
4066
  });
3865
4067
  logSuccess(`Bridge started (PID: ${bridge.pid})`);
@@ -3874,7 +4076,7 @@ async function addCommand(args) {
3874
4076
  instanceId,
3875
4077
  runtime,
3876
4078
  agentName: resolvedAgentName,
3877
- port,
4079
+ port: effectivePort,
3878
4080
  installed: true,
3879
4081
  configPath: probe.configPath ?? "",
3880
4082
  bridgeMode: mode,
@@ -3884,6 +4086,8 @@ async function addCommand(args) {
3884
4086
  lastAppliedHash: result.lastAppliedHash,
3885
4087
  lastVerifiedAt: verify.ok ? (/* @__PURE__ */ new Date()).toISOString() : null,
3886
4088
  bridge,
4089
+ manageAppServer: runtime === "codex",
4090
+ noAuth: false,
3887
4091
  headless,
3888
4092
  warnings: Array.from(/* @__PURE__ */ new Set([...result.warnings, ...verify.warnings]))
3889
4093
  };
@@ -4318,12 +4522,76 @@ async function removeCommand(args) {
4318
4522
  }
4319
4523
 
4320
4524
  // src/commands/bridge.ts
4525
+ import { existsSync as existsSync21, readFileSync as readFileSync17, renameSync as renameSync11, writeFileSync as writeFileSync12 } from "fs";
4321
4526
  import * as path22 from "path";
4322
4527
  function formatAge(seconds) {
4323
4528
  if (seconds < 60) return `${seconds}s ago`;
4324
4529
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
4325
4530
  return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m ago`;
4326
4531
  }
4532
+ var BRIDGE_UP_ACTIVE_HEARTBEAT_WINDOW_MS = 10 * 60 * 1e3;
4533
+ var BRIDGE_UP_ORPHAN_HEARTBEAT_WINDOW_MS = 24 * 60 * 60 * 1e3;
4534
+ var BRIDGE_UP_SIGNING_OFF_HEARTBEAT_WINDOW_MS = 5 * 60 * 1e3;
4535
+ function loadBridgeHeartbeatStore(commsDir) {
4536
+ const heartbeatsPath = path22.join(commsDir, "heartbeats.json");
4537
+ if (!existsSync21(heartbeatsPath)) return {};
4538
+ try {
4539
+ return JSON.parse(readFileSync17(heartbeatsPath, "utf-8"));
4540
+ } catch {
4541
+ return null;
4542
+ }
4543
+ }
4544
+ function saveBridgeHeartbeatStore(commsDir, store) {
4545
+ const heartbeatsPath = path22.join(commsDir, "heartbeats.json");
4546
+ const tmp = `${heartbeatsPath}.tmp.${process.pid}`;
4547
+ writeFileSync12(tmp, JSON.stringify(store, null, 2), "utf-8");
4548
+ renameSync11(tmp, heartbeatsPath);
4549
+ }
4550
+ function parseBridgeHeartbeatAgeMs(record, now) {
4551
+ const raw = record.lastActivity ?? record.timestamp;
4552
+ if (!raw) return Number.POSITIVE_INFINITY;
4553
+ const parsed = new Date(raw).getTime();
4554
+ if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY;
4555
+ return Math.max(0, now - parsed);
4556
+ }
4557
+ function resolveBridgeHeartbeatInstanceId(state, heartbeatId) {
4558
+ if (state.instances[heartbeatId]) return heartbeatId;
4559
+ const hyphenated = heartbeatId.replace(/_/g, "-");
4560
+ if (state.instances[hyphenated]) return hyphenated;
4561
+ const underscored = heartbeatId.replace(/-/g, "_");
4562
+ if (state.instances[underscored]) return underscored;
4563
+ return null;
4564
+ }
4565
+ function pruneStaleHeartbeatsForBridgeUp(state, stateDir, commsDir) {
4566
+ const store = loadBridgeHeartbeatStore(commsDir);
4567
+ if (store === null) {
4568
+ return {
4569
+ removed: 0,
4570
+ warning: "Auto-clean skipped \u2014 heartbeats.json unreadable"
4571
+ };
4572
+ }
4573
+ const now = Date.now();
4574
+ let removed = 0;
4575
+ for (const [heartbeatId, heartbeat] of Object.entries(store)) {
4576
+ const ageMs = parseBridgeHeartbeatAgeMs(heartbeat, now);
4577
+ const instanceId = resolveBridgeHeartbeatInstanceId(state, heartbeatId);
4578
+ const instance = instanceId ? state.instances[instanceId] : null;
4579
+ const bridgeBacked = instance?.bridgeMode === "app-server";
4580
+ const bridgeRunning = bridgeBacked && instanceId ? getBridgeStatus(stateDir, instanceId) === "running" : false;
4581
+ const status = heartbeat.status ?? "active";
4582
+ const staleByStatus = status === "signing-off" && ageMs >= BRIDGE_UP_SIGNING_OFF_HEARTBEAT_WINDOW_MS;
4583
+ const staleByDeadBridge = bridgeBacked && !bridgeRunning && ageMs >= BRIDGE_UP_ACTIVE_HEARTBEAT_WINDOW_MS;
4584
+ const staleByAge = !bridgeRunning && ageMs >= BRIDGE_UP_ORPHAN_HEARTBEAT_WINDOW_MS;
4585
+ if (staleByStatus || staleByDeadBridge || staleByAge) {
4586
+ delete store[heartbeatId];
4587
+ removed += 1;
4588
+ }
4589
+ }
4590
+ if (removed > 0) {
4591
+ saveBridgeHeartbeatStore(commsDir, store);
4592
+ }
4593
+ return { removed };
4594
+ }
4327
4595
  var BRIDGE_HELP = `
4328
4596
  Usage:
4329
4597
  tap bridge <subcommand> [instance] [options]
@@ -4335,6 +4603,7 @@ Subcommands:
4335
4603
  stop Stop all running bridges
4336
4604
  status Show bridge status for all instances
4337
4605
  status <instance> Show bridge status for a specific instance
4606
+ tui <instance> Show the safe Codex TUI attach command for a running bridge
4338
4607
  watch Monitor bridges and auto-restart stuck/stale ones
4339
4608
 
4340
4609
  Options:
@@ -4363,6 +4632,7 @@ Examples:
4363
4632
  npx @hua-labs/tap bridge stop codex
4364
4633
  npx @hua-labs/tap bridge stop
4365
4634
  npx @hua-labs/tap bridge status
4635
+ npx @hua-labs/tap bridge tui codex
4366
4636
  `.trim();
4367
4637
  function formatAppServerState(appServer) {
4368
4638
  const ownership = appServer.managed ? "managed" : "external";
@@ -4382,6 +4652,18 @@ function redactProtectedUrl(url) {
4382
4652
  return url.replace(/[?&]tap_token=[^&]+/g, "");
4383
4653
  }
4384
4654
  }
4655
+ function resolveTuiConnectUrl(appServer) {
4656
+ return appServer.auth?.upstreamUrl ?? appServer.url;
4657
+ }
4658
+ function quoteCliArg(value) {
4659
+ return `"${value.replace(/"/g, '\\"')}"`;
4660
+ }
4661
+ function formatCodexTuiAttachCommand(tuiConnectUrl, cwd) {
4662
+ return `codex --enable tui_app_server --remote ${quoteCliArg(tuiConnectUrl)} --cd ${quoteCliArg(cwd)}`;
4663
+ }
4664
+ function resolveTuiAttachCwd(repoRoot, stateRepoRoot, runtimeThreadCwd, savedThreadCwd) {
4665
+ return runtimeThreadCwd ?? savedThreadCwd ?? stateRepoRoot ?? repoRoot;
4666
+ }
4385
4667
  function loadCurrentBridgeState(stateDir, instanceId, fallback) {
4386
4668
  return loadBridgeState(stateDir, instanceId) ?? fallback ?? null;
4387
4669
  }
@@ -4749,6 +5031,26 @@ async function bridgeStartAll(flags = {}) {
4749
5031
  data: {}
4750
5032
  };
4751
5033
  }
5034
+ const ctx = createAdapterContext(state.commsDir, repoRoot);
5035
+ const warnings = [];
5036
+ let prunedHeartbeats = 0;
5037
+ if (flags["auto-prune-heartbeats"] === true) {
5038
+ const cleanup = pruneStaleHeartbeatsForBridgeUp(
5039
+ state,
5040
+ ctx.stateDir,
5041
+ ctx.commsDir
5042
+ );
5043
+ prunedHeartbeats = cleanup.removed;
5044
+ if (cleanup.warning) {
5045
+ warnings.push(cleanup.warning);
5046
+ log(cleanup.warning);
5047
+ }
5048
+ if (prunedHeartbeats > 0) {
5049
+ log(
5050
+ `Auto-clean: pruned ${prunedHeartbeats} stale heartbeat entr${prunedHeartbeats === 1 ? "y" : "ies"}`
5051
+ );
5052
+ }
5053
+ }
4752
5054
  const instanceIds = Object.keys(state.instances);
4753
5055
  const appServerInstances = instanceIds.filter((id) => {
4754
5056
  const inst = state.instances[id];
@@ -4757,13 +5059,14 @@ async function bridgeStartAll(flags = {}) {
4757
5059
  return adapter.bridgeMode() === "app-server";
4758
5060
  });
4759
5061
  if (appServerInstances.length === 0) {
5062
+ const cleanupSuffix2 = prunedHeartbeats > 0 ? ` Auto-clean pruned ${prunedHeartbeats} stale heartbeat entr${prunedHeartbeats === 1 ? "y" : "ies"}.` : "";
4760
5063
  return {
4761
5064
  ok: true,
4762
5065
  command: "bridge",
4763
5066
  code: "TAP_NO_OP",
4764
- message: "No app-server instances found to start.",
4765
- warnings: [],
4766
- data: {}
5067
+ message: `No app-server instances found to start.${cleanupSuffix2}`,
5068
+ warnings,
5069
+ data: { prunedHeartbeats }
4767
5070
  };
4768
5071
  }
4769
5072
  logHeader("@hua-labs/tap bridge start --all");
@@ -4773,7 +5076,6 @@ async function bridgeStartAll(flags = {}) {
4773
5076
  log("");
4774
5077
  const started = [];
4775
5078
  const failed = [];
4776
- const warnings = [];
4777
5079
  for (const instanceId of appServerInstances) {
4778
5080
  const inst = state.instances[instanceId];
4779
5081
  const storedName = inst?.agentName ?? void 0;
@@ -4783,8 +5085,26 @@ async function bridgeStartAll(flags = {}) {
4783
5085
  warnings.push(msg);
4784
5086
  continue;
4785
5087
  }
5088
+ const stateDir = path22.join(repoRoot, ".tap-comms");
5089
+ const currentBridgeState = loadBridgeState(stateDir, instanceId);
5090
+ const { manageAppServer, noAuth } = inferRestartMode(
5091
+ currentBridgeState,
5092
+ {
5093
+ noServer: flags["no-server"] === true ? true : void 0,
5094
+ noAuth: flags["no-auth"] === true ? true : void 0
5095
+ },
5096
+ {
5097
+ manageAppServer: inst.manageAppServer,
5098
+ noAuth: inst.noAuth
5099
+ }
5100
+ );
5101
+ const mergedFlags = {
5102
+ ...flags,
5103
+ ...manageAppServer === false ? { "no-server": true } : {},
5104
+ ...noAuth === true ? { "no-auth": true } : {}
5105
+ };
4786
5106
  log(`Starting ${instanceId} (agent: ${storedName})...`);
4787
- const result = await bridgeStart(instanceId, storedName, flags);
5107
+ const result = await bridgeStart(instanceId, storedName, mergedFlags);
4788
5108
  if (result.ok) {
4789
5109
  started.push(instanceId);
4790
5110
  logSuccess(`${instanceId} started`);
@@ -4795,13 +5115,14 @@ async function bridgeStartAll(flags = {}) {
4795
5115
  log("");
4796
5116
  }
4797
5117
  const message = started.length > 0 ? `Started ${started.length}/${appServerInstances.length} bridge(s): ${started.join(", ")}` + (failed.length > 0 ? `. Failed: ${failed.join(", ")}` : "") : `No bridges started. Failed: ${failed.join(", ")}`;
5118
+ const cleanupSuffix = prunedHeartbeats > 0 ? ` Auto-clean pruned ${prunedHeartbeats} stale heartbeat entr${prunedHeartbeats === 1 ? "y" : "ies"}.` : "";
4798
5119
  return {
4799
5120
  ok: failed.length === 0 && started.length > 0,
4800
5121
  command: "bridge",
4801
5122
  code: started.length > 0 ? "TAP_BRIDGE_START_OK" : "TAP_BRIDGE_START_FAILED",
4802
- message,
5123
+ message: `${message}${cleanupSuffix}`,
4803
5124
  warnings,
4804
- data: { started, failed }
5125
+ data: { started, failed, prunedHeartbeats }
4805
5126
  };
4806
5127
  }
4807
5128
  async function bridgeStopOne(identifier) {
@@ -5383,6 +5704,122 @@ function bridgeStatusOne(identifier) {
5383
5704
  }
5384
5705
  };
5385
5706
  }
5707
+ function bridgeTuiOne(identifier) {
5708
+ const repoRoot = findRepoRoot();
5709
+ const state = loadState(repoRoot);
5710
+ if (!state) {
5711
+ return {
5712
+ ok: false,
5713
+ command: "bridge",
5714
+ code: "TAP_NOT_INITIALIZED",
5715
+ message: "Not initialized. Run: npx @hua-labs/tap init",
5716
+ warnings: [],
5717
+ data: {}
5718
+ };
5719
+ }
5720
+ const resolved = resolveInstanceId(identifier, state);
5721
+ if (!resolved.ok) {
5722
+ return {
5723
+ ok: false,
5724
+ command: "bridge",
5725
+ code: resolved.code,
5726
+ message: resolved.message,
5727
+ warnings: [],
5728
+ data: {}
5729
+ };
5730
+ }
5731
+ const instanceId = resolved.instanceId;
5732
+ const inst = state.instances[instanceId];
5733
+ if (!inst?.installed) {
5734
+ return {
5735
+ ok: false,
5736
+ command: "bridge",
5737
+ instanceId,
5738
+ code: "TAP_INSTANCE_NOT_FOUND",
5739
+ message: `${instanceId} is not installed.`,
5740
+ warnings: [],
5741
+ data: {}
5742
+ };
5743
+ }
5744
+ if (inst.runtime !== "codex" || inst.bridgeMode !== "app-server") {
5745
+ return {
5746
+ ok: false,
5747
+ command: "bridge",
5748
+ instanceId,
5749
+ runtime: inst.runtime,
5750
+ code: "TAP_INVALID_ARGUMENT",
5751
+ message: `${instanceId} does not support Codex TUI attach. Use a Codex app-server bridge instance.`,
5752
+ warnings: [],
5753
+ data: {}
5754
+ };
5755
+ }
5756
+ const { config: resolvedConfig } = resolveConfig({}, repoRoot);
5757
+ const stateDir = resolvedConfig.stateDir;
5758
+ const status = getBridgeStatus(stateDir, instanceId);
5759
+ if (status !== "running") {
5760
+ return {
5761
+ ok: false,
5762
+ command: "bridge",
5763
+ instanceId,
5764
+ runtime: inst.runtime,
5765
+ code: "TAP_BRIDGE_NOT_RUNNING",
5766
+ message: `${instanceId} bridge is ${status}. Start it first with: npx @hua-labs/tap bridge start ${instanceId}`,
5767
+ warnings: [],
5768
+ data: { status }
5769
+ };
5770
+ }
5771
+ const bridgeState = loadBridgeState(stateDir, instanceId);
5772
+ const appServer = bridgeState?.appServer;
5773
+ const runtimeHeartbeat = loadRuntimeBridgeHeartbeat(bridgeState);
5774
+ const savedThread = loadRuntimeBridgeThreadState(bridgeState);
5775
+ if (!appServer) {
5776
+ return {
5777
+ ok: false,
5778
+ command: "bridge",
5779
+ instanceId,
5780
+ runtime: inst.runtime,
5781
+ code: "TAP_BRIDGE_NOT_RUNNING",
5782
+ message: `${instanceId} app-server state is missing. Restart the bridge first.`,
5783
+ warnings: [],
5784
+ data: { status }
5785
+ };
5786
+ }
5787
+ const tuiConnectUrl = resolveTuiConnectUrl(appServer);
5788
+ const attachCwd = resolveTuiAttachCwd(
5789
+ repoRoot,
5790
+ state.repoRoot,
5791
+ runtimeHeartbeat?.threadCwd,
5792
+ savedThread?.cwd
5793
+ );
5794
+ const attachCommand = formatCodexTuiAttachCommand(tuiConnectUrl, attachCwd);
5795
+ const warnings = appServer.auth != null ? [
5796
+ "Use the upstream TUI URL, not the protected gateway URL. The protected URL is bridge-only."
5797
+ ] : [];
5798
+ logHeader(`@hua-labs/tap bridge tui ${instanceId}`);
5799
+ if (appServer.auth) {
5800
+ log(`Protected: ${redactProtectedUrl(appServer.auth.protectedUrl)}`);
5801
+ log(`Upstream: ${appServer.auth.upstreamUrl}`);
5802
+ }
5803
+ log(`Using: ${tuiConnectUrl}`);
5804
+ log(`Attach: ${attachCommand}`);
5805
+ log("");
5806
+ return {
5807
+ ok: true,
5808
+ command: "bridge",
5809
+ instanceId,
5810
+ runtime: inst.runtime,
5811
+ code: "TAP_BRIDGE_STATUS_OK",
5812
+ message: `${instanceId} TUI attach command ready`,
5813
+ warnings,
5814
+ data: {
5815
+ status,
5816
+ tuiConnectUrl,
5817
+ attachCwd,
5818
+ attachCommand,
5819
+ appServer
5820
+ }
5821
+ };
5822
+ }
5386
5823
  async function bridgeRestart(identifier, flags) {
5387
5824
  const repoRoot = findRepoRoot();
5388
5825
  const state = loadState(repoRoot);
@@ -5582,6 +6019,19 @@ async function bridgeCommand(args) {
5582
6019
  }
5583
6020
  return bridgeStatusAll();
5584
6021
  }
6022
+ case "tui": {
6023
+ if (!identifierArg) {
6024
+ return {
6025
+ ok: false,
6026
+ command: "bridge",
6027
+ code: "TAP_INVALID_ARGUMENT",
6028
+ message: "Missing instance. Usage: npx @hua-labs/tap bridge tui <instance>",
6029
+ warnings: [],
6030
+ data: {}
6031
+ };
6032
+ }
6033
+ return bridgeTuiOne(identifierArg);
6034
+ }
5585
6035
  case "watch": {
5586
6036
  const intervalStr = typeof flags["interval"] === "string" ? flags["interval"] : void 0;
5587
6037
  const interval = intervalStr ? parseInt(intervalStr, 10) : 30;
@@ -5607,7 +6057,7 @@ async function bridgeCommand(args) {
5607
6057
  ok: false,
5608
6058
  command: "bridge",
5609
6059
  code: "TAP_INVALID_ARGUMENT",
5610
- message: `Unknown bridge subcommand: ${subcommand}. Use: start, stop, restart, status`,
6060
+ message: `Unknown bridge subcommand: ${subcommand}. Use: start, stop, restart, status, tui`,
5611
6061
  warnings: [],
5612
6062
  data: {}
5613
6063
  };
@@ -5771,6 +6221,7 @@ Usage:
5771
6221
  Description:
5772
6222
  Start all registered app-server bridge daemons with one command.
5773
6223
  This is the orchestration entrypoint for headless/background TAP operation.
6224
+ tap up auto-prunes stale heartbeat entries before bridge startup.
5774
6225
 
5775
6226
  Examples:
5776
6227
  npx @hua-labs/tap up
@@ -5794,7 +6245,12 @@ async function upCommand(args) {
5794
6245
  process.env.TAP_COLD_START_WARMUP = "true";
5795
6246
  let result;
5796
6247
  try {
5797
- result = await bridgeCommand(["start", "--all", ...args]);
6248
+ result = await bridgeCommand([
6249
+ "start",
6250
+ "--all",
6251
+ "--auto-prune-heartbeats",
6252
+ ...args
6253
+ ]);
5798
6254
  } finally {
5799
6255
  if (previousColdStartWarmup === void 0) {
5800
6256
  delete process.env.TAP_COLD_START_WARMUP;
@@ -5913,10 +6369,10 @@ async function serveCommand(args) {
5913
6369
  let commsDir;
5914
6370
  const commsDirIdx = args.indexOf("--comms-dir");
5915
6371
  if (commsDirIdx !== -1 && args[commsDirIdx + 1]) {
5916
- commsDir = path24.resolve(args[commsDirIdx + 1]);
6372
+ commsDir = path24.resolve(normalizeTapPath(args[commsDirIdx + 1]));
5917
6373
  }
5918
6374
  if (!commsDir && process.env.TAP_COMMS_DIR) {
5919
- commsDir = process.env.TAP_COMMS_DIR;
6375
+ commsDir = path24.resolve(normalizeTapPath(process.env.TAP_COMMS_DIR));
5920
6376
  }
5921
6377
  if (!commsDir) {
5922
6378
  const state = loadState(repoRoot);
@@ -6518,26 +6974,30 @@ async function dashboardCommand(args) {
6518
6974
 
6519
6975
  // src/commands/doctor.ts
6520
6976
  import {
6521
- existsSync as existsSync23,
6977
+ existsSync as existsSync24,
6522
6978
  mkdirSync as mkdirSync13,
6523
6979
  readdirSync as readdirSync6,
6524
- readFileSync as readFileSync18,
6525
- renameSync as renameSync11,
6980
+ readFileSync as readFileSync19,
6981
+ renameSync as renameSync12,
6526
6982
  statSync as statSync3,
6527
6983
  unlinkSync as unlinkSync6,
6528
- writeFileSync as writeFileSync13
6984
+ writeFileSync as writeFileSync14
6529
6985
  } from "fs";
6530
6986
  import { homedir as homedir3 } from "os";
6531
- import { spawnSync as spawnSync5 } from "child_process";
6987
+ import { spawnSync as spawnSync6 } from "child_process";
6532
6988
  import { dirname as dirname15, join as join23, resolve as resolve13 } from "path";
6533
6989
  var PASS = "pass";
6534
6990
  var WARN = "warn";
6535
6991
  var FAIL = "fail";
6992
+ var HEARTBEAT_ACTIVE_WINDOW_MS = 10 * 60 * 1e3;
6993
+ var ORPHAN_HEARTBEAT_WINDOW_MS = 24 * 60 * 60 * 1e3;
6994
+ var SIGNING_OFF_HEARTBEAT_WINDOW_MS = 5 * 60 * 1e3;
6536
6995
  var CODEX_ENV_DRIFT_KEYS = [
6537
6996
  "TAP_COMMS_DIR",
6538
6997
  "TAP_STATE_DIR",
6539
6998
  "TAP_REPO_ROOT"
6540
6999
  ];
7000
+ var CODEX_SESSION_NEUTRAL_NAME = "<set-per-session>";
6541
7001
  function normalizeComparablePath2(value) {
6542
7002
  return resolve13(value).replace(/\\/g, "/").toLowerCase();
6543
7003
  }
@@ -6574,8 +7034,8 @@ function writeTomlAtomically(filePath, content) {
6574
7034
  const dir = dirname15(filePath);
6575
7035
  mkdirSync13(dir, { recursive: true });
6576
7036
  const tmp = `${filePath}.tmp.${process.pid}`;
6577
- writeFileSync13(tmp, content, "utf-8");
6578
- renameSync11(tmp, filePath);
7037
+ writeFileSync14(tmp, content, "utf-8");
7038
+ renameSync12(tmp, filePath);
6579
7039
  }
6580
7040
  function hasInstalledCodexInstance(state) {
6581
7041
  return state ? Object.values(state.instances).some(
@@ -6585,6 +7045,22 @@ function hasInstalledCodexInstance(state) {
6585
7045
  function getCodexTrustTargets(repoRoot) {
6586
7046
  return [...new Set([repoRoot, process.cwd()].map((value) => resolve13(value)))];
6587
7047
  }
7048
+ function buildSessionNeutralCodexEnv(env) {
7049
+ const neutralEnv = {
7050
+ ...env,
7051
+ TAP_AGENT_NAME: CODEX_SESSION_NEUTRAL_NAME
7052
+ };
7053
+ delete neutralEnv.TAP_AGENT_ID;
7054
+ return neutralEnv;
7055
+ }
7056
+ function buildCodexEnvEntries2(existingTable, managedEnv) {
7057
+ const preservedEnv = parseTomlAssignments(existingTable ?? "");
7058
+ delete preservedEnv.TAP_AGENT_ID;
7059
+ return {
7060
+ ...preservedEnv,
7061
+ ...managedEnv
7062
+ };
7063
+ }
6588
7064
  function buildCodexDoctorSpec(repoRoot, commsDir) {
6589
7065
  const state = loadState(repoRoot);
6590
7066
  if (!hasInstalledCodexInstance(state)) {
@@ -6595,7 +7071,10 @@ function buildCodexDoctorSpec(repoRoot, commsDir) {
6595
7071
  return {
6596
7072
  configPath: findCodexConfigPath3(),
6597
7073
  trustTargets: getCodexTrustTargets(repoRoot),
6598
- managed
7074
+ managed: {
7075
+ ...managed,
7076
+ env: buildSessionNeutralCodexEnv(managed.env)
7077
+ }
6599
7078
  };
6600
7079
  }
6601
7080
  function repairCodexConfig(repoRoot, commsDir) {
@@ -6608,7 +7087,7 @@ function repairCodexConfig(repoRoot, commsDir) {
6608
7087
  spec.managed.issues[0] ?? "Unable to resolve the managed tap MCP server for Codex."
6609
7088
  );
6610
7089
  }
6611
- const existingContent = existsSync23(spec.configPath) ? readFileSync18(spec.configPath, "utf-8") : "";
7090
+ const existingContent = existsSync24(spec.configPath) ? readFileSync19(spec.configPath, "utf-8") : "";
6612
7091
  const existingTapEnvTable = extractTomlTable(
6613
7092
  existingContent,
6614
7093
  "mcp_servers.tap.env"
@@ -6626,6 +7105,8 @@ function repairCodexConfig(repoRoot, commsDir) {
6626
7105
  CODEX_ENV_DRIFT_KEYS.map((key) => [key, spec.managed.env[key]])
6627
7106
  )
6628
7107
  };
7108
+ repairedEnv.TAP_AGENT_NAME = spec.managed.env.TAP_AGENT_NAME;
7109
+ delete repairedEnv.TAP_AGENT_ID;
6629
7110
  let nextContent = existingContent;
6630
7111
  if (extractTomlTable(nextContent, "mcp_servers.tap-comms.env")) {
6631
7112
  nextContent = removeTomlTable(nextContent, "mcp_servers.tap-comms.env");
@@ -6650,8 +7131,10 @@ function repairCodexConfig(repoRoot, commsDir) {
6650
7131
  "mcp_servers.tap.env",
6651
7132
  renderTomlTable(
6652
7133
  "mcp_servers.tap.env",
6653
- repairedEnv,
6654
- existingTapEnvTable ?? existingLegacyEnvTable
7134
+ buildCodexEnvEntries2(
7135
+ existingTapEnvTable ?? existingLegacyEnvTable,
7136
+ repairedEnv
7137
+ )
6655
7138
  )
6656
7139
  );
6657
7140
  for (const trustTarget of spec.trustTargets) {
@@ -6670,7 +7153,7 @@ function repairCodexConfig(repoRoot, commsDir) {
6670
7153
  return `Repaired Codex config at ${spec.configPath}. Restart Codex to reload MCP settings.`;
6671
7154
  }
6672
7155
  function countFiles(dir, ext = ".md") {
6673
- if (!existsSync23(dir)) return 0;
7156
+ if (!existsSync24(dir)) return 0;
6674
7157
  try {
6675
7158
  return readdirSync6(dir).filter((f) => f.endsWith(ext)).length;
6676
7159
  } catch {
@@ -6678,7 +7161,7 @@ function countFiles(dir, ext = ".md") {
6678
7161
  }
6679
7162
  }
6680
7163
  function recentFileCount(dir, withinMs) {
6681
- if (!existsSync23(dir)) return 0;
7164
+ if (!existsSync24(dir)) return 0;
6682
7165
  const cutoff = Date.now() - withinMs;
6683
7166
  let count = 0;
6684
7167
  try {
@@ -6693,13 +7176,85 @@ function recentFileCount(dir, withinMs) {
6693
7176
  }
6694
7177
  return count;
6695
7178
  }
7179
+ function loadDoctorHeartbeatStore(commsDir) {
7180
+ const heartbeatsPath = join23(commsDir, "heartbeats.json");
7181
+ if (!existsSync24(heartbeatsPath)) return null;
7182
+ try {
7183
+ return JSON.parse(readFileSync19(heartbeatsPath, "utf-8"));
7184
+ } catch {
7185
+ return null;
7186
+ }
7187
+ }
7188
+ function saveDoctorHeartbeatStore(commsDir, store) {
7189
+ const heartbeatsPath = join23(commsDir, "heartbeats.json");
7190
+ const tmp = `${heartbeatsPath}.tmp.${process.pid}`;
7191
+ writeFileSync14(tmp, JSON.stringify(store, null, 2), "utf-8");
7192
+ renameSync12(tmp, heartbeatsPath);
7193
+ }
7194
+ function parseHeartbeatAgeMs(record, now) {
7195
+ const raw = record.lastActivity ?? record.timestamp;
7196
+ if (!raw) return Number.POSITIVE_INFINITY;
7197
+ const parsed = new Date(raw).getTime();
7198
+ if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY;
7199
+ return Math.max(0, now - parsed);
7200
+ }
7201
+ function resolveHeartbeatInstanceId(state, heartbeatId) {
7202
+ if (!state) return null;
7203
+ if (state.instances[heartbeatId]) return heartbeatId;
7204
+ const hyphenated = heartbeatId.replace(/_/g, "-");
7205
+ if (state.instances[hyphenated]) return hyphenated;
7206
+ const underscored = heartbeatId.replace(/-/g, "_");
7207
+ if (state.instances[underscored]) return underscored;
7208
+ return null;
7209
+ }
7210
+ function collectStaleHeartbeatIds(commsDir, state, stateDir) {
7211
+ const store = loadDoctorHeartbeatStore(commsDir);
7212
+ if (!store) return [];
7213
+ const now = Date.now();
7214
+ const stale = [];
7215
+ for (const [heartbeatId, heartbeat] of Object.entries(store)) {
7216
+ const ageMs = parseHeartbeatAgeMs(heartbeat, now);
7217
+ const instanceId = resolveHeartbeatInstanceId(state, heartbeatId);
7218
+ const instance = instanceId ? state?.instances[instanceId] : null;
7219
+ const bridgeBacked = instance?.bridgeMode === "app-server";
7220
+ const bridgeRunning = bridgeBacked && instanceId ? isBridgeRunning(stateDir, instanceId) : false;
7221
+ const status = heartbeat.status ?? "active";
7222
+ const staleByStatus = status === "signing-off" && ageMs >= SIGNING_OFF_HEARTBEAT_WINDOW_MS;
7223
+ const staleByDeadBridge = bridgeBacked && !bridgeRunning && ageMs >= HEARTBEAT_ACTIVE_WINDOW_MS;
7224
+ const staleByAge = !bridgeRunning && ageMs >= ORPHAN_HEARTBEAT_WINDOW_MS;
7225
+ if (staleByStatus || staleByDeadBridge || staleByAge) {
7226
+ stale.push({
7227
+ id: heartbeatId,
7228
+ label: heartbeat.agent?.trim() || heartbeatId,
7229
+ ageMs
7230
+ });
7231
+ }
7232
+ }
7233
+ return stale;
7234
+ }
7235
+ function pruneHeartbeatIds(commsDir, heartbeatIds) {
7236
+ if (heartbeatIds.length === 0) return 0;
7237
+ const store = loadDoctorHeartbeatStore(commsDir);
7238
+ if (!store) return 0;
7239
+ let removed = 0;
7240
+ for (const heartbeatId of new Set(heartbeatIds)) {
7241
+ if (heartbeatId in store) {
7242
+ delete store[heartbeatId];
7243
+ removed += 1;
7244
+ }
7245
+ }
7246
+ if (removed > 0) {
7247
+ saveDoctorHeartbeatStore(commsDir, store);
7248
+ }
7249
+ return removed;
7250
+ }
6696
7251
  function checkComms(commsDir) {
6697
7252
  const checks = [];
6698
7253
  checks.push({
6699
7254
  name: "comms directory",
6700
- status: existsSync23(commsDir) ? PASS : FAIL,
6701
- message: existsSync23(commsDir) ? commsDir : `Not found: ${commsDir}`,
6702
- fix: existsSync23(commsDir) ? void 0 : () => {
7255
+ status: existsSync24(commsDir) ? PASS : FAIL,
7256
+ message: existsSync24(commsDir) ? commsDir : `Not found: ${commsDir}`,
7257
+ fix: existsSync24(commsDir) ? void 0 : () => {
6703
7258
  mkdirSync13(commsDir, { recursive: true });
6704
7259
  return `Created ${commsDir}`;
6705
7260
  }
@@ -6710,7 +7265,7 @@ function checkComms(commsDir) {
6710
7265
  ["findings", false]
6711
7266
  ]) {
6712
7267
  const dir = join23(commsDir, subdir);
6713
- const exists = existsSync23(dir);
7268
+ const exists = existsSync24(dir);
6714
7269
  checks.push({
6715
7270
  name: `${subdir} directory`,
6716
7271
  status: exists ? PASS : required ? FAIL : WARN,
@@ -6722,14 +7277,14 @@ function checkComms(commsDir) {
6722
7277
  });
6723
7278
  }
6724
7279
  const heartbeats = join23(commsDir, "heartbeats.json");
6725
- if (existsSync23(heartbeats)) {
7280
+ if (existsSync24(heartbeats)) {
6726
7281
  try {
6727
- const store = JSON.parse(readFileSync18(heartbeats, "utf-8"));
7282
+ const store = JSON.parse(readFileSync19(heartbeats, "utf-8"));
6728
7283
  const agents = Object.keys(store);
6729
7284
  const now = Date.now();
6730
7285
  const active = agents.filter((a) => {
6731
7286
  const ts = store[a]?.lastActivity;
6732
- return ts && now - new Date(ts).getTime() < 10 * 60 * 1e3;
7287
+ return ts && now - new Date(ts).getTime() < HEARTBEAT_ACTIVE_WINDOW_MS;
6733
7288
  });
6734
7289
  checks.push({
6735
7290
  name: "heartbeats",
@@ -6752,7 +7307,35 @@ function checkComms(commsDir) {
6752
7307
  }
6753
7308
  return checks;
6754
7309
  }
6755
- function checkInstances(repoRoot, stateDir) {
7310
+ function checkStaleHeartbeats(repoRoot, commsDir, stateDir) {
7311
+ const state = loadState(repoRoot);
7312
+ const stale = collectStaleHeartbeatIds(commsDir, state, stateDir);
7313
+ if (stale.length === 0) {
7314
+ return [
7315
+ {
7316
+ name: "stale heartbeats",
7317
+ status: PASS,
7318
+ message: "none"
7319
+ }
7320
+ ];
7321
+ }
7322
+ const preview = stale.slice(0, 3).map((entry) => `${entry.label} (${Math.round(entry.ageMs / 6e4)}m)`).join(", ");
7323
+ return [
7324
+ {
7325
+ name: "stale heartbeats",
7326
+ status: WARN,
7327
+ message: stale.length > 3 ? `${stale.length} stale entries: ${preview}, ...` : `${stale.length} stale entr${stale.length === 1 ? "y" : "ies"}: ${preview}`,
7328
+ fix: () => {
7329
+ const removed = pruneHeartbeatIds(
7330
+ commsDir,
7331
+ stale.map((entry) => entry.id)
7332
+ );
7333
+ return `Pruned ${removed} stale heartbeat entr${removed === 1 ? "y" : "ies"}`;
7334
+ }
7335
+ }
7336
+ ];
7337
+ }
7338
+ function checkInstances(repoRoot, stateDir, commsDir) {
6756
7339
  const checks = [];
6757
7340
  const state = loadState(repoRoot);
6758
7341
  if (!state) {
@@ -6799,7 +7382,7 @@ function checkInstances(repoRoot, stateDir) {
6799
7382
  if (pid) {
6800
7383
  try {
6801
7384
  if (process.platform === "win32") {
6802
- spawnSync5("taskkill", ["/PID", String(pid), "/F", "/T"], {
7385
+ spawnSync6("taskkill", ["/PID", String(pid), "/F", "/T"], {
6803
7386
  stdio: "pipe"
6804
7387
  });
6805
7388
  } else {
@@ -6821,7 +7404,13 @@ function checkInstances(repoRoot, stateDir) {
6821
7404
  currentState.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6822
7405
  saveState(repoRoot, currentState);
6823
7406
  }
6824
- return `Cleaned stale bridge + managed processes for ${id}`;
7407
+ const removedHeartbeats = pruneHeartbeatIds(commsDir, [
7408
+ id,
7409
+ id.replace(/-/g, "_"),
7410
+ id.replace(/_/g, "-")
7411
+ ]);
7412
+ const suffix = removedHeartbeats > 0 ? `; pruned ${removedHeartbeats} heartbeat entr${removedHeartbeats === 1 ? "y" : "ies"}` : "";
7413
+ return `Cleaned stale bridge + managed processes for ${id}${suffix}`;
6825
7414
  };
6826
7415
  } else {
6827
7416
  status = WARN;
@@ -6867,7 +7456,7 @@ function checkInstances(repoRoot, stateDir) {
6867
7456
  function checkMessageLifecycle(commsDir) {
6868
7457
  const checks = [];
6869
7458
  const inbox = join23(commsDir, "inbox");
6870
- if (!existsSync23(inbox)) {
7459
+ if (!existsSync24(inbox)) {
6871
7460
  checks.push({
6872
7461
  name: "message flow",
6873
7462
  status: FAIL,
@@ -6884,9 +7473,9 @@ function checkMessageLifecycle(commsDir) {
6884
7473
  message: `${total} total, ${recent1h} in last 1h, ${recent10m} in last 10m`
6885
7474
  });
6886
7475
  const receiptsPath = join23(commsDir, "receipts", "receipts.json");
6887
- if (existsSync23(receiptsPath)) {
7476
+ if (existsSync24(receiptsPath)) {
6888
7477
  try {
6889
- const receipts = JSON.parse(readFileSync18(receiptsPath, "utf-8"));
7478
+ const receipts = JSON.parse(readFileSync19(receiptsPath, "utf-8"));
6890
7479
  const receiptCount = Object.keys(receipts).length;
6891
7480
  checks.push({
6892
7481
  name: "read receipts",
@@ -6906,7 +7495,7 @@ function checkMessageLifecycle(commsDir) {
6906
7495
  function checkMcpServer(repoRoot) {
6907
7496
  const checks = [];
6908
7497
  const mcpJson = join23(repoRoot, ".mcp.json");
6909
- if (!existsSync23(mcpJson)) {
7498
+ if (!existsSync24(mcpJson)) {
6910
7499
  checks.push({
6911
7500
  name: "MCP config (.mcp.json)",
6912
7501
  status: WARN,
@@ -6916,7 +7505,7 @@ function checkMcpServer(repoRoot) {
6916
7505
  }
6917
7506
  let config;
6918
7507
  try {
6919
- config = JSON.parse(readFileSync18(mcpJson, "utf-8"));
7508
+ config = JSON.parse(readFileSync19(mcpJson, "utf-8"));
6920
7509
  } catch {
6921
7510
  checks.push({
6922
7511
  name: "MCP config (.mcp.json)",
@@ -6959,10 +7548,10 @@ function checkMcpServer(repoRoot) {
6959
7548
  });
6960
7549
  if (hasTapComms.command) {
6961
7550
  const cmd = hasTapComms.command;
6962
- let cmdAvailable = existsSync23(cmd);
7551
+ let cmdAvailable = existsSync24(cmd);
6963
7552
  if (!cmdAvailable) {
6964
7553
  try {
6965
- const result = spawnSync5(cmd, ["--version"], {
7554
+ const result = spawnSync6(cmd, ["--version"], {
6966
7555
  stdio: "pipe",
6967
7556
  timeout: 5e3,
6968
7557
  shell: process.platform === "win32"
@@ -6981,8 +7570,8 @@ function checkMcpServer(repoRoot) {
6981
7570
  const mcpScript = hasTapComms.args[0];
6982
7571
  checks.push({
6983
7572
  name: "MCP server script",
6984
- status: existsSync23(mcpScript) ? PASS : FAIL,
6985
- message: existsSync23(mcpScript) ? mcpScript : `Not found: ${mcpScript}`
7573
+ status: existsSync24(mcpScript) ? PASS : FAIL,
7574
+ message: existsSync24(mcpScript) ? mcpScript : `Not found: ${mcpScript}`
6986
7575
  });
6987
7576
  if (mcpScript.endsWith(".mjs") && hasTapComms.command && !hasTapComms.command.includes("bun")) {
6988
7577
  checks.push({
@@ -7015,8 +7604,8 @@ function checkMcpServer(repoRoot) {
7015
7604
  } else {
7016
7605
  checks.push({
7017
7606
  name: "MCP TAP_COMMS_DIR",
7018
- status: existsSync23(envCommsDir) ? PASS : FAIL,
7019
- message: existsSync23(envCommsDir) ? envCommsDir : `Directory not found: ${envCommsDir}`
7607
+ status: existsSync24(envCommsDir) ? PASS : FAIL,
7608
+ message: existsSync24(envCommsDir) ? envCommsDir : `Directory not found: ${envCommsDir}`
7020
7609
  });
7021
7610
  }
7022
7611
  checks.push({
@@ -7033,7 +7622,7 @@ function checkCodexConfig(repoRoot, commsDir) {
7033
7622
  }
7034
7623
  const checks = [];
7035
7624
  const fixHint = 'Run "tap doctor --fix" or "tap add codex --force".';
7036
- if (!existsSync23(spec.configPath)) {
7625
+ if (!existsSync24(spec.configPath)) {
7037
7626
  checks.push({
7038
7627
  name: "MCP config (~/.codex/config.toml)",
7039
7628
  status: WARN,
@@ -7042,7 +7631,7 @@ function checkCodexConfig(repoRoot, commsDir) {
7042
7631
  });
7043
7632
  return checks;
7044
7633
  }
7045
- const content = readFileSync18(spec.configPath, "utf-8");
7634
+ const content = readFileSync19(spec.configPath, "utf-8");
7046
7635
  const tapTable = extractTomlTable(content, "mcp_servers.tap");
7047
7636
  const tapEnvTable = extractTomlTable(content, "mcp_servers.tap.env");
7048
7637
  const legacyTable = extractTomlTable(content, "mcp_servers.tap-comms");
@@ -7084,6 +7673,16 @@ function checkCodexConfig(repoRoot, commsDir) {
7084
7673
  issues.push(`${key} drift (${actual})`);
7085
7674
  }
7086
7675
  }
7676
+ const actualAgentName = selectedEnv.TAP_AGENT_NAME;
7677
+ if (typeof actualAgentName !== "string") {
7678
+ issues.push("TAP_AGENT_NAME missing");
7679
+ } else if (actualAgentName !== spec.managed.env.TAP_AGENT_NAME) {
7680
+ issues.push(`non-neutral TAP_AGENT_NAME persisted (${actualAgentName})`);
7681
+ }
7682
+ const actualAgentId = selectedEnv.TAP_AGENT_ID;
7683
+ if (typeof actualAgentId === "string" && actualAgentId.trim()) {
7684
+ issues.push(`concrete TAP_AGENT_ID persisted (${actualAgentId})`);
7685
+ }
7087
7686
  for (const trustTarget of spec.trustTargets) {
7088
7687
  const trustTable = extractTomlTable(content, trustSelector2(trustTarget));
7089
7688
  if (!trustTable || !trustTable.includes('trust_level = "trusted"')) {
@@ -7109,7 +7708,7 @@ function checkCodexConfig(repoRoot, commsDir) {
7109
7708
  function checkBridgeTurnHealth(repoRoot) {
7110
7709
  const checks = [];
7111
7710
  const tmpDir = join23(repoRoot, ".tmp");
7112
- if (!existsSync23(tmpDir)) return checks;
7711
+ if (!existsSync24(tmpDir)) return checks;
7113
7712
  const state = loadState(repoRoot);
7114
7713
  const activeMatchers = /* @__PURE__ */ new Set();
7115
7714
  if (state) {
@@ -7136,10 +7735,10 @@ function checkBridgeTurnHealth(repoRoot) {
7136
7735
  }
7137
7736
  for (const dir of dirs) {
7138
7737
  const heartbeatPath = join23(tmpDir, dir, "heartbeat.json");
7139
- if (!existsSync23(heartbeatPath)) continue;
7738
+ if (!existsSync24(heartbeatPath)) continue;
7140
7739
  let heartbeat;
7141
7740
  try {
7142
- heartbeat = JSON.parse(readFileSync18(heartbeatPath, "utf-8"));
7741
+ heartbeat = JSON.parse(readFileSync19(heartbeatPath, "utf-8"));
7143
7742
  } catch {
7144
7743
  checks.push({
7145
7744
  name: `turn: ${dir}`,
@@ -7199,6 +7798,13 @@ function checkBridgeTurnHealth(repoRoot) {
7199
7798
  });
7200
7799
  continue;
7201
7800
  }
7801
+ if (heartbeat.authenticated === false) {
7802
+ checks.push({
7803
+ name: `turn: ${dir}`,
7804
+ status: WARN,
7805
+ message: "bridge running without auth \u2014 app-server session is unprotected. Use --gateway-token-file to enable auth."
7806
+ });
7807
+ }
7202
7808
  const turnInfo = heartbeat.activeTurnId ? `active turn ${heartbeat.activeTurnId}` : `idle (last: ${heartbeat.lastTurnStatus ?? "none"})`;
7203
7809
  checks.push({
7204
7810
  name: `turn: ${dir}`,
@@ -7267,7 +7873,8 @@ async function doctorCommand(args) {
7267
7873
  function runAllChecks() {
7268
7874
  const checks = [];
7269
7875
  checks.push(...checkComms(commsDir));
7270
- checks.push(...checkInstances(repoRoot, config.stateDir));
7876
+ checks.push(...checkStaleHeartbeats(repoRoot, commsDir, config.stateDir));
7877
+ checks.push(...checkInstances(repoRoot, config.stateDir, commsDir));
7271
7878
  checks.push(...checkMessageLifecycle(commsDir));
7272
7879
  checks.push(...checkMcpServer(repoRoot));
7273
7880
  checks.push(...checkCodexConfig(repoRoot, commsDir));
@@ -7289,7 +7896,8 @@ async function doctorCommand(args) {
7289
7896
  "inbox directory",
7290
7897
  "reviews directory",
7291
7898
  "findings directory",
7292
- "heartbeats"
7899
+ "heartbeats",
7900
+ "stale heartbeats"
7293
7901
  ].includes(c.name)
7294
7902
  ),
7295
7903
  Instances: initialChecks.filter(
@@ -7362,7 +7970,7 @@ async function doctorCommand(args) {
7362
7970
  }
7363
7971
 
7364
7972
  // src/commands/comms.ts
7365
- import { execSync as execSync6, spawnSync as spawnSync6 } from "child_process";
7973
+ import { execSync as execSync6, spawnSync as spawnSync7 } from "child_process";
7366
7974
  import * as fs27 from "fs";
7367
7975
  import * as path26 from "path";
7368
7976
  var COMMS_HELP = `
@@ -7454,7 +8062,7 @@ function commsPush(commsDir) {
7454
8062
  };
7455
8063
  }
7456
8064
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7457
- const commitResult = spawnSync6(
8065
+ const commitResult = spawnSync7(
7458
8066
  "git",
7459
8067
  ["commit", "-m", `chore(comms): sync ${timestamp}`],
7460
8068
  { cwd: commsDir, stdio: "pipe", encoding: "utf-8" }
@@ -7680,10 +8288,10 @@ function parseMissionsFile(repoRoot) {
7680
8288
  }
7681
8289
 
7682
8290
  // src/engine/pull-requests.ts
7683
- import { spawnSync as spawnSync7 } from "child_process";
8291
+ import { spawnSync as spawnSync8 } from "child_process";
7684
8292
  function runGhPrList(repoRoot, extraArgs) {
7685
8293
  try {
7686
- const result = spawnSync7(
8294
+ const result = spawnSync8(
7687
8295
  "gh",
7688
8296
  [
7689
8297
  "pr",