@hua-labs/tap 0.2.1 → 0.2.2

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