@hua-labs/tap 0.2.6 → 0.3.1

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
@@ -979,11 +979,42 @@ function probeCommand(candidates) {
979
979
  });
980
980
  if (result.status === 0) {
981
981
  const version2 = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() || null;
982
- return { command: candidate, version: version2 };
982
+ const absolutePath = resolveCommandPath(candidate);
983
+ return { command: absolutePath ?? candidate, version: version2 };
983
984
  }
984
985
  }
985
986
  return { command: null, version: null };
986
987
  }
988
+ function resolveCommandPath(command) {
989
+ if (path7.isAbsolute(command)) return command;
990
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
991
+ try {
992
+ const result = spawnSync2(whichCmd, [command], {
993
+ encoding: "utf-8",
994
+ windowsHide: true
995
+ });
996
+ if (result.status !== 0) return null;
997
+ const lines = result.stdout.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
998
+ if (lines.length === 0) return null;
999
+ if (process.platform === "win32") {
1000
+ const candidateExt = path7.extname(command).toLowerCase();
1001
+ if (candidateExt) {
1002
+ const extMatch = lines.find(
1003
+ (l) => path7.extname(l).toLowerCase() === candidateExt && fs7.existsSync(l)
1004
+ );
1005
+ if (extMatch) return extMatch;
1006
+ }
1007
+ const executableMatch = lines.find(
1008
+ (l) => /\.(cmd|exe|ps1)$/i.test(l) && fs7.existsSync(l)
1009
+ );
1010
+ if (executableMatch) return executableMatch;
1011
+ }
1012
+ const firstValid = lines.find((l) => fs7.existsSync(l));
1013
+ return firstValid ?? null;
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ }
987
1018
  function getHomeDir() {
988
1019
  return os2.homedir();
989
1020
  }
@@ -2020,192 +2051,16 @@ function getAdapter(runtime) {
2020
2051
  return adapter;
2021
2052
  }
2022
2053
 
2023
- // src/engine/bridge.ts
2024
- import * as fs13 from "fs";
2025
- import * as net from "net";
2026
- import * as os3 from "os";
2027
- import * as path13 from "path";
2028
- import { randomBytes } from "crypto";
2029
- import { spawn, spawnSync as spawnSync3, execSync as execSync3 } from "child_process";
2030
- import { fileURLToPath as fileURLToPath4 } from "url";
2031
-
2032
- // src/runtime/resolve-node.ts
2033
- import * as fs12 from "fs";
2054
+ // src/engine/bridge-paths.ts
2034
2055
  import * as path12 from "path";
2035
- import { execSync as execSync2 } from "child_process";
2036
- function readNodeVersion(repoRoot) {
2037
- const nvFile = path12.join(repoRoot, ".node-version");
2038
- if (!fs12.existsSync(nvFile)) return null;
2039
- try {
2040
- const raw = fs12.readFileSync(nvFile, "utf-8").trim();
2041
- return raw.length > 0 ? raw.replace(/^v/, "") : null;
2042
- } catch {
2043
- return null;
2044
- }
2045
- }
2046
- function fnmCandidateDirs() {
2047
- if (process.platform === "win32") {
2048
- return [
2049
- process.env.FNM_DIR,
2050
- process.env.APPDATA ? path12.join(process.env.APPDATA, "fnm") : null,
2051
- process.env.LOCALAPPDATA ? path12.join(process.env.LOCALAPPDATA, "fnm") : null,
2052
- process.env.USERPROFILE ? path12.join(process.env.USERPROFILE, "scoop", "persist", "fnm") : null
2053
- ].filter(Boolean);
2054
- }
2055
- return [
2056
- process.env.FNM_DIR,
2057
- process.env.HOME ? path12.join(process.env.HOME, ".local", "share", "fnm") : null,
2058
- process.env.HOME ? path12.join(process.env.HOME, ".fnm") : null,
2059
- process.env.XDG_DATA_HOME ? path12.join(process.env.XDG_DATA_HOME, "fnm") : null
2060
- ].filter(Boolean);
2061
- }
2062
- function nodeExecutableName() {
2063
- return process.platform === "win32" ? "node.exe" : "node";
2064
- }
2065
- function probeFnmNode(desiredVersion) {
2066
- const dirs = fnmCandidateDirs();
2067
- const exe = nodeExecutableName();
2068
- for (const baseDir of dirs) {
2069
- const candidate = path12.join(
2070
- baseDir,
2071
- "node-versions",
2072
- `v${desiredVersion}`,
2073
- "installation",
2074
- exe
2075
- );
2076
- if (!fs12.existsSync(candidate)) continue;
2077
- try {
2078
- const v = execSync2(`"${candidate}" --version`, {
2079
- encoding: "utf-8",
2080
- timeout: 5e3
2081
- }).trim();
2082
- if (v.startsWith(`v${desiredVersion.split(".")[0]}.`)) {
2083
- return candidate;
2084
- }
2085
- } catch {
2086
- }
2087
- }
2088
- return null;
2089
- }
2090
- function detectNodeMajorVersion(command) {
2091
- try {
2092
- const version2 = execSync2(`"${command}" --version`, {
2093
- encoding: "utf-8",
2094
- timeout: 5e3
2095
- }).trim();
2096
- const match = version2.match(/^v?(\d+)\./);
2097
- return match ? parseInt(match[1], 10) : null;
2098
- } catch {
2099
- return null;
2100
- }
2101
- }
2102
- function checkStripTypesSupport(command) {
2103
- const major = detectNodeMajorVersion(command);
2104
- if (major !== null && major >= 22) return true;
2105
- try {
2106
- execSync2(`"${command}" --experimental-strip-types -e ""`, {
2107
- timeout: 5e3,
2108
- stdio: "pipe"
2109
- });
2110
- return true;
2111
- } catch {
2112
- return false;
2113
- }
2114
- }
2115
- function findTsxFallback(repoRoot) {
2116
- const candidates = [
2117
- path12.join(repoRoot, "node_modules", ".bin", "tsx.exe"),
2118
- path12.join(repoRoot, "node_modules", ".bin", "tsx.CMD"),
2119
- path12.join(repoRoot, "node_modules", ".bin", "tsx")
2120
- ];
2121
- for (const c of candidates) {
2122
- if (fs12.existsSync(c)) return c;
2123
- }
2124
- return null;
2125
- }
2126
- function getFnmBinDir(repoRoot) {
2127
- const desiredVersion = readNodeVersion(repoRoot);
2128
- if (!desiredVersion) return null;
2129
- const nodePath = probeFnmNode(desiredVersion);
2130
- if (!nodePath) return null;
2131
- return path12.dirname(nodePath);
2132
- }
2133
- function resolveNodeRuntime(configCommand, repoRoot) {
2134
- if (configCommand === "bun" || configCommand.endsWith("bun.exe")) {
2135
- return {
2136
- command: configCommand,
2137
- supportsStripTypes: false,
2138
- source: "bun",
2139
- majorVersion: null
2140
- };
2141
- }
2142
- const desiredVersion = readNodeVersion(repoRoot);
2143
- if (desiredVersion) {
2144
- const fnmNode = probeFnmNode(desiredVersion);
2145
- if (fnmNode) {
2146
- const major2 = detectNodeMajorVersion(fnmNode);
2147
- return {
2148
- command: fnmNode,
2149
- supportsStripTypes: checkStripTypesSupport(fnmNode),
2150
- source: "fnm",
2151
- majorVersion: major2
2152
- };
2153
- }
2154
- }
2155
- const major = detectNodeMajorVersion(configCommand);
2156
- if (major !== null) {
2157
- return {
2158
- command: configCommand,
2159
- supportsStripTypes: checkStripTypesSupport(configCommand),
2160
- source: major === detectNodeMajorVersion("node") ? "path" : "config",
2161
- majorVersion: major
2162
- };
2163
- }
2164
- const tsx = findTsxFallback(repoRoot);
2165
- if (tsx) {
2166
- return {
2167
- command: tsx,
2168
- supportsStripTypes: false,
2169
- source: "tsx-fallback",
2170
- majorVersion: null
2171
- };
2172
- }
2173
- return {
2174
- command: configCommand,
2175
- supportsStripTypes: false,
2176
- source: "path",
2177
- majorVersion: null
2178
- };
2179
- }
2180
- function buildRuntimeEnv(repoRoot, baseEnv = process.env) {
2181
- const fnmBin = getFnmBinDir(repoRoot);
2182
- if (!fnmBin) return { ...baseEnv };
2183
- const pathKey = process.platform === "win32" ? "Path" : "PATH";
2184
- const currentPath = baseEnv[pathKey] ?? baseEnv.PATH ?? "";
2185
- return {
2186
- ...baseEnv,
2187
- [pathKey]: `${fnmBin}${path12.delimiter}${currentPath}`
2188
- };
2189
- }
2190
-
2191
- // src/engine/bridge.ts
2192
- var DEFAULT_APP_SERVER_URL2 = "ws://127.0.0.1:4501";
2193
- var APP_SERVER_HEALTH_TIMEOUT_MS = 1500;
2194
- var APP_SERVER_START_TIMEOUT_MS = 2e4;
2195
- var APP_SERVER_GATEWAY_START_TIMEOUT_MS = 5e3;
2196
- var APP_SERVER_HEALTH_RETRY_MS = 250;
2197
- var AUTH_SUBPROTOCOL_PREFIX = "tap-auth-";
2198
- var APP_SERVER_AUTH_FILE_MODE = 384;
2199
- var WINDOWS_SPAWN_WRAPPER_PREFIX = "tap-spawn-";
2200
- var WINDOWS_SPAWN_WRAPPER_STALE_MS = 60 * 60 * 1e3;
2201
2056
  function appServerLogFilePath(stateDir, instanceId) {
2202
- return path13.join(stateDir, "logs", `app-server-${instanceId}.log`);
2057
+ return path12.join(stateDir, "logs", `app-server-${instanceId}.log`);
2203
2058
  }
2204
2059
  function appServerGatewayLogFilePath(stateDir, instanceId) {
2205
- return path13.join(stateDir, "logs", `app-server-gateway-${instanceId}.log`);
2060
+ return path12.join(stateDir, "logs", `app-server-gateway-${instanceId}.log`);
2206
2061
  }
2207
2062
  function appServerGatewayTokenFilePath(stateDir, instanceId) {
2208
- return path13.join(
2063
+ return path12.join(
2209
2064
  stateDir,
2210
2065
  "secrets",
2211
2066
  `app-server-gateway-${instanceId}.token`
@@ -2214,23 +2069,40 @@ function appServerGatewayTokenFilePath(stateDir, instanceId) {
2214
2069
  function stderrLogFilePath(logPath) {
2215
2070
  return `${logPath}.stderr`;
2216
2071
  }
2072
+ function pidFilePath(stateDir, instanceId) {
2073
+ return path12.join(stateDir, "pids", `bridge-${instanceId}.json`);
2074
+ }
2075
+ function logFilePath(stateDir, instanceId) {
2076
+ return path12.join(stateDir, "logs", `bridge-${instanceId}.log`);
2077
+ }
2078
+ function runtimeHeartbeatFilePath(runtimeStateDir) {
2079
+ return path12.join(runtimeStateDir, "heartbeat.json");
2080
+ }
2081
+ function runtimeThreadStateFilePath(runtimeStateDir) {
2082
+ return path12.join(runtimeStateDir, "thread.json");
2083
+ }
2084
+
2085
+ // src/engine/bridge-file-io.ts
2086
+ import * as fs12 from "fs";
2087
+ import * as path13 from "path";
2088
+ var APP_SERVER_AUTH_FILE_MODE = 384;
2217
2089
  function writeProtectedTextFile(filePath, content) {
2218
- fs13.mkdirSync(path13.dirname(filePath), { recursive: true });
2090
+ fs12.mkdirSync(path13.dirname(filePath), { recursive: true });
2219
2091
  const tmp = `${filePath}.tmp.${process.pid}`;
2220
- fs13.writeFileSync(tmp, content, {
2092
+ fs12.writeFileSync(tmp, content, {
2221
2093
  encoding: "utf-8",
2222
2094
  mode: APP_SERVER_AUTH_FILE_MODE
2223
2095
  });
2224
- fs13.chmodSync(tmp, APP_SERVER_AUTH_FILE_MODE);
2225
- fs13.renameSync(tmp, filePath);
2226
- fs13.chmodSync(filePath, APP_SERVER_AUTH_FILE_MODE);
2096
+ fs12.chmodSync(tmp, APP_SERVER_AUTH_FILE_MODE);
2097
+ fs12.renameSync(tmp, filePath);
2098
+ fs12.chmodSync(filePath, APP_SERVER_AUTH_FILE_MODE);
2227
2099
  }
2228
2100
  function removeFileIfExists(filePath) {
2229
- if (!filePath || !fs13.existsSync(filePath)) {
2101
+ if (!filePath || !fs12.existsSync(filePath)) {
2230
2102
  return;
2231
2103
  }
2232
2104
  try {
2233
- fs13.unlinkSync(filePath);
2105
+ fs12.unlinkSync(filePath);
2234
2106
  } catch {
2235
2107
  }
2236
2108
  }
@@ -2240,83 +2112,155 @@ function toPowerShellSingleQuotedString(value) {
2240
2112
  function toPowerShellStringArrayLiteral(values) {
2241
2113
  return `@(${values.map(toPowerShellSingleQuotedString).join(", ")})`;
2242
2114
  }
2243
- function cleanupStaleWindowsSpawnWrappers(now = Date.now()) {
2244
- let entries;
2245
- try {
2246
- entries = fs13.readdirSync(os3.tmpdir());
2247
- } catch {
2248
- return;
2249
- }
2250
- for (const entry of entries) {
2251
- if (!entry.startsWith(WINDOWS_SPAWN_WRAPPER_PREFIX) || !/\.(cmd|ps1)$/i.test(entry)) {
2252
- continue;
2253
- }
2254
- const wrapperPath = path13.join(os3.tmpdir(), entry);
2255
- try {
2256
- const stats = fs13.statSync(wrapperPath);
2257
- if (now - stats.mtimeMs < WINDOWS_SPAWN_WRAPPER_STALE_MS) {
2258
- continue;
2259
- }
2260
- fs13.unlinkSync(wrapperPath);
2261
- } catch {
2262
- }
2263
- }
2264
- }
2265
- function buildWindowsDetachedWrapperScript(command, args, logPath, stderrLogPath, env) {
2266
- const lines = ["$ErrorActionPreference = 'Stop'"];
2267
- for (const [key, value] of Object.entries(env)) {
2268
- if (value !== void 0 && value !== process.env[key]) {
2269
- lines.push(
2270
- `[Environment]::SetEnvironmentVariable(${toPowerShellSingleQuotedString(key)}, ${toPowerShellSingleQuotedString(value)}, 'Process')`
2271
- );
2272
- }
2273
- }
2274
- lines.push(
2275
- `$logPath = ${toPowerShellSingleQuotedString(logPath)}`,
2276
- `$stderrLogPath = ${toPowerShellSingleQuotedString(stderrLogPath)}`,
2277
- `$commandPath = ${toPowerShellSingleQuotedString(command)}`,
2278
- `$commandArgs = ${toPowerShellStringArrayLiteral(args)}`,
2279
- "$exitCode = 1",
2280
- "try {",
2281
- " & $commandPath @commandArgs >> $logPath 2>> $stderrLogPath",
2282
- " $exitCode = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 }",
2283
- "} finally {",
2284
- " Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue",
2285
- "}",
2286
- "exit $exitCode"
2287
- );
2288
- return `${lines.join("\r\n")}\r
2289
- `;
2290
- }
2115
+
2116
+ // src/engine/bridge-port-network.ts
2117
+ import * as net from "net";
2118
+ var DEFAULT_APP_SERVER_URL2 = "ws://127.0.0.1:4501";
2291
2119
  function getWebSocketCtor() {
2292
2120
  const candidate = globalThis.WebSocket;
2293
2121
  return typeof candidate === "function" ? candidate : null;
2294
2122
  }
2295
2123
  function delay(ms) {
2296
- return new Promise((resolve13) => setTimeout(resolve13, ms));
2124
+ return new Promise((resolve14) => setTimeout(resolve14, ms));
2297
2125
  }
2298
2126
  function isLoopbackHost(hostname) {
2299
2127
  return hostname === "127.0.0.1" || hostname === "localhost";
2300
2128
  }
2301
- function resolveCodexCommand(platform) {
2302
- const candidates = platform === "win32" ? ["codex.cmd", "codex.exe", "codex", "codex.ps1"] : ["codex"];
2303
- return probeCommand(candidates).command;
2304
- }
2305
- function formatCodexAppServerCommand(command, url) {
2306
- return `${command} app-server --listen ${url}`;
2307
- }
2308
- function resolvePowerShellCommand() {
2309
- return probeCommand(["pwsh", "powershell", "powershell.exe"]).command ?? "powershell";
2129
+ async function allocateLoopbackPort(hostname) {
2130
+ const bindHost = hostname === "localhost" ? "127.0.0.1" : hostname;
2131
+ return await new Promise((resolve14, reject) => {
2132
+ const server = net.createServer();
2133
+ server.unref();
2134
+ server.once("error", reject);
2135
+ server.listen(0, bindHost, () => {
2136
+ const address = server.address();
2137
+ if (!address || typeof address === "string") {
2138
+ server.close(() => {
2139
+ reject(new Error("Failed to allocate a loopback port"));
2140
+ });
2141
+ return;
2142
+ }
2143
+ const port = address.port;
2144
+ server.close((error) => {
2145
+ if (error) {
2146
+ reject(error);
2147
+ return;
2148
+ }
2149
+ resolve14(port);
2150
+ });
2151
+ });
2152
+ });
2153
+ }
2154
+ async function isTcpPortAvailable(hostname, port) {
2155
+ const bindHost = hostname === "localhost" ? "127.0.0.1" : hostname;
2156
+ return await new Promise((resolve14) => {
2157
+ const server = net.createServer();
2158
+ server.unref();
2159
+ server.once("error", () => resolve14(false));
2160
+ server.listen(port, bindHost, () => {
2161
+ server.close((error) => resolve14(!error));
2162
+ });
2163
+ });
2164
+ }
2165
+ async function waitForPortRelease(url, timeoutMs = 1e4, intervalMs = 500) {
2166
+ let hostname;
2167
+ let port;
2168
+ try {
2169
+ const parsed = new URL(url);
2170
+ hostname = parsed.hostname;
2171
+ port = parseInt(parsed.port, 10);
2172
+ } catch {
2173
+ return true;
2174
+ }
2175
+ if (!port || !Number.isFinite(port)) return true;
2176
+ const deadline = Date.now() + timeoutMs;
2177
+ while (Date.now() < deadline) {
2178
+ if (await isTcpPortAvailable(hostname, port)) {
2179
+ return true;
2180
+ }
2181
+ await delay(intervalMs);
2182
+ }
2183
+ return false;
2184
+ }
2185
+ async function findNextAvailableAppServerPort(state, baseUrl, basePort = 4501, excludeInstanceId) {
2186
+ let hostname = "127.0.0.1";
2187
+ try {
2188
+ hostname = new URL(baseUrl ?? DEFAULT_APP_SERVER_URL2).hostname;
2189
+ } catch {
2190
+ }
2191
+ const maxAttempts = 1e3;
2192
+ let port = basePort;
2193
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1, port += 1) {
2194
+ const claimedInState = Object.entries(state.instances).some(
2195
+ ([id, inst]) => id !== excludeInstanceId && inst.port === port
2196
+ );
2197
+ if (claimedInState) {
2198
+ continue;
2199
+ }
2200
+ if (!isLoopbackHost(hostname)) {
2201
+ return port;
2202
+ }
2203
+ if (await isTcpPortAvailable(hostname, port)) {
2204
+ return port;
2205
+ }
2206
+ }
2207
+ throw new Error(
2208
+ `Failed to find a free app-server port starting at ${basePort}`
2209
+ );
2210
+ }
2211
+
2212
+ // src/engine/bridge-codex-command.ts
2213
+ import * as fs13 from "fs";
2214
+ import * as path14 from "path";
2215
+ import { fileURLToPath as fileURLToPath4 } from "url";
2216
+ function resolveCodexCommand(platform) {
2217
+ const candidates = platform === "win32" ? ["codex.cmd", "codex.exe", "codex", "codex.ps1"] : ["codex"];
2218
+ const resolved = probeCommand(candidates).command;
2219
+ if (!resolved) return null;
2220
+ if (platform === "win32" && resolved.endsWith(".cmd")) {
2221
+ const unwrapped = unwrapNpmCmdShim(resolved);
2222
+ if (unwrapped) return unwrapped;
2223
+ }
2224
+ return resolved;
2225
+ }
2226
+ function unwrapNpmCmdShim(cmdPath) {
2227
+ let content;
2228
+ try {
2229
+ content = fs13.readFileSync(cmdPath, "utf-8");
2230
+ } catch {
2231
+ return null;
2232
+ }
2233
+ const match = content.match(
2234
+ /"%_prog%"\s+"(%dp0%\\[^"]+)"\s+%\*/
2235
+ );
2236
+ if (!match) return null;
2237
+ const dp0 = path14.dirname(cmdPath);
2238
+ const scriptRelative = match[1].replace(/%dp0%\\/g, "");
2239
+ const scriptPath = path14.resolve(dp0, scriptRelative);
2240
+ if (!fs13.existsSync(scriptPath)) return null;
2241
+ const localNode = path14.join(dp0, "node.exe");
2242
+ const nodeCommand = fs13.existsSync(localNode) ? localNode : probeCommand(["node.exe", "node"]).command ?? "node";
2243
+ return `${nodeCommand}\0${scriptPath}`;
2244
+ }
2245
+ function splitResolvedCommand(resolved) {
2246
+ const parts = resolved.split("\0");
2247
+ if (parts.length === 2) {
2248
+ return { command: parts[0], prefixArgs: [parts[1]] };
2249
+ }
2250
+ return { command: resolved, prefixArgs: [] };
2251
+ }
2252
+ function resolvePowerShellCommand() {
2253
+ return probeCommand(["pwsh", "powershell", "powershell.exe"]).command ?? "powershell";
2310
2254
  }
2311
2255
  function resolveAuthGatewayScript(repoRoot) {
2312
- const moduleDir = path13.dirname(fileURLToPath4(import.meta.url));
2256
+ const moduleDir = path14.dirname(fileURLToPath4(import.meta.url));
2313
2257
  const candidates = [
2314
2258
  // Bundled: dist/bridges/ sibling (npm install / built package)
2315
- path13.join(moduleDir, "bridges", "codex-app-server-auth-gateway.mjs"),
2259
+ path14.join(moduleDir, "bridges", "codex-app-server-auth-gateway.mjs"),
2316
2260
  // Source: src/bridges/ sibling (monorepo dev with ts runner)
2317
- path13.join(moduleDir, "bridges", "codex-app-server-auth-gateway.ts"),
2261
+ path14.join(moduleDir, "bridges", "codex-app-server-auth-gateway.ts"),
2318
2262
  // Monorepo dist fallback
2319
- path13.join(
2263
+ path14.join(
2320
2264
  repoRoot,
2321
2265
  "packages",
2322
2266
  "tap-comms",
@@ -2324,7 +2268,7 @@ function resolveAuthGatewayScript(repoRoot) {
2324
2268
  "bridges",
2325
2269
  "codex-app-server-auth-gateway.mjs"
2326
2270
  ),
2327
- path13.join(
2271
+ path14.join(
2328
2272
  repoRoot,
2329
2273
  "packages",
2330
2274
  "tap-comms",
@@ -2338,41 +2282,661 @@ function resolveAuthGatewayScript(repoRoot) {
2338
2282
  return candidate;
2339
2283
  }
2340
2284
  }
2341
- return null;
2342
- }
2343
- function getBridgeRuntimeStateDir(repoRoot, instanceId) {
2344
- return path13.join(repoRoot, ".tmp", `codex-app-server-bridge-${instanceId}`);
2285
+ return null;
2286
+ }
2287
+
2288
+ // src/engine/bridge-windows-spawn.ts
2289
+ import * as fs14 from "fs";
2290
+ import * as os3 from "os";
2291
+ import * as path15 from "path";
2292
+ import { randomBytes } from "crypto";
2293
+ import { spawnSync as spawnSync3 } from "child_process";
2294
+ var WINDOWS_SPAWN_WRAPPER_PREFIX = "tap-spawn-";
2295
+ var WINDOWS_SPAWN_WRAPPER_STALE_MS = 60 * 60 * 1e3;
2296
+ function cleanupStaleWindowsSpawnWrappers(now = Date.now()) {
2297
+ let entries;
2298
+ try {
2299
+ entries = fs14.readdirSync(os3.tmpdir());
2300
+ } catch {
2301
+ return;
2302
+ }
2303
+ for (const entry of entries) {
2304
+ if (!entry.startsWith(WINDOWS_SPAWN_WRAPPER_PREFIX) || !/\.(cmd|ps1)$/i.test(entry)) {
2305
+ continue;
2306
+ }
2307
+ const wrapperPath = path15.join(os3.tmpdir(), entry);
2308
+ try {
2309
+ const stats = fs14.statSync(wrapperPath);
2310
+ if (now - stats.mtimeMs < WINDOWS_SPAWN_WRAPPER_STALE_MS) {
2311
+ continue;
2312
+ }
2313
+ fs14.unlinkSync(wrapperPath);
2314
+ } catch {
2315
+ }
2316
+ }
2317
+ }
2318
+ function buildWindowsDetachedWrapperScript(command, args, logPath, stderrLogPath, env) {
2319
+ const lines = ["$ErrorActionPreference = 'Stop'"];
2320
+ for (const [key, value] of Object.entries(env)) {
2321
+ if (value !== void 0 && value !== process.env[key]) {
2322
+ lines.push(
2323
+ `[Environment]::SetEnvironmentVariable(${toPowerShellSingleQuotedString(key)}, ${toPowerShellSingleQuotedString(value)}, 'Process')`
2324
+ );
2325
+ }
2326
+ }
2327
+ lines.push(
2328
+ `$logPath = ${toPowerShellSingleQuotedString(logPath)}`,
2329
+ `$stderrLogPath = ${toPowerShellSingleQuotedString(stderrLogPath)}`,
2330
+ `$commandPath = ${toPowerShellSingleQuotedString(command)}`,
2331
+ `$commandArgs = ${toPowerShellStringArrayLiteral(args)}`,
2332
+ "$exitCode = 1",
2333
+ "try {",
2334
+ " & $commandPath @commandArgs >> $logPath 2>> $stderrLogPath",
2335
+ " $exitCode = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 }",
2336
+ "} finally {",
2337
+ " Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue",
2338
+ "}",
2339
+ "exit $exitCode"
2340
+ );
2341
+ return `${lines.join("\r\n")}\r
2342
+ `;
2343
+ }
2344
+ function startWindowsDetachedProcess(command, args, repoRoot, logPath, env = process.env) {
2345
+ const stderrLogPath = stderrLogFilePath(logPath);
2346
+ const powerShellCommand = resolvePowerShellCommand();
2347
+ cleanupStaleWindowsSpawnWrappers();
2348
+ const wrapperPath = path15.join(
2349
+ os3.tmpdir(),
2350
+ `${WINDOWS_SPAWN_WRAPPER_PREFIX}${randomBytes(4).toString("hex")}.ps1`
2351
+ );
2352
+ fs14.writeFileSync(
2353
+ wrapperPath,
2354
+ buildWindowsDetachedWrapperScript(
2355
+ command,
2356
+ args,
2357
+ logPath,
2358
+ stderrLogPath,
2359
+ env
2360
+ )
2361
+ );
2362
+ const psCommand = [
2363
+ "$p = Start-Process",
2364
+ `-FilePath ${toPowerShellSingleQuotedString(powerShellCommand)}`,
2365
+ `-ArgumentList ${toPowerShellStringArrayLiteral(["-NoLogo", "-NoProfile", "-File", wrapperPath])}`,
2366
+ `-WorkingDirectory ${toPowerShellSingleQuotedString(repoRoot)}`,
2367
+ "-WindowStyle Hidden",
2368
+ "-PassThru",
2369
+ "; Write-Output $p.Id"
2370
+ ].join(" ");
2371
+ const result = spawnSync3(
2372
+ powerShellCommand,
2373
+ ["-NoLogo", "-NoProfile", "-Command", psCommand],
2374
+ {
2375
+ encoding: "utf-8",
2376
+ windowsHide: true
2377
+ }
2378
+ );
2379
+ if (result.status !== 0) {
2380
+ removeFileIfExists(wrapperPath);
2381
+ return null;
2382
+ }
2383
+ const pid = parseInt(result.stdout.trim(), 10);
2384
+ if (!Number.isFinite(pid)) {
2385
+ removeFileIfExists(wrapperPath);
2386
+ return null;
2387
+ }
2388
+ return pid;
2389
+ }
2390
+ function startWindowsCodexAppServer(command, url, repoRoot, logPath) {
2391
+ const { command: exe, prefixArgs } = splitResolvedCommand(command);
2392
+ return startWindowsDetachedProcess(
2393
+ exe,
2394
+ [...prefixArgs, "app-server", "--listen", url],
2395
+ repoRoot,
2396
+ logPath
2397
+ );
2398
+ }
2399
+ function findListeningProcessId(url, platform) {
2400
+ if (platform !== "win32") {
2401
+ return null;
2402
+ }
2403
+ let port;
2404
+ try {
2405
+ const parsed = new URL(url);
2406
+ port = parsed.port ? Number.parseInt(parsed.port, 10) : null;
2407
+ } catch {
2408
+ return null;
2409
+ }
2410
+ if (port == null || !Number.isFinite(port)) {
2411
+ return null;
2412
+ }
2413
+ const result = spawnSync3(
2414
+ resolvePowerShellCommand(),
2415
+ [
2416
+ "-NoLogo",
2417
+ "-NoProfile",
2418
+ "-Command",
2419
+ [
2420
+ `$port = ${port}`,
2421
+ "$processId = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess",
2422
+ "if ($processId) { $processId }"
2423
+ ].join("; ")
2424
+ ],
2425
+ {
2426
+ encoding: "utf-8",
2427
+ windowsHide: true
2428
+ }
2429
+ );
2430
+ if (result.status !== 0) {
2431
+ return null;
2432
+ }
2433
+ const parsedPid = Number.parseInt((result.stdout ?? "").trim(), 10);
2434
+ return Number.isFinite(parsedPid) ? parsedPid : null;
2435
+ }
2436
+
2437
+ // src/engine/bridge-unix-spawn.ts
2438
+ import * as fs15 from "fs";
2439
+ import { spawn, spawnSync as spawnSync4 } from "child_process";
2440
+ function startUnixDetachedProcess(command, args, repoRoot, logPath, env = process.env) {
2441
+ const stderrPath = stderrLogFilePath(logPath);
2442
+ let logFd = null;
2443
+ let stderrFd = null;
2444
+ try {
2445
+ logFd = fs15.openSync(logPath, "a");
2446
+ stderrFd = fs15.openSync(stderrPath, "a");
2447
+ const child = spawn(command, args, {
2448
+ cwd: repoRoot,
2449
+ detached: true,
2450
+ stdio: ["ignore", logFd, stderrFd],
2451
+ env,
2452
+ windowsHide: true
2453
+ });
2454
+ child.unref();
2455
+ return child.pid ?? null;
2456
+ } finally {
2457
+ if (logFd != null) {
2458
+ fs15.closeSync(logFd);
2459
+ }
2460
+ if (stderrFd != null) {
2461
+ fs15.closeSync(stderrFd);
2462
+ }
2463
+ }
2464
+ }
2465
+ function startUnixCodexAppServer(command, url, repoRoot, logPath) {
2466
+ const { command: exe, prefixArgs } = splitResolvedCommand(command);
2467
+ return startUnixDetachedProcess(
2468
+ exe,
2469
+ [...prefixArgs, "app-server", "--listen", url],
2470
+ repoRoot,
2471
+ logPath
2472
+ );
2473
+ }
2474
+ function findUnixListeningProcessId(url, platform) {
2475
+ if (platform === "win32") {
2476
+ return null;
2477
+ }
2478
+ let port;
2479
+ try {
2480
+ const parsed = new URL(url);
2481
+ port = parsed.port ? Number.parseInt(parsed.port, 10) : null;
2482
+ } catch {
2483
+ return null;
2484
+ }
2485
+ if (port == null || !Number.isFinite(port)) {
2486
+ return null;
2487
+ }
2488
+ const result = spawnSync4(
2489
+ "lsof",
2490
+ ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
2491
+ {
2492
+ encoding: "utf-8",
2493
+ windowsHide: true
2494
+ }
2495
+ );
2496
+ if (!result || result.status !== 0) {
2497
+ return null;
2498
+ }
2499
+ const parsedPid = Number.parseInt((result.stdout ?? "").trim(), 10);
2500
+ return Number.isFinite(parsedPid) ? parsedPid : null;
2501
+ }
2502
+
2503
+ // src/engine/bridge-process-control.ts
2504
+ import { execSync as execSync2 } from "child_process";
2505
+ function isProcessAlive(pid) {
2506
+ try {
2507
+ process.kill(pid, 0);
2508
+ return true;
2509
+ } catch {
2510
+ return false;
2511
+ }
2512
+ }
2513
+ async function terminateProcess(pid, platform) {
2514
+ if (!isProcessAlive(pid)) {
2515
+ return false;
2516
+ }
2517
+ try {
2518
+ if (platform === "win32") {
2519
+ execSync2(`taskkill /PID ${pid} /F /T`, { stdio: "pipe" });
2520
+ } else {
2521
+ process.kill(pid, "SIGTERM");
2522
+ await delay(2e3);
2523
+ if (isProcessAlive(pid)) {
2524
+ process.kill(pid, "SIGKILL");
2525
+ }
2526
+ }
2527
+ } catch {
2528
+ }
2529
+ return !isProcessAlive(pid);
2530
+ }
2531
+ async function stopManagedAppServer(appServer, platform) {
2532
+ if (!appServer.managed) {
2533
+ return false;
2534
+ }
2535
+ let stopped = false;
2536
+ if (appServer.auth?.gatewayPid != null) {
2537
+ stopped = await terminateProcess(appServer.auth.gatewayPid, platform) || stopped;
2538
+ }
2539
+ if (appServer.pid != null) {
2540
+ stopped = await terminateProcess(appServer.pid, platform) || stopped;
2541
+ }
2542
+ removeFileIfExists(appServer.auth?.tokenPath);
2543
+ return stopped;
2544
+ }
2545
+
2546
+ // src/engine/bridge-config.ts
2547
+ import * as fs16 from "fs";
2548
+ import * as path16 from "path";
2549
+ function resolveAgentName(instanceId, explicit, context) {
2550
+ if (explicit) return explicit;
2551
+ try {
2552
+ const repoRoot = context?.repoRoot ?? context?.stateDir?.replace(/[\\/].tap-comms$/, "") ?? process.cwd();
2553
+ const state = loadState(repoRoot);
2554
+ const stateAgent = state?.instances[instanceId]?.agentName;
2555
+ if (stateAgent) return stateAgent;
2556
+ } catch {
2557
+ }
2558
+ return process.env.TAP_AGENT_NAME || process.env.CODEX_TAP_AGENT_NAME || null;
2559
+ }
2560
+ function inferRestartMode(bridgeState, flags, savedMode) {
2561
+ const wasManaged = bridgeState?.appServer != null;
2562
+ const hadAuth = bridgeState?.appServer?.auth != null;
2563
+ const manageAppServer = flags?.noServer === true ? false : flags?.noServer === void 0 ? savedMode?.manageAppServer ?? wasManaged : true;
2564
+ const noAuth = flags?.noAuth === true ? true : flags?.noAuth === void 0 ? savedMode?.noAuth ?? !hadAuth : false;
2565
+ return { manageAppServer, noAuth };
2566
+ }
2567
+ function cleanupHeadlessDispatch(inboxDir, agentName) {
2568
+ const removed = [];
2569
+ if (!fs16.existsSync(inboxDir)) return removed;
2570
+ const normalizedAgent = agentName.replace(/-/g, "_");
2571
+ const marker = `-headless-${normalizedAgent}-review-`;
2572
+ try {
2573
+ for (const file of fs16.readdirSync(inboxDir)) {
2574
+ if (file.includes(marker)) {
2575
+ fs16.unlinkSync(path16.join(inboxDir, file));
2576
+ removed.push(file);
2577
+ }
2578
+ }
2579
+ } catch {
2580
+ }
2581
+ return removed;
2582
+ }
2583
+
2584
+ // src/engine/bridge-state.ts
2585
+ import * as fs17 from "fs";
2586
+ function loadRuntimeBridgeHeartbeat(bridgeState) {
2587
+ const runtimeStateDir = bridgeState?.runtimeStateDir;
2588
+ if (!runtimeStateDir) {
2589
+ return null;
2590
+ }
2591
+ const heartbeatPath = runtimeHeartbeatFilePath(runtimeStateDir);
2592
+ if (!fs17.existsSync(heartbeatPath)) {
2593
+ return null;
2594
+ }
2595
+ try {
2596
+ return JSON.parse(
2597
+ fs17.readFileSync(heartbeatPath, "utf-8")
2598
+ );
2599
+ } catch {
2600
+ return null;
2601
+ }
2602
+ }
2603
+ function loadRuntimeBridgeThreadState(bridgeState) {
2604
+ const runtimeStateDir = bridgeState?.runtimeStateDir;
2605
+ if (!runtimeStateDir) {
2606
+ return null;
2607
+ }
2608
+ const threadPath = runtimeThreadStateFilePath(runtimeStateDir);
2609
+ if (!fs17.existsSync(threadPath)) {
2610
+ return null;
2611
+ }
2612
+ try {
2613
+ const parsed = JSON.parse(
2614
+ fs17.readFileSync(threadPath, "utf-8")
2615
+ );
2616
+ return parsed.threadId ? parsed : null;
2617
+ } catch {
2618
+ return null;
2619
+ }
2620
+ }
2621
+ function loadBridgeState(stateDir, instanceId) {
2622
+ const pidPath = pidFilePath(stateDir, instanceId);
2623
+ if (!fs17.existsSync(pidPath)) return null;
2624
+ try {
2625
+ const raw = fs17.readFileSync(pidPath, "utf-8");
2626
+ return JSON.parse(raw);
2627
+ } catch {
2628
+ return null;
2629
+ }
2630
+ }
2631
+ function saveBridgeState(stateDir, instanceId, state) {
2632
+ const pidPath = pidFilePath(stateDir, instanceId);
2633
+ const serializable = JSON.parse(JSON.stringify(state));
2634
+ if (serializable.appServer?.auth) {
2635
+ delete serializable.appServer.auth.token;
2636
+ }
2637
+ writeProtectedTextFile(pidPath, JSON.stringify(serializable, null, 2));
2638
+ }
2639
+ function clearBridgeState(stateDir, instanceId) {
2640
+ const pidPath = pidFilePath(stateDir, instanceId);
2641
+ if (fs17.existsSync(pidPath)) {
2642
+ fs17.unlinkSync(pidPath);
2643
+ }
2644
+ }
2645
+ function isBridgeRunning(stateDir, instanceId) {
2646
+ const state = loadBridgeState(stateDir, instanceId);
2647
+ if (!state) return false;
2648
+ return isProcessAlive(state.pid);
2649
+ }
2650
+
2651
+ // src/engine/bridge-observability.ts
2652
+ import * as fs18 from "fs";
2653
+ function loadRuntimeHeartbeatTimestamp(runtimeStateDir) {
2654
+ const heartbeat = loadRuntimeBridgeHeartbeat({ runtimeStateDir });
2655
+ return typeof heartbeat?.updatedAt === "string" ? heartbeat.updatedAt : null;
2656
+ }
2657
+ function resolveHeartbeatTimestamp(state) {
2658
+ return loadRuntimeHeartbeatTimestamp(state?.runtimeStateDir) ?? state?.lastHeartbeat ?? null;
2659
+ }
2660
+ function getHeartbeatAge(stateDir, instanceId) {
2661
+ const state = loadBridgeState(stateDir, instanceId);
2662
+ const heartbeat = resolveHeartbeatTimestamp(state);
2663
+ if (!heartbeat) return null;
2664
+ const heartbeatTime = new Date(heartbeat).getTime();
2665
+ if (isNaN(heartbeatTime)) return null;
2666
+ return Math.floor((Date.now() - heartbeatTime) / 1e3);
2667
+ }
2668
+ function getBridgeHeartbeatTimestamp(stateDir, instanceId) {
2669
+ return resolveHeartbeatTimestamp(loadBridgeState(stateDir, instanceId));
2670
+ }
2671
+ function getBridgeStatus(stateDir, instanceId) {
2672
+ const state = loadBridgeState(stateDir, instanceId);
2673
+ if (!state) return "stopped";
2674
+ if (!isProcessAlive(state.pid)) {
2675
+ clearBridgeState(stateDir, instanceId);
2676
+ return "stale";
2677
+ }
2678
+ return "running";
2679
+ }
2680
+ function getTurnInfo(stateDir, instanceId, stuckThresholdSeconds = 300) {
2681
+ const state = loadBridgeState(stateDir, instanceId);
2682
+ if (!state) return null;
2683
+ const heartbeat = loadRuntimeBridgeHeartbeat(state);
2684
+ if (!heartbeat) return null;
2685
+ const activeTurnId = heartbeat.activeTurnId ?? null;
2686
+ const lastTurnStatus = heartbeat.lastTurnStatus ?? null;
2687
+ const turnTimestamp = heartbeat.turnStartedAt ?? null;
2688
+ const updatedAt = turnTimestamp ?? heartbeat.updatedAt ?? null;
2689
+ let ageSeconds = null;
2690
+ if (turnTimestamp) {
2691
+ const ts = new Date(turnTimestamp).getTime();
2692
+ if (!isNaN(ts)) {
2693
+ ageSeconds = Math.floor((Date.now() - ts) / 1e3);
2694
+ }
2695
+ }
2696
+ const stuck = activeTurnId !== null && ageSeconds !== null && ageSeconds > stuckThresholdSeconds;
2697
+ return { activeTurnId, lastTurnStatus, updatedAt, ageSeconds, stuck };
2698
+ }
2699
+ function isTurnStuck(stateDir, instanceId, thresholdSeconds = 300) {
2700
+ const info = getTurnInfo(stateDir, instanceId, thresholdSeconds);
2701
+ return info?.stuck ?? false;
2702
+ }
2703
+ function rotateLog(logPath) {
2704
+ if (!fs18.existsSync(logPath)) return;
2705
+ try {
2706
+ const stats = fs18.statSync(logPath);
2707
+ if (stats.size === 0) return;
2708
+ const prevPath = `${logPath}.prev`;
2709
+ fs18.renameSync(logPath, prevPath);
2710
+ } catch {
2711
+ }
2712
+ }
2713
+
2714
+ // src/engine/bridge-app-server-health.ts
2715
+ var APP_SERVER_HEALTH_TIMEOUT_MS = 1500;
2716
+ var APP_SERVER_HEALTH_RETRY_MS = 250;
2717
+ var AUTH_SUBPROTOCOL_PREFIX = "tap-auth-";
2718
+ async function checkAppServerHealth(url, timeoutMs = APP_SERVER_HEALTH_TIMEOUT_MS, gatewayToken) {
2719
+ const WebSocket = getWebSocketCtor();
2720
+ if (!WebSocket) {
2721
+ return false;
2722
+ }
2723
+ return new Promise((resolve14) => {
2724
+ let settled = false;
2725
+ let socket = null;
2726
+ const finish = (healthy) => {
2727
+ if (settled) {
2728
+ return;
2729
+ }
2730
+ settled = true;
2731
+ clearTimeout(timer);
2732
+ try {
2733
+ socket?.close();
2734
+ } catch {
2735
+ }
2736
+ resolve14(healthy);
2737
+ };
2738
+ const timer = setTimeout(() => finish(false), timeoutMs);
2739
+ try {
2740
+ const protocols = gatewayToken ? [`${AUTH_SUBPROTOCOL_PREFIX}${gatewayToken}`] : void 0;
2741
+ socket = new WebSocket(url, protocols);
2742
+ socket.addEventListener("open", () => finish(true), { once: true });
2743
+ socket.addEventListener("error", () => finish(false), { once: true });
2744
+ socket.addEventListener("close", () => finish(false), { once: true });
2745
+ } catch {
2746
+ finish(false);
2747
+ }
2748
+ });
2749
+ }
2750
+ async function waitForAppServerHealth(url, timeoutMs, gatewayToken) {
2751
+ const deadline = Date.now() + timeoutMs;
2752
+ while (Date.now() < deadline) {
2753
+ if (await checkAppServerHealth(url, APP_SERVER_HEALTH_TIMEOUT_MS, gatewayToken)) {
2754
+ return true;
2755
+ }
2756
+ await delay(APP_SERVER_HEALTH_RETRY_MS);
2757
+ }
2758
+ return false;
2759
+ }
2760
+ function markAppServerHealthy(appServer) {
2761
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
2762
+ return {
2763
+ ...appServer,
2764
+ healthy: true,
2765
+ lastCheckedAt: checkedAt,
2766
+ lastHealthyAt: checkedAt
2767
+ };
2768
+ }
2769
+
2770
+ // src/engine/bridge-app-server-auth.ts
2771
+ import * as fs20 from "fs";
2772
+ import * as path18 from "path";
2773
+ import { randomBytes as randomBytes2 } from "crypto";
2774
+
2775
+ // src/runtime/resolve-node.ts
2776
+ import * as fs19 from "fs";
2777
+ import * as path17 from "path";
2778
+ import { execSync as execSync3 } from "child_process";
2779
+ function readNodeVersion(repoRoot) {
2780
+ const nvFile = path17.join(repoRoot, ".node-version");
2781
+ if (!fs19.existsSync(nvFile)) return null;
2782
+ try {
2783
+ const raw = fs19.readFileSync(nvFile, "utf-8").trim();
2784
+ return raw.length > 0 ? raw.replace(/^v/, "") : null;
2785
+ } catch {
2786
+ return null;
2787
+ }
2788
+ }
2789
+ function fnmCandidateDirs() {
2790
+ if (process.platform === "win32") {
2791
+ return [
2792
+ process.env.FNM_DIR,
2793
+ process.env.APPDATA ? path17.join(process.env.APPDATA, "fnm") : null,
2794
+ process.env.LOCALAPPDATA ? path17.join(process.env.LOCALAPPDATA, "fnm") : null,
2795
+ process.env.USERPROFILE ? path17.join(process.env.USERPROFILE, "scoop", "persist", "fnm") : null
2796
+ ].filter(Boolean);
2797
+ }
2798
+ return [
2799
+ process.env.FNM_DIR,
2800
+ process.env.HOME ? path17.join(process.env.HOME, ".local", "share", "fnm") : null,
2801
+ process.env.HOME ? path17.join(process.env.HOME, ".fnm") : null,
2802
+ process.env.XDG_DATA_HOME ? path17.join(process.env.XDG_DATA_HOME, "fnm") : null
2803
+ ].filter(Boolean);
2804
+ }
2805
+ function nodeExecutableName() {
2806
+ return process.platform === "win32" ? "node.exe" : "node";
2807
+ }
2808
+ function probeFnmNode(desiredVersion) {
2809
+ const dirs = fnmCandidateDirs();
2810
+ const exe = nodeExecutableName();
2811
+ for (const baseDir of dirs) {
2812
+ const candidate = path17.join(
2813
+ baseDir,
2814
+ "node-versions",
2815
+ `v${desiredVersion}`,
2816
+ "installation",
2817
+ exe
2818
+ );
2819
+ if (!fs19.existsSync(candidate)) continue;
2820
+ try {
2821
+ const v = execSync3(`"${candidate}" --version`, {
2822
+ encoding: "utf-8",
2823
+ timeout: 5e3
2824
+ }).trim();
2825
+ if (v.startsWith(`v${desiredVersion.split(".")[0]}.`)) {
2826
+ return candidate;
2827
+ }
2828
+ } catch {
2829
+ }
2830
+ }
2831
+ return null;
2832
+ }
2833
+ function detectNodeMajorVersion(command) {
2834
+ try {
2835
+ const version2 = execSync3(`"${command}" --version`, {
2836
+ encoding: "utf-8",
2837
+ timeout: 5e3
2838
+ }).trim();
2839
+ const match = version2.match(/^v?(\d+)\./);
2840
+ return match ? parseInt(match[1], 10) : null;
2841
+ } catch {
2842
+ return null;
2843
+ }
2844
+ }
2845
+ function checkStripTypesSupport(command) {
2846
+ const major = detectNodeMajorVersion(command);
2847
+ if (major !== null && major >= 22) return true;
2848
+ try {
2849
+ execSync3(`"${command}" --experimental-strip-types -e ""`, {
2850
+ timeout: 5e3,
2851
+ stdio: "pipe"
2852
+ });
2853
+ return true;
2854
+ } catch {
2855
+ return false;
2856
+ }
2857
+ }
2858
+ function findTsxFallback(repoRoot) {
2859
+ const candidates = [
2860
+ path17.join(repoRoot, "node_modules", ".bin", "tsx.exe"),
2861
+ path17.join(repoRoot, "node_modules", ".bin", "tsx.CMD"),
2862
+ path17.join(repoRoot, "node_modules", ".bin", "tsx")
2863
+ ];
2864
+ for (const c of candidates) {
2865
+ if (fs19.existsSync(c)) return c;
2866
+ }
2867
+ return null;
2868
+ }
2869
+ function getFnmBinDir(repoRoot) {
2870
+ const desiredVersion = readNodeVersion(repoRoot);
2871
+ if (!desiredVersion) return null;
2872
+ const nodePath = probeFnmNode(desiredVersion);
2873
+ if (!nodePath) return null;
2874
+ return path17.dirname(nodePath);
2875
+ }
2876
+ function resolveNodeRuntime(configCommand, repoRoot) {
2877
+ if (configCommand === "bun" || configCommand.endsWith("bun.exe")) {
2878
+ return {
2879
+ command: configCommand,
2880
+ supportsStripTypes: false,
2881
+ source: "bun",
2882
+ majorVersion: null
2883
+ };
2884
+ }
2885
+ const desiredVersion = readNodeVersion(repoRoot);
2886
+ if (desiredVersion) {
2887
+ const fnmNode = probeFnmNode(desiredVersion);
2888
+ if (fnmNode) {
2889
+ const major2 = detectNodeMajorVersion(fnmNode);
2890
+ return {
2891
+ command: fnmNode,
2892
+ supportsStripTypes: checkStripTypesSupport(fnmNode),
2893
+ source: "fnm",
2894
+ majorVersion: major2
2895
+ };
2896
+ }
2897
+ }
2898
+ const major = detectNodeMajorVersion(configCommand);
2899
+ if (major !== null) {
2900
+ return {
2901
+ command: configCommand,
2902
+ supportsStripTypes: checkStripTypesSupport(configCommand),
2903
+ source: major === detectNodeMajorVersion("node") ? "path" : "config",
2904
+ majorVersion: major
2905
+ };
2906
+ }
2907
+ const tsx = findTsxFallback(repoRoot);
2908
+ if (tsx) {
2909
+ return {
2910
+ command: tsx,
2911
+ supportsStripTypes: false,
2912
+ source: "tsx-fallback",
2913
+ majorVersion: null
2914
+ };
2915
+ }
2916
+ return {
2917
+ command: configCommand,
2918
+ supportsStripTypes: false,
2919
+ source: "path",
2920
+ majorVersion: null
2921
+ };
2345
2922
  }
2346
- async function allocateLoopbackPort(hostname) {
2347
- const bindHost = hostname === "localhost" ? "127.0.0.1" : hostname;
2348
- return await new Promise((resolve13, reject) => {
2349
- const server = net.createServer();
2350
- server.unref();
2351
- server.once("error", reject);
2352
- server.listen(0, bindHost, () => {
2353
- const address = server.address();
2354
- if (!address || typeof address === "string") {
2355
- server.close(() => {
2356
- reject(new Error("Failed to allocate a loopback port"));
2357
- });
2358
- return;
2359
- }
2360
- const port = address.port;
2361
- server.close((error) => {
2362
- if (error) {
2363
- reject(error);
2364
- return;
2365
- }
2366
- resolve13(port);
2367
- });
2368
- });
2369
- });
2923
+ function buildRuntimeEnv(repoRoot, baseEnv = process.env) {
2924
+ const fnmBin = getFnmBinDir(repoRoot);
2925
+ if (!fnmBin) return { ...baseEnv };
2926
+ const pathKey = process.platform === "win32" ? "Path" : "PATH";
2927
+ const currentPath = baseEnv[pathKey] ?? baseEnv.PATH ?? "";
2928
+ return {
2929
+ ...baseEnv,
2930
+ [pathKey]: `${fnmBin}${path17.delimiter}${currentPath}`
2931
+ };
2370
2932
  }
2933
+
2934
+ // src/engine/bridge-app-server-auth.ts
2371
2935
  function buildProtectedAppServerUrl(publicUrl, _token) {
2372
2936
  return publicUrl;
2373
2937
  }
2374
2938
  function readGatewayTokenFromPath(tokenPath) {
2375
- return fs13.readFileSync(tokenPath, "utf8").trim();
2939
+ return fs20.readFileSync(tokenPath, "utf8").trim();
2376
2940
  }
2377
2941
  function readGatewayToken(auth) {
2378
2942
  if (!auth) {
@@ -2382,14 +2946,14 @@ function readGatewayToken(auth) {
2382
2946
  if (legacyToken?.trim()) {
2383
2947
  return legacyToken.trim();
2384
2948
  }
2385
- if (!auth.tokenPath || !fs13.existsSync(auth.tokenPath)) {
2949
+ if (!auth.tokenPath || !fs20.existsSync(auth.tokenPath)) {
2386
2950
  return null;
2387
2951
  }
2388
2952
  const fileToken = readGatewayTokenFromPath(auth.tokenPath);
2389
2953
  return fileToken || null;
2390
2954
  }
2391
2955
  function materializeGatewayTokenFile(stateDir, instanceId, publicUrl, auth) {
2392
- if (auth.tokenPath && fs13.existsSync(auth.tokenPath)) {
2956
+ if (auth.tokenPath && fs20.existsSync(auth.tokenPath)) {
2393
2957
  return auth;
2394
2958
  }
2395
2959
  const token = readGatewayToken(auth);
@@ -2415,7 +2979,7 @@ async function createManagedAppServerAuth(options) {
2415
2979
  if (!gatewayScript) {
2416
2980
  throw new Error("Auth gateway script not found");
2417
2981
  }
2418
- const token = randomBytes(24).toString("base64url");
2982
+ const token = randomBytes2(24).toString("base64url");
2419
2983
  const tokenPath = appServerGatewayTokenFilePath(
2420
2984
  options.stateDir,
2421
2985
  options.instanceId
@@ -2427,7 +2991,7 @@ async function createManagedAppServerAuth(options) {
2427
2991
  options.stateDir,
2428
2992
  options.instanceId
2429
2993
  );
2430
- fs13.mkdirSync(path13.dirname(gatewayLogPath), { recursive: true });
2994
+ fs20.mkdirSync(path18.dirname(gatewayLogPath), { recursive: true });
2431
2995
  rotateLog(gatewayLogPath);
2432
2996
  const runtime = resolveNodeRuntime(process.execPath, options.repoRoot);
2433
2997
  const gatewayArgs = [];
@@ -2447,37 +3011,23 @@ async function createManagedAppServerAuth(options) {
2447
3011
  TAP_GATEWAY_TOKEN_FILE: tokenPath
2448
3012
  };
2449
3013
  let gatewayPid;
2450
- {
2451
- let logFd = null;
2452
- try {
2453
- if (options.platform === "win32") {
2454
- gatewayPid = startWindowsDetachedProcess(
2455
- runtime.command,
2456
- gatewayArgs,
2457
- options.repoRoot,
2458
- gatewayLogPath,
2459
- gatewayEnv
2460
- );
2461
- } else {
2462
- logFd = fs13.openSync(gatewayLogPath, "a");
2463
- const child = spawn(runtime.command, gatewayArgs, {
2464
- cwd: options.repoRoot,
2465
- detached: true,
2466
- stdio: ["ignore", logFd, logFd],
2467
- env: gatewayEnv,
2468
- windowsHide: true
2469
- });
2470
- child.unref();
2471
- gatewayPid = child.pid ?? null;
2472
- }
2473
- } catch (error) {
2474
- removeFileIfExists(tokenPath);
2475
- throw error;
2476
- } finally {
2477
- if (logFd != null) {
2478
- fs13.closeSync(logFd);
2479
- }
2480
- }
3014
+ try {
3015
+ gatewayPid = options.platform === "win32" ? startWindowsDetachedProcess(
3016
+ runtime.command,
3017
+ gatewayArgs,
3018
+ options.repoRoot,
3019
+ gatewayLogPath,
3020
+ gatewayEnv
3021
+ ) : startUnixDetachedProcess(
3022
+ runtime.command,
3023
+ gatewayArgs,
3024
+ options.repoRoot,
3025
+ gatewayLogPath,
3026
+ gatewayEnv
3027
+ );
3028
+ } catch (error) {
3029
+ removeFileIfExists(tokenPath);
3030
+ throw error;
2481
3031
  }
2482
3032
  if (gatewayPid == null) {
2483
3033
  removeFileIfExists(tokenPath);
@@ -2510,259 +3060,70 @@ function canReuseManagedAppServer(appServer) {
2510
3060
  if (auth.gatewayPid != null && !isProcessAlive(auth.gatewayPid)) {
2511
3061
  return false;
2512
3062
  }
2513
- }
2514
- return true;
2515
- }
2516
- function markAppServerHealthy(appServer) {
2517
- const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
2518
- return {
2519
- ...appServer,
2520
- healthy: true,
2521
- lastCheckedAt: checkedAt,
2522
- lastHealthyAt: checkedAt
2523
- };
2524
- }
2525
- function findReusableManagedAppServer(stateDir, publicUrl) {
2526
- const pidDir = path13.join(stateDir, "pids");
2527
- if (!fs13.existsSync(pidDir)) {
2528
- return null;
2529
- }
2530
- for (const name of fs13.readdirSync(pidDir)) {
2531
- if (!name.startsWith("bridge-") || !name.endsWith(".json")) {
2532
- continue;
2533
- }
2534
- try {
2535
- const raw = fs13.readFileSync(path13.join(pidDir, name), "utf-8");
2536
- const parsed = JSON.parse(raw);
2537
- if (parsed.appServer?.url !== publicUrl) {
2538
- continue;
2539
- }
2540
- if (canReuseManagedAppServer(parsed.appServer)) {
2541
- return markAppServerHealthy(parsed.appServer);
2542
- }
2543
- } catch {
2544
- }
2545
- }
2546
- return null;
2547
- }
2548
- function startWindowsDetachedProcess(command, args, repoRoot, logPath, env = process.env) {
2549
- const stderrLogPath = stderrLogFilePath(logPath);
2550
- const powerShellCommand = resolvePowerShellCommand();
2551
- cleanupStaleWindowsSpawnWrappers();
2552
- const wrapperPath = path13.join(
2553
- os3.tmpdir(),
2554
- `${WINDOWS_SPAWN_WRAPPER_PREFIX}${randomBytes(4).toString("hex")}.ps1`
2555
- );
2556
- fs13.writeFileSync(
2557
- wrapperPath,
2558
- buildWindowsDetachedWrapperScript(
2559
- command,
2560
- args,
2561
- logPath,
2562
- stderrLogPath,
2563
- env
2564
- )
2565
- );
2566
- const psCommand = [
2567
- "$p = Start-Process",
2568
- `-FilePath ${toPowerShellSingleQuotedString(powerShellCommand)}`,
2569
- `-ArgumentList ${toPowerShellStringArrayLiteral(["-NoLogo", "-NoProfile", "-File", wrapperPath])}`,
2570
- `-WorkingDirectory ${toPowerShellSingleQuotedString(repoRoot)}`,
2571
- "-WindowStyle Hidden",
2572
- "-PassThru",
2573
- "; Write-Output $p.Id"
2574
- ].join(" ");
2575
- const result = spawnSync3(
2576
- powerShellCommand,
2577
- ["-NoLogo", "-NoProfile", "-Command", psCommand],
2578
- {
2579
- encoding: "utf-8",
2580
- windowsHide: true
2581
- }
2582
- );
2583
- if (result.status !== 0) {
2584
- removeFileIfExists(wrapperPath);
2585
- return null;
2586
- }
2587
- const pid = parseInt(result.stdout.trim(), 10);
2588
- if (!Number.isFinite(pid)) {
2589
- removeFileIfExists(wrapperPath);
2590
- return null;
2591
- }
2592
- return pid;
2593
- }
2594
- function startWindowsCodexAppServer(command, url, repoRoot, logPath) {
2595
- return startWindowsDetachedProcess(
2596
- command,
2597
- ["app-server", "--listen", url],
2598
- repoRoot,
2599
- logPath
2600
- );
2601
- }
2602
- function findListeningProcessId(url, platform) {
2603
- if (platform !== "win32") {
2604
- return null;
2605
- }
2606
- let port;
2607
- try {
2608
- const parsed = new URL(url);
2609
- port = parsed.port ? Number.parseInt(parsed.port, 10) : null;
2610
- } catch {
2611
- return null;
2612
- }
2613
- if (port == null || !Number.isFinite(port)) {
2614
- return null;
2615
- }
2616
- const result = spawnSync3(
2617
- resolvePowerShellCommand(),
2618
- [
2619
- "-NoLogo",
2620
- "-NoProfile",
2621
- "-Command",
2622
- [
2623
- `$port = ${port}`,
2624
- "$processId = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess",
2625
- "if ($processId) { $processId }"
2626
- ].join("; ")
2627
- ],
2628
- {
2629
- encoding: "utf-8",
2630
- windowsHide: true
2631
- }
2632
- );
2633
- if (result.status !== 0) {
2634
- return null;
2635
- }
2636
- const parsedPid = Number.parseInt((result.stdout ?? "").trim(), 10);
2637
- return Number.isFinite(parsedPid) ? parsedPid : null;
2638
- }
2639
- function resolveAppServerUrl(baseUrl, port) {
2640
- const resolvedBase = (baseUrl ?? DEFAULT_APP_SERVER_URL2).replace(/\/$/, "");
2641
- if (port == null) {
2642
- return resolvedBase;
2643
- }
2644
- try {
2645
- const parsed = new URL(resolvedBase);
2646
- parsed.port = String(port);
2647
- return parsed.toString().replace(/\/$/, "");
2648
- } catch {
2649
- return resolvedBase;
2650
- }
2651
- }
2652
- async function isTcpPortAvailable(hostname, port) {
2653
- const bindHost = hostname === "localhost" ? "127.0.0.1" : hostname;
2654
- return await new Promise((resolve13) => {
2655
- const server = net.createServer();
2656
- server.unref();
2657
- server.once("error", () => resolve13(false));
2658
- server.listen(port, bindHost, () => {
2659
- server.close((error) => resolve13(!error));
2660
- });
2661
- });
2662
- }
2663
- async function findNextAvailableAppServerPort(state, baseUrl, basePort = 4501, excludeInstanceId) {
2664
- let hostname = "127.0.0.1";
2665
- try {
2666
- hostname = new URL(baseUrl ?? DEFAULT_APP_SERVER_URL2).hostname;
2667
- } catch {
2668
- }
2669
- const maxAttempts = 1e3;
2670
- let port = basePort;
2671
- for (let attempt = 0; attempt < maxAttempts; attempt += 1, port += 1) {
2672
- const claimedInState = Object.entries(state.instances).some(
2673
- ([id, inst]) => id !== excludeInstanceId && inst.port === port
2674
- );
2675
- if (claimedInState) {
2676
- continue;
2677
- }
2678
- if (!isLoopbackHost(hostname)) {
2679
- return port;
2680
- }
2681
- if (await isTcpPortAvailable(hostname, port)) {
2682
- return port;
2683
- }
2684
- }
2685
- throw new Error(
2686
- `Failed to find a free app-server port starting at ${basePort}`
2687
- );
2688
- }
2689
- async function checkAppServerHealth(url, timeoutMs = APP_SERVER_HEALTH_TIMEOUT_MS, gatewayToken) {
2690
- const WebSocket = getWebSocketCtor();
2691
- if (!WebSocket) {
2692
- return false;
2693
- }
2694
- return new Promise((resolve13) => {
2695
- let settled = false;
2696
- let socket = null;
2697
- const finish = (healthy) => {
2698
- if (settled) {
2699
- return;
2700
- }
2701
- settled = true;
2702
- clearTimeout(timer);
2703
- try {
2704
- socket?.close();
2705
- } catch {
2706
- }
2707
- resolve13(healthy);
2708
- };
2709
- const timer = setTimeout(() => finish(false), timeoutMs);
2710
- try {
2711
- const protocols = gatewayToken ? [`${AUTH_SUBPROTOCOL_PREFIX}${gatewayToken}`] : void 0;
2712
- socket = new WebSocket(url, protocols);
2713
- socket.addEventListener("open", () => finish(true), { once: true });
2714
- socket.addEventListener("error", () => finish(false), { once: true });
2715
- socket.addEventListener("close", () => finish(false), { once: true });
2716
- } catch {
2717
- finish(false);
2718
- }
2719
- });
3063
+ }
3064
+ return true;
2720
3065
  }
2721
- async function waitForAppServerHealth(url, timeoutMs, gatewayToken) {
2722
- const deadline = Date.now() + timeoutMs;
2723
- while (Date.now() < deadline) {
2724
- if (await checkAppServerHealth(
2725
- url,
2726
- APP_SERVER_HEALTH_TIMEOUT_MS,
2727
- gatewayToken
2728
- )) {
2729
- return true;
3066
+
3067
+ // src/engine/bridge-app-server-lifecycle.ts
3068
+ import * as fs21 from "fs";
3069
+ import * as path19 from "path";
3070
+ var DEFAULT_APP_SERVER_URL3 = "ws://127.0.0.1:4501";
3071
+ var APP_SERVER_START_TIMEOUT_MS = 2e4;
3072
+ var APP_SERVER_GATEWAY_START_TIMEOUT_MS = 5e3;
3073
+ function isAppServerUsedByOtherBridge(stateDir, excludeInstanceId, appServer) {
3074
+ const pidDir = path19.join(stateDir, "pids");
3075
+ if (!fs21.existsSync(pidDir)) return false;
3076
+ for (const name of fs21.readdirSync(pidDir)) {
3077
+ if (!name.startsWith("bridge-") || !name.endsWith(".json")) continue;
3078
+ const otherId = name.slice("bridge-".length, -".json".length);
3079
+ if (otherId === excludeInstanceId) continue;
3080
+ try {
3081
+ const raw = fs21.readFileSync(path19.join(pidDir, name), "utf-8");
3082
+ const state = JSON.parse(raw);
3083
+ if (state.appServer?.url === appServer.url && state.appServer?.pid === appServer.pid && isProcessAlive(state.pid)) {
3084
+ return true;
3085
+ }
3086
+ } catch {
3087
+ continue;
2730
3088
  }
2731
- await delay(APP_SERVER_HEALTH_RETRY_MS);
2732
3089
  }
2733
3090
  return false;
2734
3091
  }
2735
- async function terminateProcess(pid, platform) {
2736
- if (!isProcessAlive(pid)) {
2737
- return false;
3092
+ function findReusableManagedAppServer(stateDir, publicUrl) {
3093
+ const pidDir = path19.join(stateDir, "pids");
3094
+ if (!fs21.existsSync(pidDir)) {
3095
+ return null;
2738
3096
  }
2739
- try {
2740
- if (platform === "win32") {
2741
- execSync3(`taskkill /PID ${pid} /F /T`, { stdio: "pipe" });
2742
- } else {
2743
- process.kill(pid, "SIGTERM");
2744
- await delay(2e3);
2745
- if (isProcessAlive(pid)) {
2746
- process.kill(pid, "SIGKILL");
3097
+ for (const name of fs21.readdirSync(pidDir)) {
3098
+ if (!name.startsWith("bridge-") || !name.endsWith(".json")) {
3099
+ continue;
3100
+ }
3101
+ try {
3102
+ const raw = fs21.readFileSync(path19.join(pidDir, name), "utf-8");
3103
+ const parsed = JSON.parse(raw);
3104
+ if (parsed.appServer?.url !== publicUrl) {
3105
+ continue;
3106
+ }
3107
+ if (canReuseManagedAppServer(parsed.appServer)) {
3108
+ return markAppServerHealthy(parsed.appServer);
2747
3109
  }
3110
+ } catch {
2748
3111
  }
2749
- } catch {
2750
3112
  }
2751
- return !isProcessAlive(pid);
3113
+ return null;
2752
3114
  }
2753
- async function stopManagedAppServer(appServer, platform) {
2754
- if (!appServer.managed) {
2755
- return false;
2756
- }
2757
- let stopped = false;
2758
- if (appServer.auth?.gatewayPid != null) {
2759
- stopped = await terminateProcess(appServer.auth.gatewayPid, platform) || stopped;
3115
+ function resolveAppServerUrl(baseUrl, port) {
3116
+ const resolvedBase = (baseUrl ?? DEFAULT_APP_SERVER_URL3).replace(/\/$/, "");
3117
+ if (port == null) {
3118
+ return resolvedBase;
2760
3119
  }
2761
- if (appServer.pid != null) {
2762
- stopped = await terminateProcess(appServer.pid, platform) || stopped;
3120
+ try {
3121
+ const parsed = new URL(resolvedBase);
3122
+ parsed.port = String(port);
3123
+ return parsed.toString().replace(/\/$/, "");
3124
+ } catch {
3125
+ return resolvedBase;
2763
3126
  }
2764
- removeFileIfExists(appServer.auth?.tokenPath);
2765
- return stopped;
2766
3127
  }
2767
3128
  async function ensureCodexAppServer(options) {
2768
3129
  const effectiveUrl = resolveAppServerUrl(options.appServerUrl);
@@ -2810,7 +3171,7 @@ Start the app-server manually:
2810
3171
  );
2811
3172
  }
2812
3173
  const logPath = appServerLogFilePath(options.stateDir, options.instanceId);
2813
- fs13.mkdirSync(path13.dirname(logPath), { recursive: true });
3174
+ fs21.mkdirSync(path19.dirname(logPath), { recursive: true });
2814
3175
  rotateLog(logPath);
2815
3176
  if (options.noAuth) {
2816
3177
  const manualCommand2 = formatCodexAppServerCommand("codex", effectiveUrl);
@@ -2832,21 +3193,13 @@ Start it manually:
2832
3193
  );
2833
3194
  }
2834
3195
  } else {
2835
- const logFd = fs13.openSync(logPath, "a");
2836
3196
  try {
2837
- const child = spawn(
3197
+ pid2 = startUnixCodexAppServer(
2838
3198
  resolvedCommand,
2839
- ["app-server", "--listen", effectiveUrl],
2840
- {
2841
- cwd: options.repoRoot,
2842
- detached: true,
2843
- stdio: ["ignore", logFd, logFd],
2844
- env: process.env,
2845
- windowsHide: true
2846
- }
3199
+ effectiveUrl,
3200
+ options.repoRoot,
3201
+ logPath
2847
3202
  );
2848
- child.unref();
2849
- pid2 = child.pid ?? null;
2850
3203
  } catch (err) {
2851
3204
  throw new Error(
2852
3205
  `Failed to spawn Codex app-server: ${err instanceof Error ? err.message : String(err)}
@@ -2854,8 +3207,6 @@ Start it manually:
2854
3207
  ${manualCommand2}`,
2855
3208
  { cause: err }
2856
3209
  );
2857
- } finally {
2858
- fs13.closeSync(logFd);
2859
3210
  }
2860
3211
  }
2861
3212
  if (pid2 == null) {
@@ -2878,7 +3229,7 @@ Or start it manually:
2878
3229
  ${manualCommand2}`
2879
3230
  );
2880
3231
  }
2881
- pid2 = findListeningProcessId(effectiveUrl, options.platform) ?? pid2;
3232
+ pid2 = (options.platform === "win32" ? findListeningProcessId(effectiveUrl, options.platform) : findUnixListeningProcessId(effectiveUrl, options.platform)) ?? pid2;
2882
3233
  const healthyAt2 = (/* @__PURE__ */ new Date()).toISOString();
2883
3234
  return {
2884
3235
  url: effectiveUrl,
@@ -2922,21 +3273,13 @@ Start it manually:
2922
3273
  );
2923
3274
  }
2924
3275
  } else {
2925
- const logFd = fs13.openSync(logPath, "a");
2926
3276
  try {
2927
- const child = spawn(
3277
+ pid = startUnixCodexAppServer(
2928
3278
  resolvedCommand,
2929
- ["app-server", "--listen", auth.upstreamUrl],
2930
- {
2931
- cwd: options.repoRoot,
2932
- detached: true,
2933
- stdio: ["ignore", logFd, logFd],
2934
- env: process.env,
2935
- windowsHide: true
2936
- }
3279
+ auth.upstreamUrl,
3280
+ options.repoRoot,
3281
+ logPath
2937
3282
  );
2938
- child.unref();
2939
- pid = child.pid ?? null;
2940
3283
  } catch (err) {
2941
3284
  if (auth.gatewayPid != null) {
2942
3285
  await terminateProcess(auth.gatewayPid, options.platform);
@@ -2948,8 +3291,6 @@ Start it manually:
2948
3291
  ${manualCommand}`,
2949
3292
  { cause: err }
2950
3293
  );
2951
- } finally {
2952
- fs13.closeSync(logFd);
2953
3294
  }
2954
3295
  }
2955
3296
  if (pid == null) {
@@ -3006,7 +3347,7 @@ Check ${auth.gatewayLogPath ?? "the gateway log"} and ${logPath}.`
3006
3347
  );
3007
3348
  }
3008
3349
  const healthyAt = (/* @__PURE__ */ new Date()).toISOString();
3009
- pid = findListeningProcessId(auth.upstreamUrl, options.platform) ?? pid;
3350
+ pid = (options.platform === "win32" ? findListeningProcessId(auth.upstreamUrl, options.platform) : findUnixListeningProcessId(auth.upstreamUrl, options.platform)) ?? pid;
3010
3351
  return {
3011
3352
  url: effectiveUrl,
3012
3353
  pid,
@@ -3019,130 +3360,15 @@ Check ${auth.gatewayLogPath ?? "the gateway log"} and ${logPath}.`
3019
3360
  auth
3020
3361
  };
3021
3362
  }
3022
- function pidFilePath(stateDir, instanceId) {
3023
- return path13.join(stateDir, "pids", `bridge-${instanceId}.json`);
3024
- }
3025
- function logFilePath(stateDir, instanceId) {
3026
- return path13.join(stateDir, "logs", `bridge-${instanceId}.log`);
3027
- }
3028
- function runtimeHeartbeatFilePath(runtimeStateDir) {
3029
- return path13.join(runtimeStateDir, "heartbeat.json");
3030
- }
3031
- function runtimeThreadStateFilePath(runtimeStateDir) {
3032
- return path13.join(runtimeStateDir, "thread.json");
3033
- }
3034
- function loadRuntimeBridgeHeartbeat(bridgeState) {
3035
- const runtimeStateDir = bridgeState?.runtimeStateDir;
3036
- if (!runtimeStateDir) {
3037
- return null;
3038
- }
3039
- const heartbeatPath = runtimeHeartbeatFilePath(runtimeStateDir);
3040
- if (!fs13.existsSync(heartbeatPath)) {
3041
- return null;
3042
- }
3043
- try {
3044
- return JSON.parse(
3045
- fs13.readFileSync(heartbeatPath, "utf-8")
3046
- );
3047
- } catch {
3048
- return null;
3049
- }
3050
- }
3051
- function loadRuntimeBridgeThreadState(bridgeState) {
3052
- const runtimeStateDir = bridgeState?.runtimeStateDir;
3053
- if (!runtimeStateDir) {
3054
- return null;
3055
- }
3056
- const threadPath = runtimeThreadStateFilePath(runtimeStateDir);
3057
- if (!fs13.existsSync(threadPath)) {
3058
- return null;
3059
- }
3060
- try {
3061
- const parsed = JSON.parse(
3062
- fs13.readFileSync(threadPath, "utf-8")
3063
- );
3064
- return parsed.threadId ? parsed : null;
3065
- } catch {
3066
- return null;
3067
- }
3068
- }
3069
- function loadRuntimeHeartbeatTimestamp(runtimeStateDir) {
3070
- const heartbeat = loadRuntimeBridgeHeartbeat({ runtimeStateDir });
3071
- return typeof heartbeat?.updatedAt === "string" ? heartbeat.updatedAt : null;
3072
- }
3073
- function resolveHeartbeatTimestamp(state) {
3074
- return loadRuntimeHeartbeatTimestamp(state?.runtimeStateDir) ?? state?.lastHeartbeat ?? null;
3075
- }
3076
- function loadBridgeState(stateDir, instanceId) {
3077
- const pidPath = pidFilePath(stateDir, instanceId);
3078
- if (!fs13.existsSync(pidPath)) return null;
3079
- try {
3080
- const raw = fs13.readFileSync(pidPath, "utf-8");
3081
- return JSON.parse(raw);
3082
- } catch {
3083
- return null;
3084
- }
3085
- }
3086
- function saveBridgeState(stateDir, instanceId, state) {
3087
- const pidPath = pidFilePath(stateDir, instanceId);
3088
- const serializable = JSON.parse(JSON.stringify(state));
3089
- if (serializable.appServer?.auth) {
3090
- delete serializable.appServer.auth.token;
3091
- }
3092
- writeProtectedTextFile(pidPath, JSON.stringify(serializable, null, 2));
3093
- }
3094
- function clearBridgeState(stateDir, instanceId) {
3095
- const pidPath = pidFilePath(stateDir, instanceId);
3096
- if (fs13.existsSync(pidPath)) {
3097
- fs13.unlinkSync(pidPath);
3098
- }
3099
- }
3100
- function isProcessAlive(pid) {
3101
- try {
3102
- process.kill(pid, 0);
3103
- return true;
3104
- } catch {
3105
- return false;
3106
- }
3107
- }
3108
- function isBridgeRunning(stateDir, instanceId) {
3109
- const state = loadBridgeState(stateDir, instanceId);
3110
- if (!state) return false;
3111
- return isProcessAlive(state.pid);
3112
- }
3113
- function resolveAgentName(instanceId, explicit, context) {
3114
- if (explicit) return explicit;
3115
- try {
3116
- const repoRoot = context?.repoRoot ?? context?.stateDir?.replace(/[\\/].tap-comms$/, "") ?? process.cwd();
3117
- const state = loadState(repoRoot);
3118
- const stateAgent = state?.instances[instanceId]?.agentName;
3119
- if (stateAgent) return stateAgent;
3120
- } catch {
3121
- }
3122
- return process.env.TAP_AGENT_NAME || process.env.CODEX_TAP_AGENT_NAME || null;
3123
- }
3124
- function inferRestartMode(bridgeState, flags, savedMode) {
3125
- const wasManaged = bridgeState?.appServer != null;
3126
- const hadAuth = bridgeState?.appServer?.auth != null;
3127
- const manageAppServer = flags?.noServer === true ? false : flags?.noServer === void 0 ? savedMode?.manageAppServer ?? wasManaged : true;
3128
- const noAuth = flags?.noAuth === true ? true : flags?.noAuth === void 0 ? savedMode?.noAuth ?? !hadAuth : false;
3129
- return { manageAppServer, noAuth };
3363
+ function formatCodexAppServerCommand(command, url) {
3364
+ return `${command} app-server --listen ${url}`;
3130
3365
  }
3131
- function cleanupHeadlessDispatch(inboxDir, agentName) {
3132
- const removed = [];
3133
- if (!fs13.existsSync(inboxDir)) return removed;
3134
- const normalizedAgent = agentName.replace(/-/g, "_");
3135
- const marker = `-headless-${normalizedAgent}-review-`;
3136
- try {
3137
- for (const file of fs13.readdirSync(inboxDir)) {
3138
- if (file.includes(marker)) {
3139
- fs13.unlinkSync(path13.join(inboxDir, file));
3140
- removed.push(file);
3141
- }
3142
- }
3143
- } catch {
3144
- }
3145
- return removed;
3366
+
3367
+ // src/engine/bridge-startup.ts
3368
+ import * as fs22 from "fs";
3369
+ import * as path20 from "path";
3370
+ function getBridgeRuntimeStateDir(repoRoot, instanceId) {
3371
+ return path20.join(repoRoot, ".tmp", `codex-app-server-bridge-${instanceId}`);
3146
3372
  }
3147
3373
  async function startBridge(options) {
3148
3374
  const {
@@ -3173,10 +3399,9 @@ async function startBridge(options) {
3173
3399
  const previousAppServer = previousBridgeState?.appServer ?? null;
3174
3400
  clearBridgeState(stateDir, instanceId);
3175
3401
  const logPath = logFilePath(stateDir, instanceId);
3176
- fs13.mkdirSync(path13.dirname(logPath), { recursive: true });
3402
+ fs22.mkdirSync(path20.dirname(logPath), { recursive: true });
3177
3403
  rotateLog(logPath);
3178
- let logFd = null;
3179
- const repoRoot = options.repoRoot ?? path13.resolve(stateDir, "..");
3404
+ const repoRoot = options.repoRoot ?? path20.resolve(stateDir, "..");
3180
3405
  const runtimeStateDir = getBridgeRuntimeStateDir(repoRoot, instanceId);
3181
3406
  const resolved = resolveNodeRuntime(
3182
3407
  options.runtimeCommand ?? "node",
@@ -3244,30 +3469,19 @@ async function startBridge(options) {
3244
3469
  ...options.ephemeral ? { TAP_EPHEMERAL: "true" } : {},
3245
3470
  ...options.processExistingMessages ? { TAP_PROCESS_EXISTING: "true" } : {}
3246
3471
  };
3247
- let bridgePid = null;
3248
- if (options.platform === "win32") {
3249
- bridgePid = startWindowsDetachedProcess(
3250
- command,
3251
- [bridgeScript],
3252
- repoRoot,
3253
- logPath,
3254
- bridgeEnv
3255
- );
3256
- } else {
3257
- logFd = fs13.openSync(logPath, "a");
3258
- const child = spawn(command, [bridgeScript], {
3259
- detached: true,
3260
- stdio: ["ignore", logFd, logFd],
3261
- env: bridgeEnv,
3262
- windowsHide: true
3263
- });
3264
- child.unref();
3265
- bridgePid = child.pid ?? null;
3266
- }
3267
- if (logFd != null) {
3268
- fs13.closeSync(logFd);
3269
- logFd = null;
3270
- }
3472
+ const bridgePid = options.platform === "win32" ? startWindowsDetachedProcess(
3473
+ command,
3474
+ [bridgeScript],
3475
+ repoRoot,
3476
+ logPath,
3477
+ bridgeEnv
3478
+ ) : startUnixDetachedProcess(
3479
+ command,
3480
+ [bridgeScript],
3481
+ repoRoot,
3482
+ logPath,
3483
+ bridgeEnv
3484
+ );
3271
3485
  if (!bridgePid) {
3272
3486
  throw new Error(`Failed to spawn bridge process for ${instanceId}`);
3273
3487
  }
@@ -3281,18 +3495,23 @@ async function startBridge(options) {
3281
3495
  saveBridgeState(stateDir, instanceId, state);
3282
3496
  return state;
3283
3497
  } catch (err) {
3284
- if (logFd != null) {
3285
- try {
3286
- fs13.closeSync(logFd);
3287
- } catch {
3288
- }
3289
- }
3290
3498
  if (appServer?.managed) {
3291
- await stopManagedAppServer(appServer, options.platform);
3499
+ const shared = isAppServerUsedByOtherBridge(
3500
+ stateDir,
3501
+ instanceId,
3502
+ appServer
3503
+ );
3504
+ if (!shared) {
3505
+ await stopManagedAppServer(appServer, options.platform);
3506
+ }
3292
3507
  }
3293
3508
  throw err;
3294
3509
  }
3295
3510
  }
3511
+
3512
+ // src/engine/bridge-orchestrator.ts
3513
+ import * as fs23 from "fs";
3514
+ import * as path21 from "path";
3296
3515
  async function stopBridge(options) {
3297
3516
  const { instanceId, stateDir, platform } = options;
3298
3517
  const state = loadBridgeState(stateDir, instanceId);
@@ -3315,59 +3534,28 @@ async function restartBridge(options) {
3315
3534
  const drainTimeout = (options.drainTimeoutSeconds ?? 30) * 1e3;
3316
3535
  const repoRoot = options.repoRoot ?? stateDir.replace(/[\\/].tap-comms$/, "");
3317
3536
  const runtimeStateDir = getBridgeRuntimeStateDir(repoRoot, instanceId);
3318
- const heartbeatPath = path13.join(runtimeStateDir, "heartbeat.json");
3319
- if (fs13.existsSync(heartbeatPath)) {
3537
+ const heartbeatPath = path21.join(runtimeStateDir, "heartbeat.json");
3538
+ if (fs23.existsSync(heartbeatPath)) {
3320
3539
  const startWait = Date.now();
3321
3540
  while (Date.now() - startWait < drainTimeout) {
3322
3541
  try {
3323
- const hb = JSON.parse(fs13.readFileSync(heartbeatPath, "utf-8"));
3542
+ const hb = JSON.parse(fs23.readFileSync(heartbeatPath, "utf-8"));
3324
3543
  if (!hb.activeTurnId) break;
3325
3544
  } catch {
3326
3545
  break;
3327
3546
  }
3328
- await new Promise((r) => setTimeout(r, 1e3));
3547
+ await new Promise((resolve14) => setTimeout(resolve14, 1e3));
3329
3548
  }
3330
3549
  }
3331
3550
  if (options.headless?.enabled && options.commsDir) {
3332
3551
  const agentName = options.agentName ?? instanceId;
3333
- cleanupHeadlessDispatch(path13.join(options.commsDir, "inbox"), agentName);
3552
+ cleanupHeadlessDispatch(path21.join(options.commsDir, "inbox"), agentName);
3334
3553
  }
3335
3554
  await stopBridge({ instanceId, stateDir, platform });
3336
- const restartOptions = {
3555
+ return startBridge({
3337
3556
  ...options,
3338
3557
  processExistingMessages: true
3339
- };
3340
- return startBridge(restartOptions);
3341
- }
3342
- function rotateLog(logPath) {
3343
- if (!fs13.existsSync(logPath)) return;
3344
- try {
3345
- const stats = fs13.statSync(logPath);
3346
- if (stats.size === 0) return;
3347
- const prevPath = `${logPath}.prev`;
3348
- fs13.renameSync(logPath, prevPath);
3349
- } catch {
3350
- }
3351
- }
3352
- function getHeartbeatAge(stateDir, instanceId) {
3353
- const state = loadBridgeState(stateDir, instanceId);
3354
- const heartbeat = resolveHeartbeatTimestamp(state);
3355
- if (!heartbeat) return null;
3356
- const heartbeatTime = new Date(heartbeat).getTime();
3357
- if (isNaN(heartbeatTime)) return null;
3358
- return Math.floor((Date.now() - heartbeatTime) / 1e3);
3359
- }
3360
- function getBridgeHeartbeatTimestamp(stateDir, instanceId) {
3361
- return resolveHeartbeatTimestamp(loadBridgeState(stateDir, instanceId));
3362
- }
3363
- function getBridgeStatus(stateDir, instanceId) {
3364
- const state = loadBridgeState(stateDir, instanceId);
3365
- if (!state) return "stopped";
3366
- if (!isProcessAlive(state.pid)) {
3367
- clearBridgeState(stateDir, instanceId);
3368
- return "stale";
3369
- }
3370
- return "running";
3558
+ });
3371
3559
  }
3372
3560
 
3373
3561
  // src/commands/add.ts
@@ -3858,7 +4046,7 @@ async function statusCommand(args) {
3858
4046
  }
3859
4047
 
3860
4048
  // src/engine/rollback.ts
3861
- import * as fs14 from "fs";
4049
+ import * as fs24 from "fs";
3862
4050
  async function rollbackRuntime(_instanceId, runtimeState) {
3863
4051
  const errors = [];
3864
4052
  const restoredFiles = [];
@@ -3887,7 +4075,7 @@ async function rollbackRuntime(_instanceId, runtimeState) {
3887
4075
  };
3888
4076
  }
3889
4077
  function rollbackArtifact(artifact) {
3890
- if (!fs14.existsSync(artifact.path)) {
4078
+ if (!fs24.existsSync(artifact.path)) {
3891
4079
  return { restored: false, error: `File not found: ${artifact.path}` };
3892
4080
  }
3893
4081
  switch (artifact.kind) {
@@ -3905,7 +4093,7 @@ function rollbackArtifact(artifact) {
3905
4093
  }
3906
4094
  }
3907
4095
  function rollbackJsonPath(artifact) {
3908
- const raw = fs14.readFileSync(artifact.path, "utf-8");
4096
+ const raw = fs24.readFileSync(artifact.path, "utf-8");
3909
4097
  let config;
3910
4098
  try {
3911
4099
  config = JSON.parse(raw);
@@ -3931,18 +4119,18 @@ function rollbackJsonPath(artifact) {
3931
4119
  cleanEmptyParents(config, artifact.selector);
3932
4120
  }
3933
4121
  const tmp = `${artifact.path}.tmp.${process.pid}`;
3934
- fs14.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
3935
- fs14.renameSync(tmp, artifact.path);
4122
+ fs24.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
4123
+ fs24.renameSync(tmp, artifact.path);
3936
4124
  return { restored: true };
3937
4125
  }
3938
4126
  function rollbackTomlTable(artifact) {
3939
- const content = fs14.readFileSync(artifact.path, "utf-8");
4127
+ const content = fs24.readFileSync(artifact.path, "utf-8");
3940
4128
  const backup = artifact.backupPath ? readArtifactBackup(artifact.backupPath) : null;
3941
4129
  if (backup?.kind === "toml-table" && backup.selector === artifact.selector) {
3942
4130
  const nextContent = backup.existed ? replaceTomlTable(content, artifact.selector, backup.content ?? "") : removeTomlTable(content, artifact.selector);
3943
4131
  const tmp2 = `${artifact.path}.tmp.${process.pid}`;
3944
- fs14.writeFileSync(tmp2, nextContent, "utf-8");
3945
- fs14.renameSync(tmp2, artifact.path);
4132
+ fs24.writeFileSync(tmp2, nextContent, "utf-8");
4133
+ fs24.renameSync(tmp2, artifact.path);
3946
4134
  return { restored: true };
3947
4135
  }
3948
4136
  if (!extractTomlTable(content, artifact.selector)) {
@@ -3952,13 +4140,13 @@ function rollbackTomlTable(artifact) {
3952
4140
  };
3953
4141
  }
3954
4142
  const tmp = `${artifact.path}.tmp.${process.pid}`;
3955
- fs14.writeFileSync(tmp, removeTomlTable(content, artifact.selector), "utf-8");
3956
- fs14.renameSync(tmp, artifact.path);
4143
+ fs24.writeFileSync(tmp, removeTomlTable(content, artifact.selector), "utf-8");
4144
+ fs24.renameSync(tmp, artifact.path);
3957
4145
  return { restored: true };
3958
4146
  }
3959
4147
  function rollbackFile(artifact) {
3960
- if (fs14.existsSync(artifact.path)) {
3961
- fs14.unlinkSync(artifact.path);
4148
+ if (fs24.existsSync(artifact.path)) {
4149
+ fs24.unlinkSync(artifact.path);
3962
4150
  return { restored: true };
3963
4151
  }
3964
4152
  return { restored: false, error: `File not found: ${artifact.path}` };
@@ -4068,8 +4256,8 @@ async function removeCommand(args) {
4068
4256
  };
4069
4257
  }
4070
4258
  const instanceId = resolved.instanceId;
4071
- const instance2 = state.instances[instanceId];
4072
- if (!instance2?.installed) {
4259
+ const instance = state.instances[instanceId];
4260
+ if (!instance?.installed) {
4073
4261
  return {
4074
4262
  ok: true,
4075
4263
  command: "remove",
@@ -4081,7 +4269,7 @@ async function removeCommand(args) {
4081
4269
  };
4082
4270
  }
4083
4271
  logHeader(`@hua-labs/tap remove ${instanceId}`);
4084
- if (instance2.bridge) {
4272
+ if (instance.bridge) {
4085
4273
  const ctx = createAdapterContext(state.commsDir, repoRoot);
4086
4274
  const stopped = await stopBridge({
4087
4275
  instanceId,
@@ -4094,7 +4282,7 @@ async function removeCommand(args) {
4094
4282
  log(`No running bridge for ${instanceId}`);
4095
4283
  }
4096
4284
  }
4097
- const result = await rollbackRuntime(instanceId, instance2);
4285
+ const result = await rollbackRuntime(instanceId, instance);
4098
4286
  if (result.success) {
4099
4287
  logSuccess(`Rolled back ${result.restoredCount} artifact(s)`);
4100
4288
  for (const f of result.restoredFiles) logSuccess(`Restored: ${f}`);
@@ -4106,7 +4294,7 @@ async function removeCommand(args) {
4106
4294
  ok: true,
4107
4295
  command: "remove",
4108
4296
  instanceId,
4109
- runtime: instance2.runtime,
4297
+ runtime: instance.runtime,
4110
4298
  code: "TAP_REMOVE_OK",
4111
4299
  message: `${instanceId} removed successfully`,
4112
4300
  warnings: [],
@@ -4121,7 +4309,7 @@ async function removeCommand(args) {
4121
4309
  ok: false,
4122
4310
  command: "remove",
4123
4311
  instanceId,
4124
- runtime: instance2.runtime,
4312
+ runtime: instance.runtime,
4125
4313
  code: "TAP_ROLLBACK_FAILED",
4126
4314
  message: "Rollback had errors. State preserved for retry.",
4127
4315
  warnings: result.errors,
@@ -4130,7 +4318,7 @@ async function removeCommand(args) {
4130
4318
  }
4131
4319
 
4132
4320
  // src/commands/bridge.ts
4133
- import * as path14 from "path";
4321
+ import * as path22 from "path";
4134
4322
  function formatAge(seconds) {
4135
4323
  if (seconds < 60) return `${seconds}s ago`;
4136
4324
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
@@ -4147,6 +4335,7 @@ Subcommands:
4147
4335
  stop Stop all running bridges
4148
4336
  status Show bridge status for all instances
4149
4337
  status <instance> Show bridge status for a specific instance
4338
+ watch Monitor bridges and auto-restart stuck/stale ones
4150
4339
 
4151
4340
  Options:
4152
4341
  --agent-name <name> Agent identity for bridge (or set TAP_AGENT_NAME env)
@@ -4203,7 +4392,7 @@ function formatThreadSummary(threadId, cwd) {
4203
4392
  return cwd ? `${threadId} (${cwd})` : threadId;
4204
4393
  }
4205
4394
  function normalizeComparablePath(value) {
4206
- return path14.resolve(value).replace(/\\/g, "/").toLowerCase();
4395
+ return path22.resolve(value).replace(/\\/g, "/").toLowerCase();
4207
4396
  }
4208
4397
  function sameOptionalPath(left, right) {
4209
4398
  if (!left || !right) {
@@ -4288,37 +4477,37 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4288
4477
  };
4289
4478
  }
4290
4479
  const instanceId = resolved.instanceId;
4291
- let instance2 = state.instances[instanceId];
4292
- if (!instance2?.installed) {
4480
+ let instance = state.instances[instanceId];
4481
+ if (!instance?.installed) {
4293
4482
  return {
4294
4483
  ok: false,
4295
4484
  command: "bridge",
4296
4485
  instanceId,
4297
- runtime: instance2?.runtime,
4486
+ runtime: instance?.runtime,
4298
4487
  code: "TAP_INSTANCE_NOT_FOUND",
4299
- message: `${instanceId} is not installed. Run: npx @hua-labs/tap add ${instance2?.runtime ?? identifier}`,
4488
+ message: `${instanceId} is not installed. Run: npx @hua-labs/tap add ${instance?.runtime ?? identifier}`,
4300
4489
  warnings: [],
4301
4490
  data: {}
4302
4491
  };
4303
4492
  }
4304
- const adapter = getAdapter(instance2.runtime);
4493
+ const adapter = getAdapter(instance.runtime);
4305
4494
  const mode = adapter.bridgeMode();
4306
4495
  if (mode !== "app-server") {
4307
4496
  return {
4308
4497
  ok: true,
4309
4498
  command: "bridge",
4310
4499
  instanceId,
4311
- runtime: instance2.runtime,
4500
+ runtime: instance.runtime,
4312
4501
  code: "TAP_NO_OP",
4313
4502
  message: `${instanceId} uses ${mode} mode \u2014 no bridge needed.`,
4314
4503
  warnings: [],
4315
4504
  data: { bridgeMode: mode }
4316
4505
  };
4317
4506
  }
4318
- const resolvedAgentName = agentName ?? instance2.agentName ?? void 0;
4319
- if (agentName && agentName !== instance2.agentName) {
4320
- instance2 = { ...instance2, agentName };
4321
- const updatedState = updateInstanceState(state, instanceId, instance2);
4507
+ const resolvedAgentName = agentName ?? instance.agentName ?? void 0;
4508
+ if (agentName && agentName !== instance.agentName) {
4509
+ instance = { ...instance, agentName };
4510
+ const updatedState = updateInstanceState(state, instanceId, instance);
4322
4511
  saveState(repoRoot, updatedState);
4323
4512
  state = updatedState;
4324
4513
  }
@@ -4329,7 +4518,7 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4329
4518
  ok: false,
4330
4519
  command: "bridge",
4331
4520
  instanceId,
4332
- runtime: instance2.runtime,
4521
+ runtime: instance.runtime,
4333
4522
  code: "TAP_BRIDGE_SCRIPT_MISSING",
4334
4523
  message: `Bridge script not found for ${instanceId}. Ensure the runtime is properly configured.`,
4335
4524
  warnings: [],
@@ -4338,8 +4527,8 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4338
4527
  }
4339
4528
  const { config: resolvedConfig } = resolveConfig({}, repoRoot);
4340
4529
  const runtimeCommand = resolvedConfig.runtimeCommand;
4341
- const manageAppServer = instance2.runtime === "codex" && flags["no-server"] !== true;
4342
- let effectivePort = instance2.port;
4530
+ const manageAppServer = instance.runtime === "codex" && flags["no-server"] !== true;
4531
+ let effectivePort = instance.port;
4343
4532
  if (effectivePort == null && manageAppServer) {
4344
4533
  effectivePort = await findNextAvailableAppServerPort(
4345
4534
  state,
@@ -4347,8 +4536,8 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4347
4536
  4501,
4348
4537
  instanceId
4349
4538
  );
4350
- instance2 = { ...instance2, port: effectivePort };
4351
- const updatedState = updateInstanceState(state, instanceId, instance2);
4539
+ instance = { ...instance, port: effectivePort };
4540
+ const updatedState = updateInstanceState(state, instanceId, instance);
4352
4541
  saveState(repoRoot, updatedState);
4353
4542
  state = updatedState;
4354
4543
  }
@@ -4364,19 +4553,19 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4364
4553
  if (effectivePort != null) log(`Port: ${effectivePort}`);
4365
4554
  if (resolvedAgentName) log(`Agent name: ${resolvedAgentName}`);
4366
4555
  const noAuth = flags["no-auth"] === true;
4367
- if (!manageAppServer && instance2.runtime === "codex") {
4556
+ if (!manageAppServer && instance.runtime === "codex") {
4368
4557
  log("Auto server: disabled (--no-server)");
4369
4558
  }
4370
4559
  if (noAuth && manageAppServer) {
4371
4560
  log("Auth gateway: disabled (--no-auth)");
4372
4561
  }
4373
- const willBeHeadless = flags["headless"] === true || instance2.headless?.enabled;
4562
+ const willBeHeadless = flags["headless"] === true || instance.headless?.enabled;
4374
4563
  if (willBeHeadless) {
4375
- const role = (typeof flags["role"] === "string" ? flags["role"] : null) ?? instance2.headless?.role ?? "reviewer";
4564
+ const role = (typeof flags["role"] === "string" ? flags["role"] : null) ?? instance.headless?.role ?? "reviewer";
4376
4565
  log(`Headless: ${role}`);
4377
4566
  }
4378
4567
  try {
4379
- if (!manageAppServer && instance2.runtime === "codex") {
4568
+ if (!manageAppServer && instance.runtime === "codex") {
4380
4569
  log("Checking app-server health...");
4381
4570
  const healthy = await checkAppServerHealth(appServerUrl);
4382
4571
  if (healthy) {
@@ -4387,7 +4576,7 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4387
4576
  ok: false,
4388
4577
  command: "bridge",
4389
4578
  instanceId,
4390
- runtime: instance2.runtime,
4579
+ runtime: instance.runtime,
4391
4580
  code: "TAP_BRIDGE_START_FAILED",
4392
4581
  message: `App server not reachable at ${appServerUrl}. Start it first: codex app-server --listen ${appServerUrl}`,
4393
4582
  warnings: [],
@@ -4401,7 +4590,7 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4401
4590
  ok: false,
4402
4591
  command: "bridge",
4403
4592
  instanceId,
4404
- runtime: instance2.runtime,
4593
+ runtime: instance.runtime,
4405
4594
  code: "TAP_INVALID_ARGUMENT",
4406
4595
  message: `Invalid --busy-mode: ${String(busyModeRaw)}. Must be "steer" or "wait".`,
4407
4596
  warnings: [],
@@ -4434,7 +4623,7 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4434
4623
  ok: false,
4435
4624
  command: "bridge",
4436
4625
  instanceId,
4437
- runtime: instance2.runtime,
4626
+ runtime: instance.runtime,
4438
4627
  code: "TAP_INVALID_ARGUMENT",
4439
4628
  message: err instanceof Error ? err.message : String(err),
4440
4629
  warnings: [],
@@ -4452,7 +4641,7 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4452
4641
  ok: false,
4453
4642
  command: "bridge",
4454
4643
  instanceId,
4455
- runtime: instance2.runtime,
4644
+ runtime: instance.runtime,
4456
4645
  code: "TAP_INVALID_ARGUMENT",
4457
4646
  message: `Invalid --role: ${roleArg}. Must be: ${validRoles.join(", ")}`,
4458
4647
  warnings: [],
@@ -4464,32 +4653,43 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4464
4653
  role: roleArg ?? "reviewer",
4465
4654
  maxRounds: 5,
4466
4655
  qualitySeverityFloor: "high"
4467
- } : instance2.headless;
4468
- const bridge = await startBridge({
4469
- instanceId,
4470
- runtime: instance2.runtime,
4471
- stateDir: ctx.stateDir,
4472
- commsDir: ctx.commsDir,
4473
- bridgeScript,
4474
- platform: ctx.platform,
4475
- agentName: resolvedAgentName,
4476
- runtimeCommand,
4477
- appServerUrl,
4478
- repoRoot,
4479
- port: effectivePort ?? void 0,
4480
- manageAppServer,
4481
- noAuth,
4482
- headless,
4483
- busyMode,
4484
- pollSeconds,
4485
- reconnectSeconds,
4486
- messageLookbackMinutes,
4487
- threadId,
4488
- ephemeral,
4489
- processExistingMessages
4490
- });
4656
+ } : instance.headless;
4657
+ const previousWarmup = process.env.TAP_COLD_START_WARMUP;
4658
+ process.env.TAP_COLD_START_WARMUP = "true";
4659
+ let bridge;
4660
+ try {
4661
+ bridge = await startBridge({
4662
+ instanceId,
4663
+ runtime: instance.runtime,
4664
+ stateDir: ctx.stateDir,
4665
+ commsDir: ctx.commsDir,
4666
+ bridgeScript,
4667
+ platform: ctx.platform,
4668
+ agentName: resolvedAgentName,
4669
+ runtimeCommand,
4670
+ appServerUrl,
4671
+ repoRoot,
4672
+ port: effectivePort ?? void 0,
4673
+ manageAppServer,
4674
+ noAuth,
4675
+ headless,
4676
+ busyMode,
4677
+ pollSeconds,
4678
+ reconnectSeconds,
4679
+ messageLookbackMinutes,
4680
+ threadId,
4681
+ ephemeral,
4682
+ processExistingMessages
4683
+ });
4684
+ } finally {
4685
+ if (previousWarmup === void 0) {
4686
+ delete process.env.TAP_COLD_START_WARMUP;
4687
+ } else {
4688
+ process.env.TAP_COLD_START_WARMUP = previousWarmup;
4689
+ }
4690
+ }
4491
4691
  logSuccess(`Bridge started (PID: ${bridge.pid})`);
4492
- log(`Log: ${path14.join(ctx.stateDir, "logs", `bridge-${instanceId}.log`)}`);
4692
+ log(`Log: ${path22.join(ctx.stateDir, "logs", `bridge-${instanceId}.log`)}`);
4493
4693
  if (bridge.appServer) {
4494
4694
  log(`App server: ${formatAppServerState(bridge.appServer)}`);
4495
4695
  if (bridge.appServer.logPath) {
@@ -4508,14 +4708,14 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4508
4708
  log(`TUI connect: ${bridge.appServer.url}`);
4509
4709
  }
4510
4710
  }
4511
- const updated = { ...instance2, bridge, manageAppServer, noAuth };
4711
+ const updated = { ...instance, bridge, manageAppServer, noAuth };
4512
4712
  const newState = updateInstanceState(state, instanceId, updated);
4513
4713
  saveState(repoRoot, newState);
4514
4714
  return {
4515
4715
  ok: true,
4516
4716
  command: "bridge",
4517
4717
  instanceId,
4518
- runtime: instance2.runtime,
4718
+ runtime: instance.runtime,
4519
4719
  code: "TAP_BRIDGE_START_OK",
4520
4720
  message: `Bridge for ${instanceId} started (PID: ${bridge.pid})`,
4521
4721
  warnings: [],
@@ -4528,7 +4728,7 @@ async function bridgeStart(identifier, agentName, flags = {}) {
4528
4728
  ok: false,
4529
4729
  command: "bridge",
4530
4730
  instanceId,
4531
- runtime: instance2.runtime,
4731
+ runtime: instance.runtime,
4532
4732
  code: "TAP_BRIDGE_START_FAILED",
4533
4733
  message: msg,
4534
4734
  warnings: [],
@@ -4630,11 +4830,11 @@ async function bridgeStopOne(identifier) {
4630
4830
  }
4631
4831
  const instanceId = resolved.instanceId;
4632
4832
  const ctx = createAdapterContext(state.commsDir, repoRoot);
4633
- const instance2 = state.instances[instanceId];
4833
+ const instance = state.instances[instanceId];
4634
4834
  const bridgeState = loadCurrentBridgeState(
4635
4835
  ctx.stateDir,
4636
4836
  instanceId,
4637
- instance2?.bridge
4837
+ instance?.bridge
4638
4838
  );
4639
4839
  const appServer = bridgeState?.appServer ?? null;
4640
4840
  logHeader(`@hua-labs/tap bridge stop ${instanceId}`);
@@ -4679,11 +4879,17 @@ async function bridgeStopOne(identifier) {
4679
4879
  logSuccess(
4680
4880
  `Managed app-server stopped (PID: ${appServer.pid ?? "-"}${gatewayNote})`
4681
4881
  );
4882
+ const released = await waitForPortRelease(appServer.url, 5e3);
4883
+ if (!released) {
4884
+ log(
4885
+ `Warning: port for ${appServer.url} still in use after stop \u2014 next start may need a different port`
4886
+ );
4887
+ }
4682
4888
  }
4683
4889
  }
4684
4890
  }
4685
- if (instance2) {
4686
- const updated = { ...instance2, bridge: null };
4891
+ if (instance) {
4892
+ const updated = { ...instance, bridge: null };
4687
4893
  const newState = updateInstanceState(state, instanceId, updated);
4688
4894
  saveState(repoRoot, newState);
4689
4895
  }
@@ -4755,22 +4961,29 @@ async function bridgeStopAll() {
4755
4961
  logSuccess(`Stopped bridge for ${instanceId}`);
4756
4962
  stopped.push(instanceId);
4757
4963
  }
4758
- const instance2 = state.instances[instanceId];
4759
- if (instance2?.bridge) {
4760
- state.instances[instanceId] = { ...instance2, bridge: null };
4964
+ const instance = state.instances[instanceId];
4965
+ if (instance?.bridge) {
4966
+ state.instances[instanceId] = { ...instance, bridge: null };
4761
4967
  stateChanged = true;
4762
4968
  }
4763
4969
  }
4764
4970
  const stoppedAppServers = [];
4971
+ const releasePorts = [];
4765
4972
  for (const appServer of managedAppServers.values()) {
4766
4973
  if (await stopManagedAppServer(appServer, ctx.platform)) {
4767
4974
  stoppedAppServers.push(appServer.pid);
4975
+ releasePorts.push(appServer.url);
4768
4976
  const gatewayNote = appServer.auth?.gatewayPid != null ? `, gateway PID ${appServer.auth.gatewayPid}` : "";
4769
4977
  logSuccess(
4770
4978
  `Stopped app-server PID ${appServer.pid} (${appServer.url}${gatewayNote})`
4771
4979
  );
4772
4980
  }
4773
4981
  }
4982
+ if (releasePorts.length > 0) {
4983
+ await Promise.all(
4984
+ releasePorts.map((url) => waitForPortRelease(url, 5e3))
4985
+ );
4986
+ }
4774
4987
  if (stateChanged) {
4775
4988
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4776
4989
  saveState(repoRoot, state);
@@ -4786,6 +4999,124 @@ async function bridgeStopAll() {
4786
4999
  data: { stopped, stoppedAppServers }
4787
5000
  };
4788
5001
  }
5002
+ async function bridgeWatch(_intervalSeconds, stuckThresholdSeconds) {
5003
+ const repoRoot = findRepoRoot();
5004
+ const state = loadState(repoRoot);
5005
+ if (!state) {
5006
+ return {
5007
+ ok: false,
5008
+ command: "bridge",
5009
+ code: "TAP_NOT_INITIALIZED",
5010
+ message: "Not initialized. Run: npx @hua-labs/tap init",
5011
+ warnings: [],
5012
+ data: {}
5013
+ };
5014
+ }
5015
+ const { config: resolvedCfg } = resolveConfig({}, repoRoot);
5016
+ const stateDir = resolvedCfg.stateDir;
5017
+ const instanceIds = Object.keys(state.instances);
5018
+ logHeader("@hua-labs/tap bridge watch");
5019
+ log(
5020
+ `Checking ${instanceIds.length} instance(s), stuck threshold: ${stuckThresholdSeconds}s`
5021
+ );
5022
+ const restarted = [];
5023
+ const cleaned = [];
5024
+ const healthy = [];
5025
+ const warnings = [];
5026
+ for (const instanceId of instanceIds) {
5027
+ const inst = state.instances[instanceId];
5028
+ if (!inst?.installed || inst.bridgeMode !== "app-server") continue;
5029
+ const status = getBridgeStatus(stateDir, instanceId);
5030
+ if (status === "stale") {
5031
+ log(`${instanceId}: stale (process dead) \u2014 cleaning up`);
5032
+ cleaned.push(instanceId);
5033
+ continue;
5034
+ }
5035
+ if (status === "stopped") {
5036
+ log(`${instanceId}: stopped`);
5037
+ continue;
5038
+ }
5039
+ if (isTurnStuck(stateDir, instanceId, stuckThresholdSeconds)) {
5040
+ const turnInfo = getTurnInfo(stateDir, instanceId, stuckThresholdSeconds);
5041
+ const ageStr = turnInfo?.ageSeconds != null ? formatAge(turnInfo.ageSeconds) : "?";
5042
+ log(
5043
+ `${instanceId}: \u26A0 STUCK turn ${turnInfo?.activeTurnId?.slice(0, 8)}... (${ageStr}) \u2014 restarting`
5044
+ );
5045
+ const adapter = getAdapter(inst.runtime);
5046
+ const ctx = {
5047
+ ...createAdapterContext(state.commsDir, repoRoot),
5048
+ instanceId
5049
+ };
5050
+ const bridgeScript = adapter.resolveBridgeScript?.(ctx);
5051
+ if (!bridgeScript) {
5052
+ warnings.push(
5053
+ `${instanceId}: cannot restart \u2014 bridge script not found`
5054
+ );
5055
+ continue;
5056
+ }
5057
+ const bridgeState = loadBridgeState(stateDir, instanceId);
5058
+ const { manageAppServer, noAuth } = inferRestartMode(bridgeState, {});
5059
+ const previousWarmup = process.env.TAP_COLD_START_WARMUP;
5060
+ process.env.TAP_COLD_START_WARMUP = "true";
5061
+ try {
5062
+ const newBridgeState = await restartBridge({
5063
+ instanceId,
5064
+ runtime: inst.runtime,
5065
+ stateDir: ctx.stateDir,
5066
+ commsDir: ctx.commsDir,
5067
+ bridgeScript,
5068
+ platform: ctx.platform,
5069
+ agentName: inst.agentName ?? void 0,
5070
+ runtimeCommand: resolvedCfg.runtimeCommand,
5071
+ appServerUrl: resolvedCfg.appServerUrl,
5072
+ repoRoot,
5073
+ port: inst.port ?? void 0,
5074
+ headless: inst.headless,
5075
+ drainTimeoutSeconds: 30,
5076
+ manageAppServer,
5077
+ noAuth
5078
+ });
5079
+ const updatedInst = { ...inst, bridge: newBridgeState };
5080
+ const updatedState = updateInstanceState(
5081
+ state,
5082
+ instanceId,
5083
+ updatedInst
5084
+ );
5085
+ saveState(repoRoot, updatedState);
5086
+ restarted.push(instanceId);
5087
+ logSuccess(`${instanceId}: restarted`);
5088
+ } catch (err) {
5089
+ const msg = err instanceof Error ? err.message : String(err);
5090
+ warnings.push(`${instanceId}: restart failed \u2014 ${msg}`);
5091
+ logError(`${instanceId}: restart failed \u2014 ${msg}`);
5092
+ } finally {
5093
+ if (previousWarmup === void 0) {
5094
+ delete process.env.TAP_COLD_START_WARMUP;
5095
+ } else {
5096
+ process.env.TAP_COLD_START_WARMUP = previousWarmup;
5097
+ }
5098
+ }
5099
+ } else {
5100
+ healthy.push(instanceId);
5101
+ log(`${instanceId}: healthy`);
5102
+ }
5103
+ }
5104
+ const message = [
5105
+ restarted.length > 0 ? `Restarted: ${restarted.join(", ")}` : null,
5106
+ cleaned.length > 0 ? `Cleaned stale: ${cleaned.join(", ")}` : null,
5107
+ healthy.length > 0 ? `Healthy: ${healthy.join(", ")}` : null
5108
+ ].filter(Boolean).join(". ") || "No app-server bridges found";
5109
+ log("");
5110
+ log(message);
5111
+ return {
5112
+ ok: true,
5113
+ command: "bridge",
5114
+ code: restarted.length > 0 ? "TAP_BRIDGE_WATCH_RESTARTED" : "TAP_BRIDGE_WATCH_OK",
5115
+ message,
5116
+ warnings,
5117
+ data: { restarted, cleaned, healthy }
5118
+ };
5119
+ }
4789
5120
  function bridgeStatusAll() {
4790
5121
  const repoRoot = findRepoRoot();
4791
5122
  const state = loadState(repoRoot);
@@ -4866,6 +5197,19 @@ function bridgeStatusAll() {
4866
5197
  ` Saved: ${formatThreadSummary(savedThread.threadId, savedThread.cwd)}`
4867
5198
  );
4868
5199
  }
5200
+ const turnInfo = getTurnInfo(stateDir, instanceId);
5201
+ if (turnInfo?.activeTurnId) {
5202
+ const ageStr2 = turnInfo.ageSeconds != null ? formatAge(turnInfo.ageSeconds) : "?";
5203
+ if (turnInfo.stuck) {
5204
+ log(
5205
+ ` \u26A0 STUCK: turn ${turnInfo.activeTurnId.slice(0, 8)}... active ${ageStr2} (threshold: 5m)`
5206
+ );
5207
+ } else {
5208
+ log(
5209
+ ` Turn: ${turnInfo.activeTurnId.slice(0, 8)}... active ${ageStr2}`
5210
+ );
5211
+ }
5212
+ }
4869
5213
  bridges[instanceId] = {
4870
5214
  status,
4871
5215
  runtime: inst.runtime,
@@ -4984,7 +5328,7 @@ function bridgeStatusOne(identifier) {
4984
5328
  );
4985
5329
  }
4986
5330
  log(
4987
- `Log: ${path14.join(stateDir, "logs", `bridge-${instanceId}.log`)}`
5331
+ `Log: ${path22.join(stateDir, "logs", `bridge-${instanceId}.log`)}`
4988
5332
  );
4989
5333
  if (bridgeState.appServer) {
4990
5334
  log(`App server: ${bridgeState.appServer.url}`);
@@ -5102,7 +5446,7 @@ async function bridgeRestart(identifier, flags) {
5102
5446
  ok: false,
5103
5447
  command: "bridge",
5104
5448
  instanceId,
5105
- runtime: instance.runtime,
5449
+ runtime: inst.runtime,
5106
5450
  code: "TAP_INVALID_ARGUMENT",
5107
5451
  message: err instanceof Error ? err.message : String(err),
5108
5452
  warnings: [],
@@ -5124,23 +5468,34 @@ async function bridgeRestart(identifier, flags) {
5124
5468
  noAuth: inst.noAuth
5125
5469
  }
5126
5470
  );
5127
- const bridge = await restartBridge({
5128
- instanceId,
5129
- runtime: inst.runtime,
5130
- stateDir: ctx.stateDir,
5131
- commsDir: ctx.commsDir,
5132
- bridgeScript,
5133
- platform: ctx.platform,
5134
- agentName: inst.agentName ?? void 0,
5135
- runtimeCommand: resolvedConfig.runtimeCommand,
5136
- appServerUrl: resolvedConfig.appServerUrl,
5137
- repoRoot,
5138
- port: inst.port ?? void 0,
5139
- headless: inst.headless,
5140
- drainTimeoutSeconds: drainTimeout,
5141
- manageAppServer,
5142
- noAuth
5143
- });
5471
+ const previousColdStartWarmup = process.env.TAP_COLD_START_WARMUP;
5472
+ process.env.TAP_COLD_START_WARMUP = "true";
5473
+ let bridge;
5474
+ try {
5475
+ bridge = await restartBridge({
5476
+ instanceId,
5477
+ runtime: inst.runtime,
5478
+ stateDir: ctx.stateDir,
5479
+ commsDir: ctx.commsDir,
5480
+ bridgeScript,
5481
+ platform: ctx.platform,
5482
+ agentName: inst.agentName ?? void 0,
5483
+ runtimeCommand: resolvedConfig.runtimeCommand,
5484
+ appServerUrl: resolvedConfig.appServerUrl,
5485
+ repoRoot,
5486
+ port: inst.port ?? void 0,
5487
+ headless: inst.headless,
5488
+ drainTimeoutSeconds: drainTimeout,
5489
+ manageAppServer,
5490
+ noAuth
5491
+ });
5492
+ } finally {
5493
+ if (previousColdStartWarmup === void 0) {
5494
+ delete process.env.TAP_COLD_START_WARMUP;
5495
+ } else {
5496
+ process.env.TAP_COLD_START_WARMUP = previousColdStartWarmup;
5497
+ }
5498
+ }
5144
5499
  logSuccess(`Bridge restarted (PID: ${bridge.pid})`);
5145
5500
  const updated = { ...inst, bridge, manageAppServer, noAuth };
5146
5501
  const newState = updateInstanceState(state, instanceId, updated);
@@ -5227,6 +5582,13 @@ async function bridgeCommand(args) {
5227
5582
  }
5228
5583
  return bridgeStatusAll();
5229
5584
  }
5585
+ case "watch": {
5586
+ const intervalStr = typeof flags["interval"] === "string" ? flags["interval"] : void 0;
5587
+ const interval = intervalStr ? parseInt(intervalStr, 10) : 30;
5588
+ const stuckThresholdStr = typeof flags["stuck-threshold"] === "string" ? flags["stuck-threshold"] : void 0;
5589
+ const stuckThreshold = stuckThresholdStr ? parseInt(stuckThresholdStr, 10) : 300;
5590
+ return bridgeWatch(interval, stuckThreshold);
5591
+ }
5230
5592
  case "restart": {
5231
5593
  if (!identifierArg) {
5232
5594
  return {
@@ -5253,14 +5615,14 @@ async function bridgeCommand(args) {
5253
5615
  }
5254
5616
 
5255
5617
  // src/engine/dashboard.ts
5256
- import * as fs15 from "fs";
5257
- import * as path15 from "path";
5618
+ import * as fs25 from "fs";
5619
+ import * as path23 from "path";
5258
5620
  import { execSync as execSync4 } from "child_process";
5259
5621
  function collectAgents(commsDir) {
5260
- const heartbeatsPath = path15.join(commsDir, "heartbeats.json");
5261
- if (!fs15.existsSync(heartbeatsPath)) return [];
5622
+ const heartbeatsPath = path23.join(commsDir, "heartbeats.json");
5623
+ if (!fs25.existsSync(heartbeatsPath)) return [];
5262
5624
  try {
5263
- const raw = fs15.readFileSync(heartbeatsPath, "utf-8");
5625
+ const raw = fs25.readFileSync(heartbeatsPath, "utf-8");
5264
5626
  const data = JSON.parse(raw);
5265
5627
  return Object.entries(data).map(([name, info]) => ({
5266
5628
  name: info.agent ?? name,
@@ -5296,22 +5658,22 @@ function collectBridges(repoRoot) {
5296
5658
  });
5297
5659
  }
5298
5660
  }
5299
- const tmpDir = path15.join(repoRoot, ".tmp");
5300
- if (fs15.existsSync(tmpDir)) {
5661
+ const tmpDir = path23.join(repoRoot, ".tmp");
5662
+ if (fs25.existsSync(tmpDir)) {
5301
5663
  try {
5302
- const dirs = fs15.readdirSync(tmpDir).filter((d) => d.startsWith("codex-app-server-bridge"));
5664
+ const dirs = fs25.readdirSync(tmpDir).filter((d) => d.startsWith("codex-app-server-bridge"));
5303
5665
  for (const dir of dirs) {
5304
- const daemonPath = path15.join(tmpDir, dir, "bridge-daemon.json");
5305
- if (!fs15.existsSync(daemonPath)) continue;
5666
+ const daemonPath = path23.join(tmpDir, dir, "bridge-daemon.json");
5667
+ if (!fs25.existsSync(daemonPath)) continue;
5306
5668
  try {
5307
- const raw = fs15.readFileSync(daemonPath, "utf-8");
5669
+ const raw = fs25.readFileSync(daemonPath, "utf-8");
5308
5670
  const daemon = JSON.parse(raw);
5309
5671
  const alreadyCovered = bridges.some(
5310
5672
  (b) => b.pid === daemon.pid && b.pid !== null
5311
5673
  );
5312
5674
  if (alreadyCovered) continue;
5313
- const agentFile = path15.join(tmpDir, dir, "agent-name.txt");
5314
- const agentName = fs15.existsSync(agentFile) ? fs15.readFileSync(agentFile, "utf-8").trim() : dir;
5675
+ const agentFile = path23.join(tmpDir, dir, "agent-name.txt");
5676
+ const agentName = fs25.existsSync(agentFile) ? fs25.readFileSync(agentFile, "utf-8").trim() : dir;
5315
5677
  const running = daemon.pid ? isProcessAlive(daemon.pid) : false;
5316
5678
  const portMatch = daemon.appServerUrl?.match(/:(\d+)/);
5317
5679
  const port = portMatch ? parseInt(portMatch[1], 10) : null;
@@ -5517,7 +5879,7 @@ async function downCommand(args) {
5517
5879
  }
5518
5880
 
5519
5881
  // src/commands/serve.ts
5520
- import * as path16 from "path";
5882
+ import * as path24 from "path";
5521
5883
  import { spawn as spawn2 } from "child_process";
5522
5884
  var SERVE_HELP = `
5523
5885
  Usage:
@@ -5551,7 +5913,7 @@ async function serveCommand(args) {
5551
5913
  let commsDir;
5552
5914
  const commsDirIdx = args.indexOf("--comms-dir");
5553
5915
  if (commsDirIdx !== -1 && args[commsDirIdx + 1]) {
5554
- commsDir = path16.resolve(args[commsDirIdx + 1]);
5916
+ commsDir = path24.resolve(args[commsDirIdx + 1]);
5555
5917
  }
5556
5918
  if (!commsDir && process.env.TAP_COMMS_DIR) {
5557
5919
  commsDir = process.env.TAP_COMMS_DIR;
@@ -5594,9 +5956,9 @@ async function serveCommand(args) {
5594
5956
  TAP_COMMS_DIR: commsDir
5595
5957
  }
5596
5958
  });
5597
- return new Promise((resolve13) => {
5959
+ return new Promise((resolve14) => {
5598
5960
  child.on("error", (err) => {
5599
- resolve13({
5961
+ resolve14({
5600
5962
  ok: false,
5601
5963
  command: "serve",
5602
5964
  code: "TAP_INTERNAL_ERROR",
@@ -5606,7 +5968,7 @@ async function serveCommand(args) {
5606
5968
  });
5607
5969
  });
5608
5970
  child.on("exit", (code) => {
5609
- resolve13({
5971
+ resolve14({
5610
5972
  ok: code === 0,
5611
5973
  command: "serve",
5612
5974
  code: code === 0 ? "TAP_SERVE_OK" : "TAP_INTERNAL_ERROR",
@@ -5619,8 +5981,8 @@ async function serveCommand(args) {
5619
5981
  }
5620
5982
 
5621
5983
  // src/commands/init-worktree.ts
5622
- import * as fs16 from "fs";
5623
- import * as path17 from "path";
5984
+ import * as fs26 from "fs";
5985
+ import * as path25 from "path";
5624
5986
  import { execSync as execSync5 } from "child_process";
5625
5987
  var INIT_WORKTREE_HELP = `
5626
5988
  Usage:
@@ -5657,7 +6019,7 @@ function run(cmd, opts) {
5657
6019
  }
5658
6020
  }
5659
6021
  function toAbsolute(p) {
5660
- const resolved = path17.resolve(p);
6022
+ const resolved = path25.resolve(p);
5661
6023
  return resolved.replace(/\\/g, "/");
5662
6024
  }
5663
6025
  function probeBun(candidate) {
@@ -5688,18 +6050,18 @@ function findBun() {
5688
6050
  }
5689
6051
  }
5690
6052
  const home = process.env.HOME || process.env.USERPROFILE || "";
5691
- const bunHome = path17.join(
6053
+ const bunHome = path25.join(
5692
6054
  home,
5693
6055
  ".bun",
5694
6056
  "bin",
5695
6057
  process.platform === "win32" ? "bun.exe" : "bun"
5696
6058
  );
5697
- if (fs16.existsSync(bunHome) && probeBun(bunHome)) return bunHome;
6059
+ if (fs26.existsSync(bunHome) && probeBun(bunHome)) return bunHome;
5698
6060
  return null;
5699
6061
  }
5700
6062
  function step1CreateWorktree(opts) {
5701
6063
  log("Step 1/9: Creating worktree...");
5702
- if (fs16.existsSync(opts.worktreePath)) {
6064
+ if (fs26.existsSync(opts.worktreePath)) {
5703
6065
  logWarn(`Directory already exists: ${opts.worktreePath}`);
5704
6066
  try {
5705
6067
  run("git rev-parse --git-dir", { cwd: opts.worktreePath });
@@ -5761,22 +6123,22 @@ function step2MergeMain(opts, warnings) {
5761
6123
  }
5762
6124
  function step3CopyPermissions(opts, warnings) {
5763
6125
  log("Step 3/9: Copying permissions...");
5764
- const srcSettings = path17.join(
6126
+ const srcSettings = path25.join(
5765
6127
  opts.repoRoot,
5766
6128
  ".claude",
5767
6129
  "settings.local.json"
5768
6130
  );
5769
- const destDir = path17.join(opts.worktreePath, ".claude");
5770
- const destSettings = path17.join(destDir, "settings.local.json");
5771
- if (!fs16.existsSync(srcSettings)) {
6131
+ const destDir = path25.join(opts.worktreePath, ".claude");
6132
+ const destSettings = path25.join(destDir, "settings.local.json");
6133
+ if (!fs26.existsSync(srcSettings)) {
5772
6134
  warn(
5773
6135
  warnings,
5774
6136
  "No .claude/settings.local.json found in main repo. Skipping."
5775
6137
  );
5776
6138
  return;
5777
6139
  }
5778
- fs16.mkdirSync(destDir, { recursive: true });
5779
- fs16.copyFileSync(srcSettings, destSettings);
6140
+ fs26.mkdirSync(destDir, { recursive: true });
6141
+ fs26.copyFileSync(srcSettings, destSettings);
5780
6142
  logSuccess("Copied settings.local.json");
5781
6143
  try {
5782
6144
  run("git update-index --skip-worktree .claude/settings.local.json", {
@@ -5801,7 +6163,7 @@ function step4GenerateMcpJson(opts, warnings) {
5801
6163
  const wtAbs = toAbsolute(opts.worktreePath);
5802
6164
  const bunAbs = toAbsolute(bunPath);
5803
6165
  const commsAbs = toAbsolute(opts.commsDir);
5804
- const channelEntry = path17.join(
6166
+ const channelEntry = path25.join(
5805
6167
  wtAbs,
5806
6168
  "packages/tap-plugin/channels/tap-comms.ts"
5807
6169
  );
@@ -5818,8 +6180,8 @@ function step4GenerateMcpJson(opts, warnings) {
5818
6180
  }
5819
6181
  }
5820
6182
  };
5821
- const mcpPath = path17.join(opts.worktreePath, ".mcp.json");
5822
- fs16.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
6183
+ const mcpPath = path25.join(opts.worktreePath, ".mcp.json");
6184
+ fs26.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
5823
6185
  logSuccess(`.mcp.json generated (absolute paths + cwd)`);
5824
6186
  log(` bun: ${bunAbs}`);
5825
6187
  log(` comms: ${commsAbs}`);
@@ -5857,16 +6219,16 @@ function step6BuildEslintPlugin(opts, warnings) {
5857
6219
  }
5858
6220
  function step7VerifyComms(opts, warnings) {
5859
6221
  log("Step 7/9: Verifying comms directory...");
5860
- if (!fs16.existsSync(opts.commsDir)) {
6222
+ if (!fs26.existsSync(opts.commsDir)) {
5861
6223
  warn(warnings, `Comms directory not found: ${opts.commsDir}`);
5862
6224
  warn(warnings, "Create it or run: npx @hua-labs/tap init");
5863
6225
  return;
5864
6226
  }
5865
6227
  const requiredDirs = ["inbox", "findings", "reviews", "letters"];
5866
6228
  for (const dir of requiredDirs) {
5867
- const dirPath = path17.join(opts.commsDir, dir);
5868
- if (!fs16.existsSync(dirPath)) {
5869
- fs16.mkdirSync(dirPath, { recursive: true });
6229
+ const dirPath = path25.join(opts.commsDir, dir);
6230
+ if (!fs26.existsSync(dirPath)) {
6231
+ fs26.mkdirSync(dirPath, { recursive: true });
5870
6232
  logSuccess(`Created ${dir}/`);
5871
6233
  }
5872
6234
  }
@@ -5925,17 +6287,17 @@ async function initWorktreeCommand(args) {
5925
6287
  }
5926
6288
  const repoRoot = findRepoRoot();
5927
6289
  const { config } = resolveConfig({}, repoRoot);
5928
- const branch = typeof flags["branch"] === "string" ? flags["branch"] : path17.basename(path17.resolve(worktreePath));
6290
+ const branch = typeof flags["branch"] === "string" ? flags["branch"] : path25.basename(path25.resolve(worktreePath));
5929
6291
  const base = typeof flags["base"] === "string" ? flags["base"] : "origin/main";
5930
6292
  const mission = typeof flags["mission"] === "string" ? flags["mission"] : void 0;
5931
6293
  const commsDir = typeof flags["comms-dir"] === "string" ? flags["comms-dir"] : config.commsDir;
5932
6294
  const skipInstall = flags["skip-install"] === true;
5933
6295
  const opts = {
5934
- worktreePath: path17.resolve(worktreePath),
6296
+ worktreePath: path25.resolve(worktreePath),
5935
6297
  branch,
5936
6298
  base,
5937
6299
  mission,
5938
- commsDir: path17.resolve(commsDir),
6300
+ commsDir: path25.resolve(commsDir),
5939
6301
  skipInstall,
5940
6302
  repoRoot
5941
6303
  };
@@ -6156,18 +6518,18 @@ async function dashboardCommand(args) {
6156
6518
 
6157
6519
  // src/commands/doctor.ts
6158
6520
  import {
6159
- existsSync as existsSync16,
6160
- mkdirSync as mkdirSync10,
6161
- readdirSync as readdirSync4,
6162
- readFileSync as readFileSync14,
6163
- renameSync as renameSync10,
6164
- statSync as statSync2,
6165
- unlinkSync as unlinkSync3,
6166
- writeFileSync as writeFileSync12
6521
+ existsSync as existsSync23,
6522
+ mkdirSync as mkdirSync13,
6523
+ readdirSync as readdirSync6,
6524
+ readFileSync as readFileSync18,
6525
+ renameSync as renameSync11,
6526
+ statSync as statSync3,
6527
+ unlinkSync as unlinkSync6,
6528
+ writeFileSync as writeFileSync13
6167
6529
  } from "fs";
6168
6530
  import { homedir as homedir3 } from "os";
6169
- import { spawnSync as spawnSync4 } from "child_process";
6170
- import { dirname as dirname11, join as join17, resolve as resolve12 } from "path";
6531
+ import { spawnSync as spawnSync5 } from "child_process";
6532
+ import { dirname as dirname15, join as join23, resolve as resolve13 } from "path";
6171
6533
  var PASS = "pass";
6172
6534
  var WARN = "warn";
6173
6535
  var FAIL = "fail";
@@ -6177,7 +6539,7 @@ var CODEX_ENV_DRIFT_KEYS = [
6177
6539
  "TAP_REPO_ROOT"
6178
6540
  ];
6179
6541
  function normalizeComparablePath2(value) {
6180
- return resolve12(value).replace(/\\/g, "/").toLowerCase();
6542
+ return resolve13(value).replace(/\\/g, "/").toLowerCase();
6181
6543
  }
6182
6544
  function samePath(left, right) {
6183
6545
  return normalizeComparablePath2(left) === normalizeComparablePath2(right);
@@ -6195,10 +6557,10 @@ function appendWarningMessage(message, extra) {
6195
6557
  return message.includes(extra) ? message : `${message}; ${extra}`;
6196
6558
  }
6197
6559
  function findCodexConfigPath3() {
6198
- return join17(homedir3(), ".codex", "config.toml");
6560
+ return join23(homedir3(), ".codex", "config.toml");
6199
6561
  }
6200
6562
  function canonicalizeTrustPath3(targetPath) {
6201
- let resolved = resolve12(targetPath).replace(/\//g, "\\");
6563
+ let resolved = resolve13(targetPath).replace(/\//g, "\\");
6202
6564
  const driveRoot = /^[A-Za-z]:\\$/;
6203
6565
  if (!driveRoot.test(resolved)) {
6204
6566
  resolved = resolved.replace(/\\+$/g, "");
@@ -6209,19 +6571,19 @@ function trustSelector2(targetPath) {
6209
6571
  return `projects.'${canonicalizeTrustPath3(targetPath)}'`;
6210
6572
  }
6211
6573
  function writeTomlAtomically(filePath, content) {
6212
- const dir = dirname11(filePath);
6213
- mkdirSync10(dir, { recursive: true });
6574
+ const dir = dirname15(filePath);
6575
+ mkdirSync13(dir, { recursive: true });
6214
6576
  const tmp = `${filePath}.tmp.${process.pid}`;
6215
- writeFileSync12(tmp, content, "utf-8");
6216
- renameSync10(tmp, filePath);
6577
+ writeFileSync13(tmp, content, "utf-8");
6578
+ renameSync11(tmp, filePath);
6217
6579
  }
6218
6580
  function hasInstalledCodexInstance(state) {
6219
- return !!state ? Object.values(state.instances).some(
6220
- (instance2) => instance2.runtime === "codex" && instance2.installed
6581
+ return state ? Object.values(state.instances).some(
6582
+ (instance) => instance.runtime === "codex" && instance.installed
6221
6583
  ) : false;
6222
6584
  }
6223
6585
  function getCodexTrustTargets(repoRoot) {
6224
- return [...new Set([repoRoot, process.cwd()].map((value) => resolve12(value)))];
6586
+ return [...new Set([repoRoot, process.cwd()].map((value) => resolve13(value)))];
6225
6587
  }
6226
6588
  function buildCodexDoctorSpec(repoRoot, commsDir) {
6227
6589
  const state = loadState(repoRoot);
@@ -6246,8 +6608,11 @@ function repairCodexConfig(repoRoot, commsDir) {
6246
6608
  spec.managed.issues[0] ?? "Unable to resolve the managed tap MCP server for Codex."
6247
6609
  );
6248
6610
  }
6249
- const existingContent = existsSync16(spec.configPath) ? readFileSync14(spec.configPath, "utf-8") : "";
6250
- const existingTapEnvTable = extractTomlTable(existingContent, "mcp_servers.tap.env");
6611
+ const existingContent = existsSync23(spec.configPath) ? readFileSync18(spec.configPath, "utf-8") : "";
6612
+ const existingTapEnvTable = extractTomlTable(
6613
+ existingContent,
6614
+ "mcp_servers.tap.env"
6615
+ );
6251
6616
  const existingLegacyEnvTable = extractTomlTable(
6252
6617
  existingContent,
6253
6618
  "mcp_servers.tap-comms.env"
@@ -6305,22 +6670,22 @@ function repairCodexConfig(repoRoot, commsDir) {
6305
6670
  return `Repaired Codex config at ${spec.configPath}. Restart Codex to reload MCP settings.`;
6306
6671
  }
6307
6672
  function countFiles(dir, ext = ".md") {
6308
- if (!existsSync16(dir)) return 0;
6673
+ if (!existsSync23(dir)) return 0;
6309
6674
  try {
6310
- return readdirSync4(dir).filter((f) => f.endsWith(ext)).length;
6675
+ return readdirSync6(dir).filter((f) => f.endsWith(ext)).length;
6311
6676
  } catch {
6312
6677
  return 0;
6313
6678
  }
6314
6679
  }
6315
6680
  function recentFileCount(dir, withinMs) {
6316
- if (!existsSync16(dir)) return 0;
6681
+ if (!existsSync23(dir)) return 0;
6317
6682
  const cutoff = Date.now() - withinMs;
6318
6683
  let count = 0;
6319
6684
  try {
6320
- for (const f of readdirSync4(dir)) {
6685
+ for (const f of readdirSync6(dir)) {
6321
6686
  if (!f.endsWith(".md")) continue;
6322
6687
  try {
6323
- if (statSync2(join17(dir, f)).mtimeMs > cutoff) count++;
6688
+ if (statSync3(join23(dir, f)).mtimeMs > cutoff) count++;
6324
6689
  } catch {
6325
6690
  }
6326
6691
  }
@@ -6332,10 +6697,10 @@ function checkComms(commsDir) {
6332
6697
  const checks = [];
6333
6698
  checks.push({
6334
6699
  name: "comms directory",
6335
- status: existsSync16(commsDir) ? PASS : FAIL,
6336
- message: existsSync16(commsDir) ? commsDir : `Not found: ${commsDir}`,
6337
- fix: existsSync16(commsDir) ? void 0 : () => {
6338
- mkdirSync10(commsDir, { recursive: true });
6700
+ status: existsSync23(commsDir) ? PASS : FAIL,
6701
+ message: existsSync23(commsDir) ? commsDir : `Not found: ${commsDir}`,
6702
+ fix: existsSync23(commsDir) ? void 0 : () => {
6703
+ mkdirSync13(commsDir, { recursive: true });
6339
6704
  return `Created ${commsDir}`;
6340
6705
  }
6341
6706
  });
@@ -6344,22 +6709,22 @@ function checkComms(commsDir) {
6344
6709
  ["reviews", false],
6345
6710
  ["findings", false]
6346
6711
  ]) {
6347
- const dir = join17(commsDir, subdir);
6348
- const exists = existsSync16(dir);
6712
+ const dir = join23(commsDir, subdir);
6713
+ const exists = existsSync23(dir);
6349
6714
  checks.push({
6350
6715
  name: `${subdir} directory`,
6351
6716
  status: exists ? PASS : required ? FAIL : WARN,
6352
6717
  message: exists ? subdir === "findings" ? `${countFiles(dir)} findings` : subdir === "inbox" ? `${countFiles(dir)} messages` : "exists" : `Missing${required ? "" : " (optional)"}`,
6353
6718
  fix: exists ? void 0 : () => {
6354
- mkdirSync10(dir, { recursive: true });
6719
+ mkdirSync13(dir, { recursive: true });
6355
6720
  return `Created ${dir}`;
6356
6721
  }
6357
6722
  });
6358
6723
  }
6359
- const heartbeats = join17(commsDir, "heartbeats.json");
6360
- if (existsSync16(heartbeats)) {
6724
+ const heartbeats = join23(commsDir, "heartbeats.json");
6725
+ if (existsSync23(heartbeats)) {
6361
6726
  try {
6362
- const store = JSON.parse(readFileSync14(heartbeats, "utf-8"));
6727
+ const store = JSON.parse(readFileSync18(heartbeats, "utf-8"));
6363
6728
  const agents = Object.keys(store);
6364
6729
  const now = Date.now();
6365
6730
  const active = agents.filter((a) => {
@@ -6433,15 +6798,21 @@ function checkInstances(repoRoot, stateDir) {
6433
6798
  for (const pid of [appServer.auth?.gatewayPid, appServer.pid]) {
6434
6799
  if (pid) {
6435
6800
  try {
6436
- process.kill(pid);
6801
+ if (process.platform === "win32") {
6802
+ spawnSync5("taskkill", ["/PID", String(pid), "/F", "/T"], {
6803
+ stdio: "pipe"
6804
+ });
6805
+ } else {
6806
+ process.kill(pid);
6807
+ }
6437
6808
  } catch {
6438
6809
  }
6439
6810
  }
6440
6811
  }
6441
6812
  }
6442
- const pidPath = join17(stateDir, "pids", `bridge-${id}.json`);
6813
+ const pidPath = join23(stateDir, "pids", `bridge-${id}.json`);
6443
6814
  try {
6444
- unlinkSync3(pidPath);
6815
+ unlinkSync6(pidPath);
6445
6816
  } catch {
6446
6817
  }
6447
6818
  const currentState = loadState(repoRoot);
@@ -6495,8 +6866,8 @@ function checkInstances(repoRoot, stateDir) {
6495
6866
  }
6496
6867
  function checkMessageLifecycle(commsDir) {
6497
6868
  const checks = [];
6498
- const inbox = join17(commsDir, "inbox");
6499
- if (!existsSync16(inbox)) {
6869
+ const inbox = join23(commsDir, "inbox");
6870
+ if (!existsSync23(inbox)) {
6500
6871
  checks.push({
6501
6872
  name: "message flow",
6502
6873
  status: FAIL,
@@ -6512,10 +6883,10 @@ function checkMessageLifecycle(commsDir) {
6512
6883
  status: recent10m > 0 ? PASS : total > 0 ? WARN : FAIL,
6513
6884
  message: `${total} total, ${recent1h} in last 1h, ${recent10m} in last 10m`
6514
6885
  });
6515
- const receiptsPath = join17(commsDir, "receipts", "receipts.json");
6516
- if (existsSync16(receiptsPath)) {
6886
+ const receiptsPath = join23(commsDir, "receipts", "receipts.json");
6887
+ if (existsSync23(receiptsPath)) {
6517
6888
  try {
6518
- const receipts = JSON.parse(readFileSync14(receiptsPath, "utf-8"));
6889
+ const receipts = JSON.parse(readFileSync18(receiptsPath, "utf-8"));
6519
6890
  const receiptCount = Object.keys(receipts).length;
6520
6891
  checks.push({
6521
6892
  name: "read receipts",
@@ -6534,8 +6905,8 @@ function checkMessageLifecycle(commsDir) {
6534
6905
  }
6535
6906
  function checkMcpServer(repoRoot) {
6536
6907
  const checks = [];
6537
- const mcpJson = join17(repoRoot, ".mcp.json");
6538
- if (!existsSync16(mcpJson)) {
6908
+ const mcpJson = join23(repoRoot, ".mcp.json");
6909
+ if (!existsSync23(mcpJson)) {
6539
6910
  checks.push({
6540
6911
  name: "MCP config (.mcp.json)",
6541
6912
  status: WARN,
@@ -6545,7 +6916,7 @@ function checkMcpServer(repoRoot) {
6545
6916
  }
6546
6917
  let config;
6547
6918
  try {
6548
- config = JSON.parse(readFileSync14(mcpJson, "utf-8"));
6919
+ config = JSON.parse(readFileSync18(mcpJson, "utf-8"));
6549
6920
  } catch {
6550
6921
  checks.push({
6551
6922
  name: "MCP config (.mcp.json)",
@@ -6573,6 +6944,14 @@ function checkMcpServer(repoRoot) {
6573
6944
  return checks;
6574
6945
  }
6575
6946
  const hasTapComms = hasTap ?? hasOldKey;
6947
+ if (!hasTapComms) {
6948
+ checks.push({
6949
+ name: "MCP config (.mcp.json)",
6950
+ status: FAIL,
6951
+ message: "No tap or tap-comms key found in .mcp.json"
6952
+ });
6953
+ return checks;
6954
+ }
6576
6955
  checks.push({
6577
6956
  name: "MCP config (.mcp.json)",
6578
6957
  status: PASS,
@@ -6580,10 +6959,10 @@ function checkMcpServer(repoRoot) {
6580
6959
  });
6581
6960
  if (hasTapComms.command) {
6582
6961
  const cmd = hasTapComms.command;
6583
- let cmdAvailable = existsSync16(cmd);
6962
+ let cmdAvailable = existsSync23(cmd);
6584
6963
  if (!cmdAvailable) {
6585
6964
  try {
6586
- const result = spawnSync4(cmd, ["--version"], {
6965
+ const result = spawnSync5(cmd, ["--version"], {
6587
6966
  stdio: "pipe",
6588
6967
  timeout: 5e3,
6589
6968
  shell: process.platform === "win32"
@@ -6602,8 +6981,8 @@ function checkMcpServer(repoRoot) {
6602
6981
  const mcpScript = hasTapComms.args[0];
6603
6982
  checks.push({
6604
6983
  name: "MCP server script",
6605
- status: existsSync16(mcpScript) ? PASS : FAIL,
6606
- message: existsSync16(mcpScript) ? mcpScript : `Not found: ${mcpScript}`
6984
+ status: existsSync23(mcpScript) ? PASS : FAIL,
6985
+ message: existsSync23(mcpScript) ? mcpScript : `Not found: ${mcpScript}`
6607
6986
  });
6608
6987
  if (mcpScript.endsWith(".mjs") && hasTapComms.command && !hasTapComms.command.includes("bun")) {
6609
6988
  checks.push({
@@ -6636,8 +7015,8 @@ function checkMcpServer(repoRoot) {
6636
7015
  } else {
6637
7016
  checks.push({
6638
7017
  name: "MCP TAP_COMMS_DIR",
6639
- status: existsSync16(envCommsDir) ? PASS : FAIL,
6640
- message: existsSync16(envCommsDir) ? envCommsDir : `Directory not found: ${envCommsDir}`
7018
+ status: existsSync23(envCommsDir) ? PASS : FAIL,
7019
+ message: existsSync23(envCommsDir) ? envCommsDir : `Directory not found: ${envCommsDir}`
6641
7020
  });
6642
7021
  }
6643
7022
  checks.push({
@@ -6654,7 +7033,7 @@ function checkCodexConfig(repoRoot, commsDir) {
6654
7033
  }
6655
7034
  const checks = [];
6656
7035
  const fixHint = 'Run "tap doctor --fix" or "tap add codex --force".';
6657
- if (!existsSync16(spec.configPath)) {
7036
+ if (!existsSync23(spec.configPath)) {
6658
7037
  checks.push({
6659
7038
  name: "MCP config (~/.codex/config.toml)",
6660
7039
  status: WARN,
@@ -6663,15 +7042,13 @@ function checkCodexConfig(repoRoot, commsDir) {
6663
7042
  });
6664
7043
  return checks;
6665
7044
  }
6666
- const content = readFileSync14(spec.configPath, "utf-8");
7045
+ const content = readFileSync18(spec.configPath, "utf-8");
6667
7046
  const tapTable = extractTomlTable(content, "mcp_servers.tap");
6668
7047
  const tapEnvTable = extractTomlTable(content, "mcp_servers.tap.env");
6669
7048
  const legacyTable = extractTomlTable(content, "mcp_servers.tap-comms");
6670
7049
  const legacyEnvTable = extractTomlTable(content, "mcp_servers.tap-comms.env");
6671
7050
  const selectedMain = parseTomlAssignments(tapTable ?? "");
6672
- const selectedEnv = parseTomlAssignments(
6673
- tapEnvTable ?? legacyEnvTable ?? ""
6674
- );
7051
+ const selectedEnv = parseTomlAssignments(tapEnvTable ?? legacyEnvTable ?? "");
6675
7052
  const issues = [];
6676
7053
  if (legacyTable || legacyEnvTable) {
6677
7054
  issues.push('legacy "tap-comms" key present');
@@ -6731,8 +7108,8 @@ function checkCodexConfig(repoRoot, commsDir) {
6731
7108
  }
6732
7109
  function checkBridgeTurnHealth(repoRoot) {
6733
7110
  const checks = [];
6734
- const tmpDir = join17(repoRoot, ".tmp");
6735
- if (!existsSync16(tmpDir)) return checks;
7111
+ const tmpDir = join23(repoRoot, ".tmp");
7112
+ if (!existsSync23(tmpDir)) return checks;
6736
7113
  const state = loadState(repoRoot);
6737
7114
  const activeMatchers = /* @__PURE__ */ new Set();
6738
7115
  if (state) {
@@ -6745,7 +7122,7 @@ function checkBridgeTurnHealth(repoRoot) {
6745
7122
  }
6746
7123
  let dirs;
6747
7124
  try {
6748
- dirs = readdirSync4(tmpDir).filter((d) => {
7125
+ dirs = readdirSync6(tmpDir).filter((d) => {
6749
7126
  if (!d.startsWith("codex-app-server-bridge")) return false;
6750
7127
  const suffix = d.replace("codex-app-server-bridge-", "");
6751
7128
  if (activeMatchers.size === 0) return true;
@@ -6758,11 +7135,11 @@ function checkBridgeTurnHealth(repoRoot) {
6758
7135
  return checks;
6759
7136
  }
6760
7137
  for (const dir of dirs) {
6761
- const heartbeatPath = join17(tmpDir, dir, "heartbeat.json");
6762
- if (!existsSync16(heartbeatPath)) continue;
7138
+ const heartbeatPath = join23(tmpDir, dir, "heartbeat.json");
7139
+ if (!existsSync23(heartbeatPath)) continue;
6763
7140
  let heartbeat;
6764
7141
  try {
6765
- heartbeat = JSON.parse(readFileSync14(heartbeatPath, "utf-8"));
7142
+ heartbeat = JSON.parse(readFileSync18(heartbeatPath, "utf-8"));
6766
7143
  } catch {
6767
7144
  checks.push({
6768
7145
  name: `turn: ${dir}`,
@@ -6985,9 +7362,9 @@ async function doctorCommand(args) {
6985
7362
  }
6986
7363
 
6987
7364
  // src/commands/comms.ts
6988
- import { execSync as execSync6, spawnSync as spawnSync5 } from "child_process";
6989
- import * as fs17 from "fs";
6990
- import * as path18 from "path";
7365
+ import { execSync as execSync6, spawnSync as spawnSync6 } from "child_process";
7366
+ import * as fs27 from "fs";
7367
+ import * as path26 from "path";
6991
7368
  var COMMS_HELP = `
6992
7369
  Usage:
6993
7370
  tap comms <subcommand>
@@ -7001,7 +7378,7 @@ Examples:
7001
7378
  npx @hua-labs/tap comms push
7002
7379
  `.trim();
7003
7380
  function isGitRepo(dir) {
7004
- return fs17.existsSync(path18.join(dir, ".git"));
7381
+ return fs27.existsSync(path26.join(dir, ".git"));
7005
7382
  }
7006
7383
  function commsPull(commsDir) {
7007
7384
  logHeader("tap comms pull");
@@ -7077,7 +7454,7 @@ function commsPush(commsDir) {
7077
7454
  };
7078
7455
  }
7079
7456
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7080
- const commitResult = spawnSync5(
7457
+ const commitResult = spawnSync6(
7081
7458
  "git",
7082
7459
  ["commit", "-m", `chore(comms): sync ${timestamp}`],
7083
7460
  { cwd: commsDir, stdio: "pipe", encoding: "utf-8" }
@@ -7148,6 +7525,568 @@ async function commsCommand(args) {
7148
7525
  }
7149
7526
  }
7150
7527
 
7528
+ // src/commands/watch.ts
7529
+ var WATCH_HELP = `
7530
+ Usage:
7531
+ tap watch [options]
7532
+
7533
+ Description:
7534
+ Monitor all bridges and auto-restart stuck/stale ones.
7535
+ Single-pass by default. Use --loop for continuous monitoring.
7536
+
7537
+ Options:
7538
+ --stuck-threshold <seconds> Turn stuck threshold (default: 300)
7539
+ --interval <seconds> Loop interval (default: 60)
7540
+ --loop Run continuously instead of single-pass
7541
+ --max-rounds <n> Max loop iterations (default: unlimited)
7542
+
7543
+ Examples:
7544
+ npx @hua-labs/tap watch # single check
7545
+ npx @hua-labs/tap watch --loop # continuous
7546
+ npx @hua-labs/tap watch --loop --interval 30 # check every 30s
7547
+ npx @hua-labs/tap watch --stuck-threshold 120 # 2 min threshold
7548
+ `.trim();
7549
+ function delay2(ms) {
7550
+ return new Promise((resolve14) => setTimeout(resolve14, ms));
7551
+ }
7552
+ async function watchCommand(args) {
7553
+ const { flags } = parseArgs(args);
7554
+ if (flags["help"] === true || flags["h"] === true) {
7555
+ log(WATCH_HELP);
7556
+ return {
7557
+ ok: true,
7558
+ command: "watch",
7559
+ code: "TAP_NO_OP",
7560
+ message: WATCH_HELP,
7561
+ warnings: [],
7562
+ data: {}
7563
+ };
7564
+ }
7565
+ const stuckThresholdStr = typeof flags["stuck-threshold"] === "string" ? flags["stuck-threshold"] : void 0;
7566
+ const intervalStr = typeof flags["interval"] === "string" ? flags["interval"] : void 0;
7567
+ const loop = flags["loop"] === true;
7568
+ const maxRoundsStr = typeof flags["max-rounds"] === "string" ? flags["max-rounds"] : void 0;
7569
+ let stuckThreshold;
7570
+ let interval;
7571
+ let maxRounds;
7572
+ try {
7573
+ stuckThreshold = parseIntFlag(stuckThresholdStr, "--stuck-threshold", 30, 3600) ?? 300;
7574
+ interval = parseIntFlag(intervalStr, "--interval", 5, 3600) ?? 60;
7575
+ maxRounds = parseIntFlag(maxRoundsStr, "--max-rounds", 1, 1e4) ?? null;
7576
+ } catch (err) {
7577
+ return {
7578
+ ok: false,
7579
+ command: "watch",
7580
+ code: "TAP_INVALID_ARGUMENT",
7581
+ message: err instanceof Error ? err.message : String(err),
7582
+ warnings: [],
7583
+ data: {}
7584
+ };
7585
+ }
7586
+ const bridgeArgs = ["watch", "--stuck-threshold", String(stuckThreshold)];
7587
+ if (!loop) {
7588
+ return bridgeCommand(bridgeArgs);
7589
+ }
7590
+ logHeader("@hua-labs/tap watch (loop mode)");
7591
+ log(`Interval: ${interval}s, Stuck threshold: ${stuckThreshold}s`);
7592
+ if (maxRounds != null) {
7593
+ log(`Max rounds: ${maxRounds}`);
7594
+ }
7595
+ log("");
7596
+ let round = 0;
7597
+ let failedRounds = 0;
7598
+ const allRestarted = [];
7599
+ const allWarnings = [];
7600
+ while (maxRounds == null || round < maxRounds) {
7601
+ round++;
7602
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
7603
+ log(`[${timestamp}] Round ${round}`);
7604
+ const result = await bridgeCommand(bridgeArgs);
7605
+ if (!result.ok) {
7606
+ failedRounds++;
7607
+ allWarnings.push(`Round ${round}: ${result.message}`);
7608
+ }
7609
+ if (result.data?.restarted) {
7610
+ const restarted = result.data.restarted;
7611
+ allRestarted.push(...restarted);
7612
+ }
7613
+ if (result.warnings?.length) {
7614
+ allWarnings.push(...result.warnings);
7615
+ }
7616
+ if (maxRounds != null && round >= maxRounds) break;
7617
+ await delay2(interval * 1e3);
7618
+ }
7619
+ const allOk = failedRounds === 0;
7620
+ const message = [
7621
+ `Completed ${round} round(s)`,
7622
+ failedRounds > 0 ? `${failedRounds} failed` : null,
7623
+ allRestarted.length > 0 ? `Total restarts: ${allRestarted.length} (${allRestarted.join(", ")})` : "No restarts needed"
7624
+ ].filter(Boolean).join(". ");
7625
+ return {
7626
+ ok: allOk,
7627
+ command: "watch",
7628
+ code: !allOk ? "TAP_WATCH_FAILED" : allRestarted.length > 0 ? "TAP_WATCH_RESTARTED" : "TAP_WATCH_OK",
7629
+ message,
7630
+ warnings: allWarnings,
7631
+ data: { rounds: round, restarted: allRestarted }
7632
+ };
7633
+ }
7634
+
7635
+ // src/commands/gui.ts
7636
+ import * as http from "http";
7637
+
7638
+ // src/engine/missions.ts
7639
+ import * as fs28 from "fs";
7640
+ import * as path27 from "path";
7641
+ function parseStatus(raw) {
7642
+ const trimmed = raw.trim();
7643
+ if (trimmed.includes("active")) return "active";
7644
+ if (trimmed.includes("completed")) return "completed";
7645
+ return "planned";
7646
+ }
7647
+ function parseRow(line) {
7648
+ if (!line.startsWith("|") || !line.endsWith("|")) return null;
7649
+ const cells = line.split("|").slice(1, -1).map((c) => c.trim());
7650
+ if (cells.length < 4) return null;
7651
+ const [idCell, missionCell, branchCell, statusCell, ownerCell] = cells;
7652
+ if (/^[-: ]+$/.test(idCell ?? "")) return null;
7653
+ const id = (idCell ?? "").replace(/[^\w]/g, "");
7654
+ if (!id || !/^M\d+$/i.test(id)) return null;
7655
+ const titleMatch = missionCell?.match(/\[([^\]]+)\]/);
7656
+ const title = titleMatch ? titleMatch[1] : (missionCell ?? "").trim();
7657
+ if (!title) return null;
7658
+ const branchMatch = branchCell?.match(/`([^`]+)`/);
7659
+ const branch = branchMatch ? branchMatch[1] : null;
7660
+ const status = parseStatus(statusCell ?? "");
7661
+ const rawOwner = (ownerCell ?? "").trim();
7662
+ const owner = rawOwner === "" || rawOwner === "\u2014" || rawOwner === "\uBBF8\uBC30\uC815" ? null : rawOwner;
7663
+ return { id: id.toUpperCase(), title, branch, status, owner };
7664
+ }
7665
+ function parseMissionsFile(repoRoot) {
7666
+ const missionsPath = path27.join(repoRoot, "docs", "missions", "MISSIONS.md");
7667
+ let content;
7668
+ try {
7669
+ content = fs28.readFileSync(missionsPath, "utf-8");
7670
+ } catch {
7671
+ return [];
7672
+ }
7673
+ const missions = [];
7674
+ for (const line of content.split("\n")) {
7675
+ const mission = parseRow(line);
7676
+ if (!mission) continue;
7677
+ missions.push(mission);
7678
+ }
7679
+ return missions;
7680
+ }
7681
+
7682
+ // src/engine/pull-requests.ts
7683
+ import { spawnSync as spawnSync7 } from "child_process";
7684
+ function runGhPrList(repoRoot, extraArgs) {
7685
+ try {
7686
+ const result = spawnSync7(
7687
+ "gh",
7688
+ [
7689
+ "pr",
7690
+ "list",
7691
+ "--json",
7692
+ "number,title,state,author,headRefName,url,mergedAt",
7693
+ ...extraArgs
7694
+ ],
7695
+ { cwd: repoRoot, encoding: "utf-8", timeout: 1e4 }
7696
+ );
7697
+ if (result.error || result.status !== 0) return null;
7698
+ const raw = result.stdout.trim();
7699
+ if (!raw) return null;
7700
+ return JSON.parse(raw);
7701
+ } catch {
7702
+ return null;
7703
+ }
7704
+ }
7705
+ function mapEntry(entry) {
7706
+ const state = entry.state?.toLowerCase();
7707
+ return {
7708
+ number: entry.number,
7709
+ title: entry.title ?? "",
7710
+ state: state === "merged" ? "merged" : state === "closed" ? "closed" : "open",
7711
+ author: entry.author?.login ?? "",
7712
+ branch: entry.headRefName ?? "",
7713
+ url: entry.url ?? "",
7714
+ mergedAt: entry.mergedAt ?? null
7715
+ };
7716
+ }
7717
+ function fetchOpenPrs(repoRoot) {
7718
+ const entries = runGhPrList(repoRoot, ["--limit", "50"]);
7719
+ if (!entries) return [];
7720
+ return entries.map(mapEntry);
7721
+ }
7722
+ function fetchMergedPrs(repoRoot, limit = 20) {
7723
+ const entries = runGhPrList(repoRoot, [
7724
+ "--state",
7725
+ "merged",
7726
+ "--limit",
7727
+ String(limit)
7728
+ ]);
7729
+ if (!entries) return [];
7730
+ return entries.map(mapEntry).sort((a, b) => {
7731
+ if (!a.mergedAt || !b.mergedAt) return 0;
7732
+ return new Date(b.mergedAt).getTime() - new Date(a.mergedAt).getTime();
7733
+ });
7734
+ }
7735
+ function fetchPrs(repoRoot) {
7736
+ return {
7737
+ open: fetchOpenPrs(repoRoot),
7738
+ merged: fetchMergedPrs(repoRoot)
7739
+ };
7740
+ }
7741
+
7742
+ // src/commands/gui.ts
7743
+ var GUI_HELP = `
7744
+ Usage:
7745
+ tap gui [options]
7746
+
7747
+ Description:
7748
+ Start a local web dashboard showing bridge status, agents, and turn info.
7749
+
7750
+ Options:
7751
+ --port <n> Dashboard port (default: 3847)
7752
+ --help, -h Show help
7753
+
7754
+ Examples:
7755
+ npx @hua-labs/tap gui
7756
+ npx @hua-labs/tap gui --port 8080
7757
+ `.trim();
7758
+ function esc(str) {
7759
+ if (!str) return "-";
7760
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7761
+ }
7762
+ function buildHtml(snapshot, turnData) {
7763
+ const agentRows = snapshot.agents.map(
7764
+ (a) => `<tr><td>${esc(a.name)}</td><td class="${a.status === "active" ? "ok" : "warn"}">${esc(a.status)}</td><td>${a.lastActivity ? esc(new Date(a.lastActivity).toLocaleTimeString()) : "-"}</td></tr>`
7765
+ ).join("\n");
7766
+ const bridgeRows = snapshot.bridges.map((b) => {
7767
+ const turn = turnData[b.instanceId];
7768
+ const turnCell = turn?.activeTurnId ? `<span class="${turn.stuck ? "stuck" : "ok"}">${esc(turn.activeTurnId.slice(0, 8))}... ${turn.stuck ? "\u26A0 STUCK" : ""} ${turn.ageSeconds != null ? `(${turn.ageSeconds}s)` : ""}</span>` : "-";
7769
+ const statusClass = b.status === "running" ? "ok" : b.status === "stale" ? "stuck" : "off";
7770
+ return `<tr><td>${esc(b.instanceId)}</td><td>${esc(b.runtime)}</td><td class="${statusClass}">${esc(b.status)}</td><td>${b.pid ?? "-"}</td><td>${b.port ?? "-"}</td><td>${b.heartbeatAge != null ? `${b.heartbeatAge}s ago` : "-"}</td><td>${turnCell}</td></tr>`;
7771
+ }).join("\n");
7772
+ const warningRows = snapshot.warnings.map(
7773
+ (w) => `<tr><td class="warn">${esc(w.level)}</td><td>${esc(w.message)}</td></tr>`
7774
+ ).join("\n");
7775
+ return `<!DOCTYPE html>
7776
+ <html lang="en">
7777
+ <head>
7778
+ <meta charset="utf-8">
7779
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7780
+ <title>tap dashboard</title>
7781
+ <style>
7782
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0d1117; color: #c9d1d9; margin: 0; padding: 20px; }
7783
+ h1 { color: #58a6ff; font-size: 1.4em; }
7784
+ h2 { color: #8b949e; font-size: 1.1em; margin-top: 24px; }
7785
+ table { border-collapse: collapse; width: 100%; margin: 8px 0; }
7786
+ th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #21262d; }
7787
+ th { color: #8b949e; font-size: 0.85em; text-transform: uppercase; }
7788
+ .ok { color: #3fb950; }
7789
+ .warn { color: #d29922; }
7790
+ .stuck { color: #f85149; font-weight: bold; }
7791
+ .off { color: #8b949e; }
7792
+ .meta { color: #8b949e; font-size: 0.85em; }
7793
+ .refresh { color: #8b949e; font-size: 0.8em; margin-top: 16px; }
7794
+ </style>
7795
+ </head>
7796
+ <body>
7797
+ <h1>tap dashboard</h1>
7798
+ <p class="meta">${esc(snapshot.generatedAt)} &middot; ${esc(snapshot.repoRoot)}</p>
7799
+
7800
+ <h2>Agents</h2>
7801
+ <table>
7802
+ <tr><th>Name</th><th>Status</th><th>Last Activity</th></tr>
7803
+ ${agentRows || '<tr><td colspan="3" class="off">No agents</td></tr>'}
7804
+ </table>
7805
+
7806
+ <h2>Bridges</h2>
7807
+ <table>
7808
+ <tr><th>Instance</th><th>Runtime</th><th>Status</th><th>PID</th><th>Port</th><th>Heartbeat</th><th>Turn</th></tr>
7809
+ ${bridgeRows || '<tr><td colspan="7" class="off">No bridges</td></tr>'}
7810
+ </table>
7811
+
7812
+ ${warningRows ? `<h2>Warnings</h2><table><tr><th>Level</th><th>Message</th></tr>${warningRows}</table>` : ""}
7813
+
7814
+ <p class="refresh" id="status">Connecting to live updates...</p>
7815
+ <script>
7816
+ const es = new EventSource('/api/events');
7817
+ const statusEl = document.getElementById('status');
7818
+ let lastReloadAt = Date.now();
7819
+ es.onmessage = (e) => {
7820
+ statusEl.textContent = 'Live \u2014 updated ' + new Date().toLocaleTimeString();
7821
+ statusEl.style.color = '#3fb950';
7822
+ const elapsed = Date.now() - lastReloadAt;
7823
+ if (elapsed >= 9000) { lastReloadAt = Date.now(); location.reload(); }
7824
+ };
7825
+ es.onerror = () => {
7826
+ statusEl.textContent = 'Disconnected \u2014 will retry...';
7827
+ statusEl.style.color = '#f85149';
7828
+ };
7829
+ </script>
7830
+ <p class="refresh"><a href="/missions" style="color:#58a6ff;">Mission Kanban</a> &middot; <a href="/prs" style="color:#58a6ff;">PR Board</a></p>
7831
+ </body>
7832
+ </html>`;
7833
+ }
7834
+ function buildMissionsHtml(repoRoot) {
7835
+ const missions = parseMissionsFile(repoRoot);
7836
+ const byStatus = {
7837
+ active: missions.filter((m) => m.status === "active"),
7838
+ planned: missions.filter((m) => m.status === "planned"),
7839
+ completed: missions.filter((m) => m.status === "completed")
7840
+ };
7841
+ function card(m) {
7842
+ return `<div class="card">
7843
+ <div class="card-id">${esc(m.id)}</div>
7844
+ <div class="card-title">${esc(m.title)}</div>
7845
+ ${m.owner ? `<div class="card-meta">Owner: ${esc(m.owner)}</div>` : ""}
7846
+ ${m.branch ? `<div class="card-meta card-branch">${esc(m.branch)}</div>` : ""}
7847
+ </div>`;
7848
+ }
7849
+ function column(label, headerClass, items) {
7850
+ return `<div class="column">
7851
+ <div class="col-header ${headerClass}">${label} <span class="badge">${items.length}</span></div>
7852
+ <div class="col-body">
7853
+ ${items.length ? items.map(card).join("\n ") : '<div class="empty">No missions</div>'}
7854
+ </div>
7855
+ </div>`;
7856
+ }
7857
+ return `<!DOCTYPE html>
7858
+ <html lang="en">
7859
+ <head>
7860
+ <meta charset="utf-8">
7861
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7862
+ <title>tap \u2014 mission kanban</title>
7863
+ <meta http-equiv="refresh" content="30">
7864
+ <style>
7865
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0d1117; color: #c9d1d9; margin: 0; padding: 20px; }
7866
+ h1 { color: #58a6ff; font-size: 1.4em; }
7867
+ a { color: #58a6ff; text-decoration: none; }
7868
+ a:hover { text-decoration: underline; }
7869
+ .meta { color: #8b949e; font-size: 0.85em; }
7870
+ .refresh { color: #8b949e; font-size: 0.8em; margin-top: 16px; }
7871
+ .board { display: flex; gap: 16px; margin-top: 16px; align-items: flex-start; flex-wrap: wrap; }
7872
+ .column { flex: 1; min-width: 240px; background: #161b22; border: 1px solid #21262d; border-radius: 6px; overflow: hidden; }
7873
+ .col-header { padding: 10px 14px; font-size: 0.85em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; display: flex; justify-content: space-between; align-items: center; }
7874
+ .col-header.active { color: #3fb950; border-bottom: 2px solid #3fb950; }
7875
+ .col-header.planned { color: #d29922; border-bottom: 2px solid #d29922; }
7876
+ .col-header.completed { color: #8b949e; border-bottom: 2px solid #8b949e; }
7877
+ .badge { background: #21262d; color: #c9d1d9; border-radius: 10px; padding: 1px 7px; font-size: 0.8em; }
7878
+ .col-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; }
7879
+ .card { background: #0d1117; border: 1px solid #21262d; border-radius: 4px; padding: 10px 12px; }
7880
+ .card-id { font-size: 0.75em; color: #58a6ff; font-weight: 600; margin-bottom: 4px; }
7881
+ .card-title { font-size: 0.9em; color: #e6edf3; line-height: 1.4; }
7882
+ .card-meta { font-size: 0.75em; color: #8b949e; margin-top: 4px; }
7883
+ .card-branch { font-family: ui-monospace, monospace; color: #6e7681; }
7884
+ .empty { color: #6e7681; font-size: 0.85em; padding: 8px 4px; }
7885
+ </style>
7886
+ </head>
7887
+ <body>
7888
+ <h1>mission kanban</h1>
7889
+ <p class="meta"><a href="/">&larr; Dashboard</a> &middot; ${esc(repoRoot)}</p>
7890
+ <div class="board">
7891
+ ${column("Active", "active", byStatus.active)}
7892
+ ${column("Planned", "planned", byStatus.planned)}
7893
+ ${column("Completed", "completed", byStatus.completed)}
7894
+ </div>
7895
+ <p class="refresh">Auto-refresh every 30s</p>
7896
+ </body>
7897
+ </html>`;
7898
+ }
7899
+ function buildPrsHtml(repoRoot) {
7900
+ const { open, merged } = fetchPrs(repoRoot);
7901
+ function prRow(pr) {
7902
+ return `<tr>
7903
+ <td><a href="${esc(pr.url)}" target="_blank" rel="noopener" style="color:#58a6ff;">#${pr.number}</a></td>
7904
+ <td>${esc(pr.title)}</td>
7905
+ <td>${esc(pr.author)}</td>
7906
+ <td class="branch">${esc(pr.branch)}</td>
7907
+ </tr>`;
7908
+ }
7909
+ const openRows = open.map(prRow).join("\n");
7910
+ const mergedRows = merged.map(
7911
+ (pr) => `<tr>
7912
+ <td><a href="${esc(pr.url)}" target="_blank" rel="noopener" style="color:#58a6ff;">#${pr.number}</a></td>
7913
+ <td>${esc(pr.title)}</td>
7914
+ <td>${esc(pr.author)}</td>
7915
+ </tr>`
7916
+ ).join("\n");
7917
+ return `<!DOCTYPE html>
7918
+ <html lang="en">
7919
+ <head>
7920
+ <meta charset="utf-8">
7921
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7922
+ <title>tap \u2014 pr board</title>
7923
+ <meta http-equiv="refresh" content="60">
7924
+ <style>
7925
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0d1117; color: #c9d1d9; margin: 0; padding: 20px; }
7926
+ h1 { color: #58a6ff; font-size: 1.4em; }
7927
+ h2 { color: #8b949e; font-size: 1.1em; margin-top: 24px; }
7928
+ a { color: #58a6ff; text-decoration: none; }
7929
+ a:hover { text-decoration: underline; }
7930
+ table { border-collapse: collapse; width: 100%; margin: 8px 0; }
7931
+ th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #21262d; }
7932
+ th { color: #8b949e; font-size: 0.85em; text-transform: uppercase; }
7933
+ .branch { font-family: ui-monospace, monospace; font-size: 0.85em; color: #6e7681; }
7934
+ .meta { color: #8b949e; font-size: 0.85em; }
7935
+ .refresh { color: #8b949e; font-size: 0.8em; margin-top: 16px; }
7936
+ .off { color: #8b949e; }
7937
+ </style>
7938
+ </head>
7939
+ <body>
7940
+ <h1>pr board</h1>
7941
+ <p class="meta"><a href="/">&larr; Dashboard</a> &middot; ${esc(repoRoot)}</p>
7942
+
7943
+ <h2>Open PRs <span style="color:#3fb950;">(${open.length})</span></h2>
7944
+ <table>
7945
+ <tr><th>#</th><th>Title</th><th>Author</th><th>Branch</th></tr>
7946
+ ${openRows || '<tr><td colspan="4" class="off">No open PRs</td></tr>'}
7947
+ </table>
7948
+
7949
+ <h2>Recently Merged <span style="color:#8b949e;">(${merged.length})</span></h2>
7950
+ <table>
7951
+ <tr><th>#</th><th>Title</th><th>Author</th></tr>
7952
+ ${mergedRows || '<tr><td colspan="3" class="off">No merged PRs</td></tr>'}
7953
+ </table>
7954
+
7955
+ <p class="refresh">Auto-refresh every 60s</p>
7956
+ </body>
7957
+ </html>`;
7958
+ }
7959
+ async function guiCommand(args) {
7960
+ const { flags } = parseArgs(args);
7961
+ if (flags["help"] === true || flags["h"] === true) {
7962
+ log(GUI_HELP);
7963
+ return {
7964
+ ok: true,
7965
+ command: "gui",
7966
+ code: "TAP_NO_OP",
7967
+ message: GUI_HELP,
7968
+ warnings: [],
7969
+ data: {}
7970
+ };
7971
+ }
7972
+ const portStr = typeof flags["port"] === "string" ? flags["port"] : void 0;
7973
+ let port;
7974
+ try {
7975
+ port = parseIntFlag(portStr, "--port", 1024, 65535) ?? 3847;
7976
+ } catch (err) {
7977
+ return {
7978
+ ok: false,
7979
+ command: "gui",
7980
+ code: "TAP_INVALID_ARGUMENT",
7981
+ message: err instanceof Error ? err.message : String(err),
7982
+ warnings: [],
7983
+ data: {}
7984
+ };
7985
+ }
7986
+ const repoRoot = findRepoRoot();
7987
+ const server = http.createServer((req, res) => {
7988
+ const snapshot = collectDashboardSnapshot(repoRoot);
7989
+ const state = loadState(repoRoot);
7990
+ const { config } = resolveConfig({}, repoRoot);
7991
+ const turnData = {};
7992
+ if (state) {
7993
+ for (const [id, inst] of Object.entries(state.instances)) {
7994
+ if (!inst?.installed || inst.bridgeMode !== "app-server") continue;
7995
+ turnData[id] = getTurnInfo(config.stateDir, id);
7996
+ }
7997
+ }
7998
+ const jsonHeaders = {
7999
+ "Content-Type": "application/json",
8000
+ "Access-Control-Allow-Origin": "*"
8001
+ };
8002
+ if (req.url === "/api/snapshot") {
8003
+ res.writeHead(200, jsonHeaders);
8004
+ res.end(JSON.stringify({ ...snapshot, turns: turnData }, null, 2));
8005
+ return;
8006
+ }
8007
+ if (req.url === "/api/events") {
8008
+ res.writeHead(200, {
8009
+ "Content-Type": "text/event-stream",
8010
+ "Cache-Control": "no-cache",
8011
+ Connection: "keep-alive",
8012
+ "Access-Control-Allow-Origin": "*"
8013
+ });
8014
+ const sendEvent = () => {
8015
+ const s = collectDashboardSnapshot(repoRoot);
8016
+ const st = loadState(repoRoot);
8017
+ const cfg = resolveConfig({}, repoRoot).config;
8018
+ const td = {};
8019
+ if (st) {
8020
+ for (const [id, inst] of Object.entries(st.instances)) {
8021
+ if (!inst?.installed || inst.bridgeMode !== "app-server") continue;
8022
+ td[id] = getTurnInfo(cfg.stateDir, id);
8023
+ }
8024
+ }
8025
+ res.write(`data: ${JSON.stringify({ ...s, turns: td })}
8026
+
8027
+ `);
8028
+ };
8029
+ sendEvent();
8030
+ const interval = setInterval(sendEvent, 5e3);
8031
+ req.on("close", () => clearInterval(interval));
8032
+ return;
8033
+ }
8034
+ if (req.url === "/api/missions") {
8035
+ const missions = parseMissionsFile(repoRoot);
8036
+ res.writeHead(200, jsonHeaders);
8037
+ res.end(JSON.stringify(missions, null, 2));
8038
+ return;
8039
+ }
8040
+ if (req.url === "/missions") {
8041
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
8042
+ res.end(buildMissionsHtml(repoRoot));
8043
+ return;
8044
+ }
8045
+ if (req.url === "/api/prs") {
8046
+ const prs = fetchPrs(repoRoot);
8047
+ res.writeHead(200, jsonHeaders);
8048
+ res.end(JSON.stringify(prs, null, 2));
8049
+ return;
8050
+ }
8051
+ if (req.url === "/prs") {
8052
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
8053
+ res.end(buildPrsHtml(repoRoot));
8054
+ return;
8055
+ }
8056
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
8057
+ res.end(buildHtml(snapshot, turnData));
8058
+ });
8059
+ return new Promise((resolve14) => {
8060
+ server.on("error", (err) => {
8061
+ if (err.code === "EADDRINUSE") {
8062
+ resolve14({
8063
+ ok: false,
8064
+ command: "gui",
8065
+ code: "TAP_PORT_IN_USE",
8066
+ message: `Port ${port} is already in use. Try: tap gui --port <other>`,
8067
+ warnings: [],
8068
+ data: {}
8069
+ });
8070
+ } else {
8071
+ resolve14({
8072
+ ok: false,
8073
+ command: "gui",
8074
+ code: "TAP_GUI_ERROR",
8075
+ message: err.message,
8076
+ warnings: [],
8077
+ data: {}
8078
+ });
8079
+ }
8080
+ });
8081
+ server.listen(port, "127.0.0.1", () => {
8082
+ logHeader("tap gui dashboard");
8083
+ logSuccess(`Dashboard: http://127.0.0.1:${port}`);
8084
+ log(`API: http://127.0.0.1:${port}/api/snapshot`);
8085
+ log("Press Ctrl+C to stop");
8086
+ });
8087
+ });
8088
+ }
8089
+
7151
8090
  // src/output.ts
7152
8091
  function emitResult(result, jsonMode) {
7153
8092
  if (jsonMode) {
@@ -7240,6 +8179,8 @@ Commands:
7240
8179
  down Stop all running bridge daemons
7241
8180
  comms <pull|push> Sync comms directory with remote repo
7242
8181
  dashboard Show unified ops dashboard
8182
+ watch Monitor bridges and auto-restart stuck ones
8183
+ gui Start local web dashboard (http)
7243
8184
  doctor Diagnose tap infrastructure health
7244
8185
  serve Start tap MCP server (stdio)
7245
8186
  version Show version
@@ -7270,6 +8211,8 @@ function normalizeCommandName(command) {
7270
8211
  case "dashboard":
7271
8212
  case "doctor":
7272
8213
  case "serve":
8214
+ case "watch":
8215
+ case "gui":
7273
8216
  return command;
7274
8217
  default:
7275
8218
  return "unknown";
@@ -7334,6 +8277,12 @@ async function main() {
7334
8277
  case "doctor":
7335
8278
  result = await doctorCommand(commandArgs);
7336
8279
  break;
8280
+ case "watch":
8281
+ result = await watchCommand(commandArgs);
8282
+ break;
8283
+ case "gui":
8284
+ result = await guiCommand(commandArgs);
8285
+ break;
7337
8286
  case "serve": {
7338
8287
  const serveResult = await serveCommand(commandArgs);
7339
8288
  if (!serveResult.ok || serveResult.code === "TAP_NO_OP") {