@hua-labs/tap 0.2.1 → 0.2.3

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
@@ -219,7 +219,8 @@ function resolveConfig(overrides = {}, startDir) {
219
219
  commsDir: "auto",
220
220
  stateDir: "auto",
221
221
  runtimeCommand: "auto",
222
- appServerUrl: "auto"
222
+ appServerUrl: "auto",
223
+ towerName: "auto"
223
224
  };
224
225
  let commsDir;
225
226
  if (overrides.commsDir) {
@@ -288,8 +289,16 @@ function resolveConfig(overrides = {}, startDir) {
288
289
  } else {
289
290
  appServerUrl = DEFAULT_APP_SERVER_URL;
290
291
  }
292
+ const towerName = local.towerName ?? shared.towerName ?? null;
291
293
  return {
292
- config: { repoRoot, commsDir, stateDir, runtimeCommand, appServerUrl },
294
+ config: {
295
+ repoRoot,
296
+ commsDir,
297
+ stateDir,
298
+ runtimeCommand,
299
+ appServerUrl,
300
+ towerName
301
+ },
293
302
  sources
294
303
  };
295
304
  }
@@ -935,6 +944,10 @@ function canWriteOrCreate(filePath) {
935
944
  return false;
936
945
  }
937
946
  }
947
+ function isEphemeralPath(p) {
948
+ const normalized = p.replace(/\\/g, "/").toLowerCase();
949
+ return normalized.includes("/_npx/") || normalized.includes("\\_npx\\") || normalized.includes("/fnm_multishells/") || normalized.includes("\\fnm_multishells\\") || normalized.includes("/tmp/") || normalized.includes("\\temp\\");
950
+ }
938
951
  function findLocalTapCommsSource(ctx) {
939
952
  const candidates = [
940
953
  path7.join(
@@ -994,8 +1007,10 @@ function buildManagedMcpServerSpec(ctx, instanceId) {
994
1007
  const warnings = [];
995
1008
  const issues = [];
996
1009
  const env = {
997
- TAP_AGENT_NAME: "<set-per-session>",
998
- TAP_COMMS_DIR: toForwardSlashPath(ctx.commsDir)
1010
+ TAP_AGENT_NAME: ctx.agentName ?? "<set-per-session>",
1011
+ TAP_COMMS_DIR: toForwardSlashPath(ctx.commsDir),
1012
+ TAP_STATE_DIR: toForwardSlashPath(ctx.stateDir),
1013
+ TAP_REPO_ROOT: toForwardSlashPath(ctx.repoRoot)
999
1014
  };
1000
1015
  if (instanceId) {
1001
1016
  env.TAP_AGENT_ID = instanceId;
@@ -1007,9 +1022,25 @@ function buildManagedMcpServerSpec(ctx, instanceId) {
1007
1022
  return { command: null, args: [], env, sourcePath, warnings, issues };
1008
1023
  }
1009
1024
  const isBundled = sourcePath.endsWith(".mjs");
1025
+ const isEphemeralSource = isEphemeralPath(sourcePath);
1010
1026
  let command = bunCommand;
1011
- if (!command && isBundled) {
1012
- command = process.execPath;
1027
+ let args = [toForwardSlashPath(sourcePath)];
1028
+ if (isEphemeralSource && isBundled) {
1029
+ command = "npx";
1030
+ args = ["@hua-labs/tap", "serve"];
1031
+ warnings.push(
1032
+ "Detected npx cache path. Using `npx @hua-labs/tap serve` as stable MCP launcher."
1033
+ );
1034
+ } else if (!command && isBundled) {
1035
+ const isEphemeralNode = isEphemeralPath(process.execPath);
1036
+ if (isEphemeralNode) {
1037
+ command = "node";
1038
+ warnings.push(
1039
+ "Detected ephemeral node path. Using `node` from PATH for MCP config stability."
1040
+ );
1041
+ } else {
1042
+ command = toForwardSlashPath(process.execPath);
1043
+ }
1013
1044
  warnings.push(
1014
1045
  "bun not found; using node to run the compiled MCP server. Install bun for better performance."
1015
1046
  );
@@ -1021,8 +1052,8 @@ function buildManagedMcpServerSpec(ctx, instanceId) {
1021
1052
  return { command: null, args: [], env, sourcePath, warnings, issues };
1022
1053
  }
1023
1054
  return {
1024
- command: isBundled && command === process.execPath ? toForwardSlashPath(command) : command,
1025
- args: [toForwardSlashPath(sourcePath)],
1055
+ command,
1056
+ args,
1026
1057
  env,
1027
1058
  sourcePath,
1028
1059
  warnings,
@@ -1386,13 +1417,12 @@ function verifyManagedToml(content, ctx, configPath) {
1386
1417
  });
1387
1418
  }
1388
1419
  if (mainTable && managed.command) {
1420
+ const expectedArgs = managed.args.map((a) => `"${a.replace(/\\/g, "\\\\")}"`).join(", ");
1389
1421
  checks.push({
1390
1422
  name: "Managed command configured",
1391
1423
  passed: mainTable.includes(
1392
1424
  `command = "${managed.command.replace(/\\/g, "\\\\")}"`
1393
- ) && mainTable.includes(
1394
- `args = ["${managed.args[0]?.replace(/\\/g, "\\\\") ?? ""}"]`
1395
- ),
1425
+ ) && mainTable.includes(`args = [${expectedArgs}]`),
1396
1426
  message: "Managed tap-comms command/args do not match expected values"
1397
1427
  });
1398
1428
  }
@@ -2879,6 +2909,40 @@ function isBridgeRunning(stateDir, instanceId) {
2879
2909
  if (!state) return false;
2880
2910
  return isProcessAlive(state.pid);
2881
2911
  }
2912
+ function resolveAgentName(instanceId, explicit, context) {
2913
+ if (explicit) return explicit;
2914
+ try {
2915
+ const repoRoot = context?.repoRoot ?? context?.stateDir?.replace(/[\\/].tap-comms$/, "") ?? process.cwd();
2916
+ const state = loadState(repoRoot);
2917
+ const stateAgent = state?.instances[instanceId]?.agentName;
2918
+ if (stateAgent) return stateAgent;
2919
+ } catch {
2920
+ }
2921
+ return process.env.TAP_AGENT_NAME || process.env.CODEX_TAP_AGENT_NAME || null;
2922
+ }
2923
+ function inferRestartMode(bridgeState, flags, savedMode) {
2924
+ const wasManaged = bridgeState?.appServer != null;
2925
+ const hadAuth = bridgeState?.appServer?.auth != null;
2926
+ const manageAppServer = flags?.noServer === true ? false : flags?.noServer === void 0 ? savedMode?.manageAppServer ?? wasManaged : true;
2927
+ const noAuth = flags?.noAuth === true ? true : flags?.noAuth === void 0 ? savedMode?.noAuth ?? !hadAuth : false;
2928
+ return { manageAppServer, noAuth };
2929
+ }
2930
+ function cleanupHeadlessDispatch(inboxDir, agentName) {
2931
+ const removed = [];
2932
+ if (!fs13.existsSync(inboxDir)) return removed;
2933
+ const normalizedAgent = agentName.replace(/-/g, "_");
2934
+ const marker = `-headless-${normalizedAgent}-review-`;
2935
+ try {
2936
+ for (const file of fs13.readdirSync(inboxDir)) {
2937
+ if (file.includes(marker)) {
2938
+ fs13.unlinkSync(path13.join(inboxDir, file));
2939
+ removed.push(file);
2940
+ }
2941
+ }
2942
+ } catch {
2943
+ }
2944
+ return removed;
2945
+ }
2882
2946
  async function startBridge(options) {
2883
2947
  const {
2884
2948
  instanceId,
@@ -2889,7 +2953,10 @@ async function startBridge(options) {
2889
2953
  agentName,
2890
2954
  port
2891
2955
  } = options;
2892
- const resolvedAgent = agentName || process.env.TAP_AGENT_NAME || process.env.CODEX_TAP_AGENT_NAME;
2956
+ const resolvedAgent = resolveAgentName(instanceId, agentName, {
2957
+ repoRoot: options.repoRoot,
2958
+ stateDir
2959
+ });
2893
2960
  if (!resolvedAgent) {
2894
2961
  throw new Error(
2895
2962
  `No agent name for ${instanceId} bridge. Set TAP_AGENT_NAME env var or pass --agent-name flag.`
@@ -3041,6 +3108,35 @@ async function stopBridge(options) {
3041
3108
  clearBridgeState(stateDir, instanceId);
3042
3109
  return true;
3043
3110
  }
3111
+ async function restartBridge(options) {
3112
+ const { instanceId, stateDir, platform } = options;
3113
+ const drainTimeout = (options.drainTimeoutSeconds ?? 30) * 1e3;
3114
+ const repoRoot = options.repoRoot ?? stateDir.replace(/[\\/].tap-comms$/, "");
3115
+ const runtimeStateDir = getBridgeRuntimeStateDir(repoRoot, instanceId);
3116
+ const heartbeatPath = path13.join(runtimeStateDir, "heartbeat.json");
3117
+ if (fs13.existsSync(heartbeatPath)) {
3118
+ const startWait = Date.now();
3119
+ while (Date.now() - startWait < drainTimeout) {
3120
+ try {
3121
+ const hb = JSON.parse(fs13.readFileSync(heartbeatPath, "utf-8"));
3122
+ if (!hb.activeTurnId) break;
3123
+ } catch {
3124
+ break;
3125
+ }
3126
+ await new Promise((r) => setTimeout(r, 1e3));
3127
+ }
3128
+ }
3129
+ if (options.headless?.enabled && options.commsDir) {
3130
+ const agentName = options.agentName ?? instanceId;
3131
+ cleanupHeadlessDispatch(path13.join(options.commsDir, "inbox"), agentName);
3132
+ }
3133
+ await stopBridge({ instanceId, stateDir, platform });
3134
+ const restartOptions = {
3135
+ ...options,
3136
+ processExistingMessages: true
3137
+ };
3138
+ return startBridge(restartOptions);
3139
+ }
3044
3140
  function rotateLog(logPath) {
3045
3141
  if (!fs13.existsSync(logPath)) return;
3046
3142
  try {
@@ -3192,7 +3288,13 @@ async function addCommand(args) {
3192
3288
  logHeader(`@hua-labs/tap add ${instanceId}`);
3193
3289
  if (instanceName) log(`Instance name: ${instanceName}`);
3194
3290
  if (port !== null) log(`Port: ${port}`);
3195
- const ctx = { ...createAdapterContext(state.commsDir, repoRoot), instanceId };
3291
+ const existingAgentName = state.instances[instanceId]?.agentName ?? null;
3292
+ const effectiveAgentName = agentNameFlag ?? existingAgentName ?? void 0;
3293
+ const ctx = {
3294
+ ...createAdapterContext(state.commsDir, repoRoot),
3295
+ instanceId,
3296
+ agentName: effectiveAgentName
3297
+ };
3196
3298
  const adapter = getAdapter(runtime);
3197
3299
  const warnings = [];
3198
3300
  log("Probing runtime...");
@@ -3280,14 +3382,8 @@ async function addCommand(args) {
3280
3382
  logWarn("Bridge script not found. Bridge not started.");
3281
3383
  warnings.push("Bridge script not found. Run bridge manually.");
3282
3384
  } else {
3283
- const resolvedAgentName = agentNameFlag || process.env.TAP_AGENT_NAME || process.env.CODEX_TAP_AGENT_NAME;
3284
- if (!resolvedAgentName) {
3285
- logWarn(
3286
- "No agent name set. Bridge not started. Use: npx @hua-labs/tap bridge start <instance> --agent-name <name>"
3287
- );
3288
- warnings.push("Bridge not auto-started: no agent name available.");
3289
- } else {
3290
- const { config: resolvedCfg } = resolveConfig({}, repoRoot);
3385
+ const { config: resolvedCfg } = resolveConfig({}, repoRoot);
3386
+ {
3291
3387
  log(`Starting bridge: ${bridgeScript}`);
3292
3388
  try {
3293
3389
  bridge = await startBridge({
@@ -3297,7 +3393,7 @@ async function addCommand(args) {
3297
3393
  commsDir: ctx.commsDir,
3298
3394
  bridgeScript,
3299
3395
  platform: ctx.platform,
3300
- agentName: resolvedAgentName,
3396
+ agentName: agentNameFlag ?? void 0,
3301
3397
  runtimeCommand: resolvedCfg.runtimeCommand,
3302
3398
  appServerUrl: resolvedCfg.appServerUrl,
3303
3399
  repoRoot,
@@ -3313,7 +3409,6 @@ async function addCommand(args) {
3313
3409
  }
3314
3410
  }
3315
3411
  }
3316
- const existingAgentName = state.instances[instanceId]?.agentName ?? null;
3317
3412
  const instanceState = {
3318
3413
  instanceId,
3319
3414
  runtime,
@@ -4043,7 +4138,7 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4043
4138
  log(`TUI connect: ${bridge.appServer.url}`);
4044
4139
  }
4045
4140
  }
4046
- const updated = { ...instance, bridge };
4141
+ const updated = { ...instance, bridge, manageAppServer, noAuth };
4047
4142
  const newState = updateInstanceState(state, instanceId, updated);
4048
4143
  saveState(repoRoot, newState);
4049
4144
  return {
@@ -4534,6 +4629,121 @@ function bridgeStatusOne(identifier) {
4534
4629
  }
4535
4630
  };
4536
4631
  }
4632
+ async function bridgeRestart(identifier, flags) {
4633
+ const repoRoot = findRepoRoot();
4634
+ const state = loadState(repoRoot);
4635
+ if (!state) {
4636
+ return {
4637
+ ok: false,
4638
+ command: "bridge",
4639
+ code: "TAP_NOT_INITIALIZED",
4640
+ message: "Not initialized. Run: npx @hua-labs/tap init",
4641
+ warnings: [],
4642
+ data: {}
4643
+ };
4644
+ }
4645
+ const resolved = resolveInstanceId(identifier, state);
4646
+ if (!resolved.ok) {
4647
+ return {
4648
+ ok: false,
4649
+ command: "bridge",
4650
+ code: resolved.code,
4651
+ message: resolved.message,
4652
+ warnings: [],
4653
+ data: {}
4654
+ };
4655
+ }
4656
+ const instanceId = resolved.instanceId;
4657
+ const inst = state.instances[instanceId];
4658
+ if (!inst) {
4659
+ return {
4660
+ ok: false,
4661
+ command: "bridge",
4662
+ code: "TAP_INSTANCE_NOT_FOUND",
4663
+ message: `Instance not found: ${instanceId}`,
4664
+ warnings: [],
4665
+ data: {}
4666
+ };
4667
+ }
4668
+ const adapter = getAdapter(inst.runtime);
4669
+ const ctx = {
4670
+ ...createAdapterContext(state.commsDir, repoRoot),
4671
+ instanceId
4672
+ };
4673
+ const bridgeScript = adapter.resolveBridgeScript?.(ctx);
4674
+ if (!bridgeScript) {
4675
+ return {
4676
+ ok: false,
4677
+ command: "bridge",
4678
+ instanceId,
4679
+ code: "TAP_BRIDGE_SCRIPT_MISSING",
4680
+ message: `Bridge script not found for ${instanceId}`,
4681
+ warnings: [],
4682
+ data: {}
4683
+ };
4684
+ }
4685
+ const { config: resolvedConfig } = resolveConfig({}, repoRoot);
4686
+ const drainStr = typeof flags["drain-timeout"] === "string" ? flags["drain-timeout"] : "30";
4687
+ const drainTimeout = parseInt(drainStr, 10) || 30;
4688
+ logHeader(`@hua-labs/tap bridge restart ${instanceId}`);
4689
+ log(`Drain timeout: ${drainTimeout}s`);
4690
+ try {
4691
+ const currentBridgeState = loadBridgeState(ctx.stateDir, instanceId);
4692
+ const { manageAppServer, noAuth } = inferRestartMode(
4693
+ currentBridgeState,
4694
+ {
4695
+ noServer: flags["no-server"] === true ? true : void 0,
4696
+ noAuth: flags["no-auth"] === true ? true : void 0
4697
+ },
4698
+ {
4699
+ manageAppServer: inst.manageAppServer,
4700
+ noAuth: inst.noAuth
4701
+ }
4702
+ );
4703
+ const bridge = await restartBridge({
4704
+ instanceId,
4705
+ runtime: inst.runtime,
4706
+ stateDir: ctx.stateDir,
4707
+ commsDir: ctx.commsDir,
4708
+ bridgeScript,
4709
+ platform: ctx.platform,
4710
+ agentName: inst.agentName ?? void 0,
4711
+ runtimeCommand: resolvedConfig.runtimeCommand,
4712
+ appServerUrl: resolvedConfig.appServerUrl,
4713
+ repoRoot,
4714
+ port: inst.port ?? void 0,
4715
+ headless: inst.headless,
4716
+ drainTimeoutSeconds: drainTimeout,
4717
+ manageAppServer,
4718
+ noAuth
4719
+ });
4720
+ logSuccess(`Bridge restarted (PID: ${bridge.pid})`);
4721
+ const updated = { ...inst, bridge, manageAppServer, noAuth };
4722
+ const newState = updateInstanceState(state, instanceId, updated);
4723
+ saveState(repoRoot, newState);
4724
+ return {
4725
+ ok: true,
4726
+ command: "bridge",
4727
+ instanceId,
4728
+ code: "TAP_BRIDGE_START_OK",
4729
+ message: `Bridge for ${instanceId} restarted (PID: ${bridge.pid})`,
4730
+ warnings: [],
4731
+ data: { pid: bridge.pid }
4732
+ };
4733
+ } catch (err) {
4734
+ const msg = err instanceof Error ? err.message : String(err);
4735
+ logError(msg);
4736
+ return {
4737
+ ok: false,
4738
+ command: "bridge",
4739
+ instanceId,
4740
+ code: "TAP_BRIDGE_START_FAILED",
4741
+ message: msg,
4742
+ warnings: [],
4743
+ data: {}
4744
+ };
4745
+ }
4746
+ }
4537
4747
  async function bridgeCommand(args) {
4538
4748
  const { positional, flags } = parseArgs(args);
4539
4749
  const subcommand = positional[0];
@@ -4593,12 +4803,25 @@ async function bridgeCommand(args) {
4593
4803
  }
4594
4804
  return bridgeStatusAll();
4595
4805
  }
4806
+ case "restart": {
4807
+ if (!identifierArg) {
4808
+ return {
4809
+ ok: false,
4810
+ command: "bridge",
4811
+ code: "TAP_INVALID_ARGUMENT",
4812
+ message: "Missing instance. Usage: npx @hua-labs/tap bridge restart <instance>",
4813
+ warnings: [],
4814
+ data: {}
4815
+ };
4816
+ }
4817
+ return bridgeRestart(identifierArg, flags);
4818
+ }
4596
4819
  default:
4597
4820
  return {
4598
4821
  ok: false,
4599
4822
  command: "bridge",
4600
4823
  code: "TAP_INVALID_ARGUMENT",
4601
- message: `Unknown bridge subcommand: ${subcommand}. Use: start, stop, status`,
4824
+ message: `Unknown bridge subcommand: ${subcommand}. Use: start, stop, restart, status`,
4602
4825
  warnings: [],
4603
4826
  data: {}
4604
4827
  };
@@ -4900,7 +5123,9 @@ async function serveCommand(args) {
4900
5123
  data: {}
4901
5124
  };
4902
5125
  }
4903
- const child = spawn2(managed.command, managed.args, {
5126
+ const serveCommand2 = managed.command === "npx" ? "node" : managed.command;
5127
+ const serveArgs = managed.command === "npx" && managed.sourcePath ? [managed.sourcePath] : managed.args;
5128
+ const child = spawn2(serveCommand2, serveArgs, {
4904
5129
  stdio: "inherit",
4905
5130
  env: {
4906
5131
  ...process.env,
@@ -5446,6 +5671,7 @@ import {
5446
5671
  statSync as statSync2,
5447
5672
  unlinkSync as unlinkSync3
5448
5673
  } from "fs";
5674
+ import { execSync as execSync7 } from "child_process";
5449
5675
  import { join as join17 } from "path";
5450
5676
  var PASS = "pass";
5451
5677
  var WARN = "warn";
@@ -5674,35 +5900,205 @@ function checkMessageLifecycle(commsDir) {
5674
5900
  function checkMcpServer(repoRoot) {
5675
5901
  const checks = [];
5676
5902
  const mcpJson = join17(repoRoot, ".mcp.json");
5677
- if (existsSync16(mcpJson)) {
5903
+ if (!existsSync16(mcpJson)) {
5904
+ checks.push({
5905
+ name: "MCP config (.mcp.json)",
5906
+ status: WARN,
5907
+ message: "Not found \u2014 MCP channel notifications won't work"
5908
+ });
5909
+ return checks;
5910
+ }
5911
+ let config;
5912
+ try {
5913
+ config = JSON.parse(readFileSync14(mcpJson, "utf-8"));
5914
+ } catch {
5915
+ checks.push({
5916
+ name: "MCP config (.mcp.json)",
5917
+ status: WARN,
5918
+ message: "File exists but invalid JSON"
5919
+ });
5920
+ return checks;
5921
+ }
5922
+ const hasTapComms = config?.mcpServers?.["tap-comms"];
5923
+ if (!hasTapComms) {
5924
+ checks.push({
5925
+ name: "MCP config (.mcp.json)",
5926
+ status: WARN,
5927
+ message: "tap-comms not configured"
5928
+ });
5929
+ return checks;
5930
+ }
5931
+ checks.push({
5932
+ name: "MCP config (.mcp.json)",
5933
+ status: PASS,
5934
+ message: `command: ${hasTapComms.command}`
5935
+ });
5936
+ if (hasTapComms.command) {
5937
+ const cmd = hasTapComms.command;
5938
+ let cmdAvailable = existsSync16(cmd);
5939
+ if (!cmdAvailable) {
5940
+ try {
5941
+ execSync7(`"${cmd}" --version`, {
5942
+ stdio: "pipe",
5943
+ timeout: 5e3
5944
+ });
5945
+ cmdAvailable = true;
5946
+ } catch {
5947
+ }
5948
+ }
5949
+ checks.push({
5950
+ name: "MCP command binary",
5951
+ status: cmdAvailable ? PASS : FAIL,
5952
+ message: cmdAvailable ? cmd : `Not found: ${cmd} (checked PATH and absolute)`
5953
+ });
5954
+ }
5955
+ if (hasTapComms.args?.[0]) {
5956
+ const mcpScript = hasTapComms.args[0];
5957
+ checks.push({
5958
+ name: "MCP server script",
5959
+ status: existsSync16(mcpScript) ? PASS : FAIL,
5960
+ message: existsSync16(mcpScript) ? mcpScript : `Not found: ${mcpScript}`
5961
+ });
5962
+ if (mcpScript.endsWith(".mjs") && hasTapComms.command && !hasTapComms.command.includes("bun")) {
5963
+ checks.push({
5964
+ name: "MCP SQLite support",
5965
+ status: WARN,
5966
+ message: "Node + .mjs = no SQLite (bun:sqlite unavailable). Use bun or .ts source for full features."
5967
+ });
5968
+ }
5969
+ }
5970
+ if (!hasTapComms.cwd) {
5971
+ checks.push({
5972
+ name: "MCP cwd field",
5973
+ status: WARN,
5974
+ message: "No cwd in .mcp.json \u2014 worktree sessions may fail to resolve MCP server dependencies"
5975
+ });
5976
+ } else {
5977
+ checks.push({
5978
+ name: "MCP cwd field",
5979
+ status: PASS,
5980
+ message: hasTapComms.cwd
5981
+ });
5982
+ }
5983
+ const envCommsDir = hasTapComms.env?.TAP_COMMS_DIR;
5984
+ if (!envCommsDir) {
5985
+ checks.push({
5986
+ name: "MCP TAP_COMMS_DIR",
5987
+ status: FAIL,
5988
+ message: "TAP_COMMS_DIR not set in .mcp.json env \u2014 server will fail to start"
5989
+ });
5990
+ } else {
5991
+ checks.push({
5992
+ name: "MCP TAP_COMMS_DIR",
5993
+ status: existsSync16(envCommsDir) ? PASS : FAIL,
5994
+ message: existsSync16(envCommsDir) ? envCommsDir : `Directory not found: ${envCommsDir}`
5995
+ });
5996
+ }
5997
+ checks.push({
5998
+ name: "MCP session cache",
5999
+ status: PASS,
6000
+ message: "If .mcp.json was changed mid-session, restart Claude (Ctrl+C \u2192 claude --resume) to reload"
6001
+ });
6002
+ return checks;
6003
+ }
6004
+ function checkBridgeTurnHealth(repoRoot) {
6005
+ const checks = [];
6006
+ const tmpDir = join17(repoRoot, ".tmp");
6007
+ if (!existsSync16(tmpDir)) return checks;
6008
+ const state = loadState(repoRoot);
6009
+ const activeMatchers = /* @__PURE__ */ new Set();
6010
+ if (state) {
6011
+ for (const [id, inst] of Object.entries(state.instances)) {
6012
+ if (inst?.installed && inst.bridgeMode === "app-server") {
6013
+ activeMatchers.add(id);
6014
+ if (inst.agentName) activeMatchers.add(inst.agentName);
6015
+ }
6016
+ }
6017
+ }
6018
+ let dirs;
6019
+ try {
6020
+ dirs = readdirSync4(tmpDir).filter((d) => {
6021
+ if (!d.startsWith("codex-app-server-bridge")) return false;
6022
+ const suffix = d.replace("codex-app-server-bridge-", "");
6023
+ if (activeMatchers.size === 0) return true;
6024
+ for (const matcher of activeMatchers) {
6025
+ if (suffix === matcher || suffix.startsWith(matcher)) return true;
6026
+ }
6027
+ return false;
6028
+ });
6029
+ } catch {
6030
+ return checks;
6031
+ }
6032
+ for (const dir of dirs) {
6033
+ const heartbeatPath = join17(tmpDir, dir, "heartbeat.json");
6034
+ if (!existsSync16(heartbeatPath)) continue;
6035
+ let heartbeat;
5678
6036
  try {
5679
- const config = JSON.parse(readFileSync14(mcpJson, "utf-8"));
5680
- const hasTapComms = config?.mcpServers?.["tap-comms"];
6037
+ heartbeat = JSON.parse(readFileSync14(heartbeatPath, "utf-8"));
6038
+ } catch {
6039
+ checks.push({
6040
+ name: `turn: ${dir}`,
6041
+ status: WARN,
6042
+ message: "heartbeat.json unreadable"
6043
+ });
6044
+ continue;
6045
+ }
6046
+ const heartbeatAge = heartbeat.updatedAt ? Math.floor(
6047
+ (Date.now() - new Date(heartbeat.updatedAt).getTime()) / 1e3
6048
+ ) : null;
6049
+ if (heartbeat.connected === false || heartbeat.initialized === false) {
5681
6050
  checks.push({
5682
- name: "MCP config (.mcp.json)",
5683
- status: hasTapComms ? PASS : WARN,
5684
- message: hasTapComms ? `command: ${hasTapComms.command}` : "tap-comms not configured"
6051
+ name: `turn: ${dir}`,
6052
+ status: FAIL,
6053
+ message: `disconnected (connected=${heartbeat.connected}, initialized=${heartbeat.initialized})${heartbeat.lastError ? ` \u2014 ${heartbeat.lastError}` : ""}`
5685
6054
  });
5686
- if (hasTapComms?.args?.[0]) {
5687
- const mcpScript = hasTapComms.args[0];
6055
+ continue;
6056
+ }
6057
+ if (heartbeatAge !== null && heartbeatAge > 300) {
6058
+ checks.push({
6059
+ name: `turn: ${dir}`,
6060
+ status: FAIL,
6061
+ message: `dead \u2014 heartbeat ${Math.round(heartbeatAge)}s ago, no updates`
6062
+ });
6063
+ continue;
6064
+ }
6065
+ if (heartbeat.activeTurnId) {
6066
+ const ZOMBIE_THRESHOLD = 30 * 60;
6067
+ const lastNotifAge = heartbeat.lastNotificationAt ? Math.floor(
6068
+ (Date.now() - new Date(heartbeat.lastNotificationAt).getTime()) / 1e3
6069
+ ) : null;
6070
+ if (lastNotifAge !== null && lastNotifAge > ZOMBIE_THRESHOLD) {
6071
+ checks.push({
6072
+ name: `turn: ${dir}`,
6073
+ status: WARN,
6074
+ message: `zombie \u2014 active turn ${heartbeat.activeTurnId}, last notification ${Math.round(lastNotifAge / 60)}m ago (${heartbeat.lastNotificationMethod ?? "?"}). MCP tools may not be exposed in app-server turns \u2014 try bridge restart${heartbeat.lastError ? `. Error: ${heartbeat.lastError}` : ""}`
6075
+ });
6076
+ continue;
6077
+ }
6078
+ const failures2 = heartbeat.consecutiveFailureCount ?? 0;
6079
+ if (failures2 > 0 && heartbeatAge !== null && heartbeatAge < 60) {
5688
6080
  checks.push({
5689
- name: "MCP server script",
5690
- status: existsSync16(mcpScript) ? PASS : FAIL,
5691
- message: existsSync16(mcpScript) ? mcpScript : `Not found: ${mcpScript}`
6081
+ name: `turn: ${dir}`,
6082
+ status: WARN,
6083
+ message: `zombie \u2014 active turn ${heartbeat.activeTurnId}, ${failures2} consecutive failures. MCP tools may not be exposed in app-server turns \u2014 try bridge restart${heartbeat.lastError ? `. Error: ${heartbeat.lastError}` : ""}`
5692
6084
  });
6085
+ continue;
5693
6086
  }
5694
- } catch {
6087
+ }
6088
+ const failures = heartbeat.consecutiveFailureCount ?? 0;
6089
+ if (failures > 5) {
5695
6090
  checks.push({
5696
- name: "MCP config (.mcp.json)",
6091
+ name: `turn: ${dir}`,
5697
6092
  status: WARN,
5698
- message: "File exists but invalid JSON"
6093
+ message: `slow \u2014 ${failures} consecutive failures, last: ${heartbeat.lastError ?? "unknown"}`
5699
6094
  });
6095
+ continue;
5700
6096
  }
5701
- } else {
6097
+ const turnInfo = heartbeat.activeTurnId ? `active turn ${heartbeat.activeTurnId}` : `idle (last: ${heartbeat.lastTurnStatus ?? "none"})`;
5702
6098
  checks.push({
5703
- name: "MCP config (.mcp.json)",
5704
- status: WARN,
5705
- message: "Not found \u2014 MCP channel notifications won't work"
6099
+ name: `turn: ${dir}`,
6100
+ status: PASS,
6101
+ message: `healthy \u2014 ${turnInfo}, heartbeat ${heartbeatAge ?? "?"}s ago`
5706
6102
  });
5707
6103
  }
5708
6104
  return checks;
@@ -5741,10 +6137,17 @@ async function doctorCommand(args) {
5741
6137
  checks.push(...checkInstances(repoRoot, config.stateDir));
5742
6138
  checks.push(...checkMessageLifecycle(commsDir));
5743
6139
  checks.push(...checkMcpServer(repoRoot));
6140
+ checks.push(...checkBridgeTurnHealth(repoRoot));
5744
6141
  return checks;
5745
6142
  }
5746
6143
  const initialChecks = runAllChecks();
5747
- for (const section of ["Comms", "Instances", "Messages", "MCP"]) {
6144
+ for (const section of [
6145
+ "Comms",
6146
+ "Instances",
6147
+ "Messages",
6148
+ "MCP",
6149
+ "Turns"
6150
+ ]) {
5748
6151
  const sectionChecks = {
5749
6152
  Comms: initialChecks.filter(
5750
6153
  (c) => [
@@ -5763,7 +6166,8 @@ async function doctorCommand(args) {
5763
6166
  ),
5764
6167
  MCP: initialChecks.filter(
5765
6168
  (c) => c.name.startsWith("MCP") || c.name === "MCP server script"
5766
- )
6169
+ ),
6170
+ Turns: initialChecks.filter((c) => c.name.startsWith("turn:"))
5767
6171
  }[section];
5768
6172
  if (sectionChecks.length > 0) {
5769
6173
  log(`${section}:`);
@@ -5824,7 +6228,7 @@ async function doctorCommand(args) {
5824
6228
  }
5825
6229
 
5826
6230
  // src/commands/comms.ts
5827
- import { execSync as execSync7 } from "child_process";
6231
+ import { execSync as execSync8 } from "child_process";
5828
6232
  import * as fs17 from "fs";
5829
6233
  import * as path18 from "path";
5830
6234
  var COMMS_HELP = `
@@ -5856,7 +6260,7 @@ function commsPull(commsDir) {
5856
6260
  };
5857
6261
  }
5858
6262
  try {
5859
- const output = execSync7("git pull --rebase", {
6263
+ const output = execSync8("git pull --rebase", {
5860
6264
  cwd: commsDir,
5861
6265
  encoding: "utf-8",
5862
6266
  stdio: "pipe"
@@ -5898,8 +6302,8 @@ function commsPush(commsDir) {
5898
6302
  };
5899
6303
  }
5900
6304
  try {
5901
- execSync7("git add -A", { cwd: commsDir, stdio: "pipe" });
5902
- const status = execSync7("git status --porcelain", {
6305
+ execSync8("git add -A", { cwd: commsDir, stdio: "pipe" });
6306
+ const status = execSync8("git status --porcelain", {
5903
6307
  cwd: commsDir,
5904
6308
  encoding: "utf-8",
5905
6309
  stdio: "pipe"
@@ -5916,11 +6320,11 @@ function commsPush(commsDir) {
5916
6320
  };
5917
6321
  }
5918
6322
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5919
- execSync7(`git commit -m "chore(comms): sync ${timestamp}"`, {
6323
+ execSync8(`git commit -m "chore(comms): sync ${timestamp}"`, {
5920
6324
  cwd: commsDir,
5921
6325
  stdio: "pipe"
5922
6326
  });
5923
- execSync7("git push", { cwd: commsDir, stdio: "pipe" });
6327
+ execSync8("git push", { cwd: commsDir, stdio: "pipe" });
5924
6328
  logSuccess("Comms push complete");
5925
6329
  return {
5926
6330
  ok: true,