@ait-co/devtools 0.1.89 → 0.1.91

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/mcp/cli.js CHANGED
@@ -84,6 +84,40 @@ function startParentWatcher(onOrphaned, opts) {
84
84
  clearInterval(handle);
85
85
  } };
86
86
  }
87
+ /**
88
+ * Starts a periodic watchdog that calls `onExpired` once after `maxAgeMs`
89
+ * milliseconds have elapsed since the watchdog was created.
90
+ *
91
+ * Motivation (issue #571): cloudflared quick-tunnel lifetimes are finite (a
92
+ * few hours). A daemon that has been running for days will have outlived its
93
+ * tunnel regardless of whether the tunnel process exited cleanly. This watchdog
94
+ * caps the daemon's maximum age and forces a fresh start so the tunnel is
95
+ * replaced before it silently expires.
96
+ *
97
+ * @param onExpired - Called once when the maximum age is reached. The caller
98
+ * should call `shutdown()` then `process.exit(0)`.
99
+ * @param opts.maxAgeMs - Maximum daemon lifetime in ms. Default 6 h.
100
+ * @param opts.intervalMs - Check interval in ms. Default 60 000 (1 min).
101
+ * @param opts.now - Time source (injectable for tests). Default `Date.now`.
102
+ *
103
+ * @returns `stop` — call during shutdown to clear the interval.
104
+ */
105
+ function startMaxAgeWatchdog(onExpired, opts = {}) {
106
+ const { maxAgeMs = 360 * 60 * 1e3, intervalMs = 6e4, now = () => Date.now() } = opts;
107
+ const startedAt = now();
108
+ let fired = false;
109
+ const handle = setInterval(() => {
110
+ if (fired) return;
111
+ if (now() - startedAt >= maxAgeMs) {
112
+ fired = true;
113
+ clearInterval(handle);
114
+ onExpired();
115
+ }
116
+ }, intervalMs);
117
+ return { stop() {
118
+ clearInterval(handle);
119
+ } };
120
+ }
87
121
  //#endregion
88
122
  //#region src/mcp/ait-chii-source.ts
89
123
  function isObject$4(value) {
@@ -3491,10 +3525,12 @@ function readLock(lockPath) {
3491
3525
  const parsed = JSON.parse(raw);
3492
3526
  if (typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" && "startedAt" in parsed && typeof parsed.startedAt === "string") {
3493
3527
  const p = parsed;
3528
+ const tunnelChildPid = typeof p.tunnelChildPid === "number" ? p.tunnelChildPid : null;
3494
3529
  return {
3495
3530
  pid: p.pid,
3496
3531
  wssUrl: typeof p.wssUrl === "string" ? p.wssUrl : null,
3497
- startedAt: p.startedAt
3532
+ startedAt: p.startedAt,
3533
+ tunnelChildPid
3498
3534
  };
3499
3535
  }
3500
3536
  return null;
@@ -3560,16 +3596,19 @@ function acquireLock(options = {}) {
3560
3596
  const { force = false } = options;
3561
3597
  const lockPath = lockFilePath();
3562
3598
  const existing = readLock(lockPath);
3563
- if (existing !== null) if (isPidAlive(existing.pid)) if (force) {
3564
- process.stderr.write(`[ait-debug] --force: terminating existing session PID=${existing.pid} …\n`);
3565
- killAndWait(existing.pid);
3566
- process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\n`);
3567
- } else {
3568
- const urlPart = existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : "wssUrl=(tunnel starting)";
3569
- process.stderr.write(`[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\n[ait-debug] 회복: \`kill ${existing.pid}\` 또는 \`npx @ait-co/devtools devtools-mcp --force\`\n`);
3570
- throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
3571
- }
3572
- else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered starting fresh.\n`);
3599
+ if (existing !== null) if (isPidAlive(existing.pid)) {
3600
+ const tunnelChildPid = existing.tunnelChildPid;
3601
+ if (typeof tunnelChildPid === "number" && !isPidAlive(tunnelChildPid)) process.stderr.write(`[ait-debug] stale lock: holder PID=${existing.pid} alive but tunnel child PID=${tunnelChildPid} is dead — reclaiming lock.\n`);
3602
+ else if (force) {
3603
+ process.stderr.write(`[ait-debug] --force: terminating existing session PID=${existing.pid} …\n`);
3604
+ killAndWait(existing.pid);
3605
+ process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\n`);
3606
+ } else {
3607
+ const urlPart = existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : "wssUrl=(tunnel starting)";
3608
+ process.stderr.write(`[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\n[ait-debug] 회복: \`kill ${existing.pid}\` 또는 \`npx @ait-co/devtools devtools-mcp --force\`\n`);
3609
+ throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
3610
+ }
3611
+ } else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
3573
3612
  const data = {
3574
3613
  pid: process.pid,
3575
3614
  wssUrl: null,
@@ -3583,6 +3622,11 @@ function acquireLock(options = {}) {
3583
3622
  data.wssUrl = wssUrl;
3584
3623
  writeLock(lockPath, data);
3585
3624
  },
3625
+ updateTunnelChildPid(pid) {
3626
+ if (released) return;
3627
+ data.tunnelChildPid = pid;
3628
+ writeLock(lockPath, data);
3629
+ },
3586
3630
  release() {
3587
3631
  if (released) return;
3588
3632
  released = true;
@@ -4800,7 +4844,7 @@ async function readMcpSdkVersion() {
4800
4844
  * some test environments that skip the build step).
4801
4845
  */
4802
4846
  function readDevtoolsVersion() {
4803
- return "0.1.89";
4847
+ return "0.1.91";
4804
4848
  }
4805
4849
  /**
4806
4850
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -4859,7 +4903,7 @@ function computeNextRecommendedAction(tunnel, pages, env, authRejects = null) {
4859
4903
  * - Lock file data contains only pid + startedAt + wssUrl — no secrets.
4860
4904
  */
4861
4905
  async function getDiagnostics(input) {
4862
- const { tunnel, connection, env, envReason, collector, readLock: readLockFn, recentErrorsLimit = 10, getMcpVersion = readMcpSdkVersion, checkParentAlive = () => isPidAlive(process.ppid) } = input;
4906
+ const { tunnel, connection, env, envReason, collector, readLock: readLockFn, recentErrorsLimit = 10, getMcpVersion = readMcpSdkVersion, checkParentAlive = () => isPidAlive(process.ppid), tunnelChildPid } = input;
4863
4907
  const [mcpVersion, devtoolsVersion] = await Promise.all([getMcpVersion(), Promise.resolve(readDevtoolsVersion())]);
4864
4908
  const lockData = readLockFn();
4865
4909
  const serverLockHolder = lockData ? {
@@ -4867,8 +4911,11 @@ async function getDiagnostics(input) {
4867
4911
  startedAt: lockData.startedAt,
4868
4912
  wssUrl: lockData.wssUrl
4869
4913
  } : null;
4914
+ const effectiveTunnelChildPid = tunnelChildPid ?? lockData?.tunnelChildPid ?? null;
4915
+ let effectiveUp = tunnel.up;
4916
+ if (tunnel.up && typeof effectiveTunnelChildPid === "number" && effectiveTunnelChildPid !== null && !isPidAlive(effectiveTunnelChildPid)) effectiveUp = false;
4870
4917
  const tunnelInfo = {
4871
- up: tunnel.up,
4918
+ up: effectiveUp,
4872
4919
  wssUrl: tunnel.wssUrl,
4873
4920
  pid: lockData?.pid ?? null,
4874
4921
  startedAt: lockData?.startedAt ?? null,
@@ -4956,6 +5003,11 @@ async function ensureCloudflaredBin() {
4956
5003
  /**
4957
5004
  * Opens a cloudflared quick tunnel to the local relay port and resolves once
4958
5005
  * the public URL is assigned.
5006
+ *
5007
+ * FIX 1 (issue #571): after URL resolution the returned `QuickTunnel` object
5008
+ * watches the cloudflared child process for unexpected exits and calls any
5009
+ * registered `onUnexpectedExit` callback so the health probe can immediately
5010
+ * trigger reissue instead of waiting for the next poll interval.
4959
5011
  */
4960
5012
  async function startQuickTunnel(localPort) {
4961
5013
  await ensureCloudflaredBin();
@@ -4982,10 +5034,20 @@ async function startQuickTunnel(localPort) {
4982
5034
  tunnel.once("error", onError);
4983
5035
  tunnel.once("exit", onExit);
4984
5036
  });
5037
+ let intentionalStop = false;
5038
+ let unexpectedExitCb = null;
5039
+ tunnel.once("exit", (code) => {
5040
+ if (!intentionalStop && unexpectedExitCb !== null) unexpectedExitCb(code);
5041
+ });
4985
5042
  return {
4986
5043
  url,
4987
5044
  wssUrl: url.replace(/^https/, "wss"),
4988
- stop: () => {
5045
+ childPid: tunnel.process?.pid,
5046
+ onUnexpectedExit(cb) {
5047
+ unexpectedExitCb = cb;
5048
+ },
5049
+ stop() {
5050
+ intentionalStop = true;
4989
5051
  tunnel.stop();
4990
5052
  }
4991
5053
  };
@@ -5107,6 +5169,10 @@ async function probeTunnel(httpsUrl, timeoutMs = 1e4) {
5107
5169
  * times). On success the caller is notified via `onReissue`; on permanent
5108
5170
  * failure via `onPermanentDrop`.
5109
5171
  *
5172
+ * FIX 1 (issue #571): the probe also subscribes to each tunnel's
5173
+ * `onUnexpectedExit` callback to detect child death *immediately* instead of
5174
+ * waiting for the next probe interval (which could be 60 s away).
5175
+ *
5110
5176
  * @returns `stop` — call during server shutdown to clear the probe interval.
5111
5177
  */
5112
5178
  function startTunnelHealthProbe(initialTunnel, localPort, options) {
@@ -5115,6 +5181,43 @@ function startTunnelHealthProbe(initialTunnel, localPort, options) {
5115
5181
  let consecutiveFailures = 0;
5116
5182
  let reissueAttempts = 0;
5117
5183
  let stopped = false;
5184
+ const doReissueOrDrop = async () => {
5185
+ if (stopped) return;
5186
+ reissueAttempts += 1;
5187
+ if (reissueAttempts > 3) return;
5188
+ log(`[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/3)\n`);
5189
+ try {
5190
+ const newTunnel = await spawnTunnel(localPort);
5191
+ try {
5192
+ currentTunnel.stop();
5193
+ } catch {}
5194
+ currentTunnel = newTunnel;
5195
+ consecutiveFailures = 0;
5196
+ armChildExitWatch(newTunnel);
5197
+ log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\n`);
5198
+ onReissue(newTunnel);
5199
+ } catch (err) {
5200
+ const message = err instanceof Error ? err.message : String(err);
5201
+ log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\n`);
5202
+ if (reissueAttempts >= 3) {
5203
+ clearInterval(handle);
5204
+ stopped = true;
5205
+ const droppedAt = (/* @__PURE__ */ new Date()).toISOString();
5206
+ log(`[ait-debug] tunnel permanently dropped after 3 reissue attempts — restart the debug server to continue (npx @ait-co/devtools devtools-mcp).
5207
+ `);
5208
+ onPermanentDrop(droppedAt);
5209
+ }
5210
+ }
5211
+ };
5212
+ const armChildExitWatch = (t) => {
5213
+ t.onUnexpectedExit((code) => {
5214
+ if (stopped) return;
5215
+ log(`[ait-debug] cloudflared child exited unexpectedly (code=${code}) — triggering immediate reissue\n`);
5216
+ consecutiveFailures = failuresBeforeReissue;
5217
+ doReissueOrDrop();
5218
+ });
5219
+ };
5220
+ armChildExitWatch(initialTunnel);
5118
5221
  const handle = setInterval(() => {
5119
5222
  (async () => {
5120
5223
  if (stopped) return;
@@ -5128,30 +5231,7 @@ function startTunnelHealthProbe(initialTunnel, localPort, options) {
5128
5231
  consecutiveFailures += 1;
5129
5232
  log(`[ait-debug] tunnel health probe: failure ${consecutiveFailures}/${failuresBeforeReissue} (url=${httpsUrl})\n`);
5130
5233
  if (consecutiveFailures < failuresBeforeReissue) return;
5131
- reissueAttempts += 1;
5132
- if (reissueAttempts > 3) return;
5133
- log(`[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/3)\n`);
5134
- try {
5135
- const newTunnel = await spawnTunnel(localPort);
5136
- try {
5137
- currentTunnel.stop();
5138
- } catch {}
5139
- currentTunnel = newTunnel;
5140
- consecutiveFailures = 0;
5141
- log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\n`);
5142
- onReissue(newTunnel);
5143
- } catch (err) {
5144
- const message = err instanceof Error ? err.message : String(err);
5145
- log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\n`);
5146
- if (reissueAttempts >= 3) {
5147
- clearInterval(handle);
5148
- stopped = true;
5149
- const droppedAt = (/* @__PURE__ */ new Date()).toISOString();
5150
- log(`[ait-debug] tunnel permanently dropped after 3 reissue attempts — restart the debug server to continue (npx @ait-co/devtools devtools-mcp).
5151
- `);
5152
- onPermanentDrop(droppedAt);
5153
- }
5154
- }
5234
+ await doReissueOrDrop();
5155
5235
  })();
5156
5236
  }, probeIntervalMs);
5157
5237
  return { stop() {
@@ -5301,15 +5381,16 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
5301
5381
  * naturally via `enableDomains`). The tier only controls visibility.
5302
5382
  */
5303
5383
  function createDebugServer(deps) {
5304
- const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 6e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret, onAttachUrlBuilt } = deps;
5384
+ const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 6e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret, onAttachUrlBuilt, getTunnelChildPid, readLock: readLockDep } = deps;
5305
5385
  const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);
5386
+ const readLockFn = readLockDep ?? readServerLock;
5306
5387
  const router = routerDep ?? makeSingleConnectionRouter(connection);
5307
5388
  const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
5308
5389
  const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
5309
5390
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
5310
5391
  const server = new Server({
5311
5392
  name: "ait-debug",
5312
- version: "0.1.89"
5393
+ version: "0.1.91"
5313
5394
  }, { capabilities: { tools: { listChanged: true } } });
5314
5395
  server.setRequestHandler(ListToolsRequestSchema, () => {
5315
5396
  const conn = router.active;
@@ -5374,8 +5455,9 @@ function createDebugServer(deps) {
5374
5455
  env,
5375
5456
  envReason,
5376
5457
  collector,
5377
- readLock: readServerLock,
5378
- recentErrorsLimit
5458
+ readLock: readLockFn,
5459
+ recentErrorsLimit,
5460
+ tunnelChildPid: getTunnelChildPid?.() ?? void 0
5379
5461
  }), name, env, conn.listTargets().length > 0);
5380
5462
  } catch (err) {
5381
5463
  return errorResult(err, name);
@@ -6068,12 +6150,14 @@ async function bootRelayFamily(options = {}) {
6068
6150
  tunnel = t;
6069
6151
  tunnelStatus = makeTunnelStatus(true, t.wssUrl);
6070
6152
  options.onWssUrl?.(t.wssUrl);
6153
+ if (t.childPid !== void 0) options.onTunnelChildPid?.(t.childPid);
6071
6154
  logInfo("tunnel.up", { totpEnabled });
6072
6155
  tunnelProbe = startTunnelHealthProbe(t, relay.port, {
6073
6156
  onReissue: (newTunnel) => {
6074
6157
  tunnel = newTunnel;
6075
6158
  tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
6076
6159
  options.onWssUrl?.(newTunnel.wssUrl);
6160
+ if (newTunnel.childPid !== void 0) options.onTunnelChildPid?.(newTunnel.childPid);
6077
6161
  printAttachBanner({
6078
6162
  wssUrl: newTunnel.wssUrl,
6079
6163
  totpEnabled
@@ -6406,6 +6490,7 @@ async function runDebugServer(options = {}) {
6406
6490
  const lockHandle = acquireLock({ force: options.force ?? false });
6407
6491
  const devtoolsOpener = new AutoDevtoolsOpener();
6408
6492
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
6493
+ let activeTunnelChildPid = null;
6409
6494
  const router = new DualConnectionRouter({
6410
6495
  bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot), await readRelayLocalUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
6411
6496
  relayPort: options.relayPort,
@@ -6414,6 +6499,10 @@ async function runDebugServer(options = {}) {
6414
6499
  lockHandle.updateWssUrl(wssUrl);
6415
6500
  qrServer?.notifyStateChange();
6416
6501
  },
6502
+ onTunnelChildPid: (pid) => {
6503
+ activeTunnelChildPid = pid;
6504
+ lockHandle.updateTunnelChildPid(pid);
6505
+ },
6417
6506
  onAuthReject: () => diagnosticsCollector.recordAuthReject()
6418
6507
  }),
6419
6508
  diagnosticsCollector,
@@ -6485,6 +6574,7 @@ async function runDebugServer(options = {}) {
6485
6574
  router,
6486
6575
  aitSource,
6487
6576
  getTunnelStatus: () => router.relayTunnelStatus(),
6577
+ getTunnelChildPid: () => activeTunnelChildPid,
6488
6578
  get qrHttpServer() {
6489
6579
  return qrServer;
6490
6580
  },
@@ -6498,10 +6588,12 @@ async function runDebugServer(options = {}) {
6498
6588
  const transport = new StdioServerTransport();
6499
6589
  let closed = false;
6500
6590
  let parentWatcher = null;
6591
+ let maxAgeWatchdog = null;
6501
6592
  const shutdown = () => {
6502
6593
  if (closed) return;
6503
6594
  closed = true;
6504
6595
  parentWatcher?.stop();
6596
+ maxAgeWatchdog?.stop();
6505
6597
  if (totpRefreshHandle) clearInterval(totpRefreshHandle);
6506
6598
  router.stopWatcher();
6507
6599
  for (const family of router.bootedFamilies()) family.stop();
@@ -6516,6 +6608,7 @@ async function runDebugServer(options = {}) {
6516
6608
  if (!closed) {
6517
6609
  closed = true;
6518
6610
  parentWatcher?.stop();
6611
+ maxAgeWatchdog?.stop();
6519
6612
  if (totpRefreshHandle) clearInterval(totpRefreshHandle);
6520
6613
  router.stopWatcher();
6521
6614
  for (const family of router.bootedFamilies()) family.stop();
@@ -6554,6 +6647,11 @@ async function runDebugServer(options = {}) {
6554
6647
  process.exit(0);
6555
6648
  });
6556
6649
  }
6650
+ if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
6651
+ process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
6652
+ shutdown();
6653
+ process.exit(0);
6654
+ }, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
6557
6655
  }
6558
6656
  /**
6559
6657
  * Serves the debug stack over stdio with the local browser as the default
@@ -6605,6 +6703,7 @@ async function runLocalDebugServer(options = {}) {
6605
6703
  };
6606
6704
  const devtoolsOpener = new AutoDevtoolsOpener();
6607
6705
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
6706
+ let activeTunnelChildPid = null;
6608
6707
  const router = new DualConnectionRouter({
6609
6708
  bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot), await readRelayLocalUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamilyForEntry() : bootRelayFamily({
6610
6709
  verifyAuth: buildRelayVerifyAuth(),
@@ -6612,6 +6711,10 @@ async function runLocalDebugServer(options = {}) {
6612
6711
  lockHandle.updateWssUrl(wssUrl);
6613
6712
  qrServer?.notifyStateChange();
6614
6713
  },
6714
+ onTunnelChildPid: (pid) => {
6715
+ activeTunnelChildPid = pid;
6716
+ lockHandle.updateTunnelChildPid(pid);
6717
+ },
6615
6718
  onAuthReject: () => diagnosticsCollector.recordAuthReject()
6616
6719
  }),
6617
6720
  diagnosticsCollector,
@@ -6682,6 +6785,7 @@ async function runLocalDebugServer(options = {}) {
6682
6785
  router,
6683
6786
  aitSource,
6684
6787
  getTunnelStatus: () => router.relayTunnelStatus(),
6788
+ getTunnelChildPid: () => activeTunnelChildPid,
6685
6789
  get qrHttpServer() {
6686
6790
  return qrServer;
6687
6791
  },
@@ -6695,10 +6799,12 @@ async function runLocalDebugServer(options = {}) {
6695
6799
  const transport = new StdioServerTransport();
6696
6800
  let closed = false;
6697
6801
  let parentWatcher = null;
6802
+ let maxAgeWatchdog = null;
6698
6803
  const shutdown = () => {
6699
6804
  if (closed) return;
6700
6805
  closed = true;
6701
6806
  parentWatcher?.stop();
6807
+ maxAgeWatchdog?.stop();
6702
6808
  if (totpRefreshHandle) clearInterval(totpRefreshHandle);
6703
6809
  router.stopWatcher();
6704
6810
  for (const family of router.bootedFamilies()) family.stop();
@@ -6713,6 +6819,7 @@ async function runLocalDebugServer(options = {}) {
6713
6819
  if (!closed) {
6714
6820
  closed = true;
6715
6821
  parentWatcher?.stop();
6822
+ maxAgeWatchdog?.stop();
6716
6823
  if (totpRefreshHandle) clearInterval(totpRefreshHandle);
6717
6824
  router.stopWatcher();
6718
6825
  for (const family of router.bootedFamilies()) family.stop();
@@ -6753,6 +6860,11 @@ async function runLocalDebugServer(options = {}) {
6753
6860
  process.exit(0);
6754
6861
  });
6755
6862
  }
6863
+ if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
6864
+ process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
6865
+ shutdown();
6866
+ process.exit(0);
6867
+ }, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
6756
6868
  }
6757
6869
  /**
6758
6870
  * Serves the env-2 (real-device PWA) debug stack over stdio with the external
@@ -6786,6 +6898,7 @@ async function runMobileDebugServer(options = {}) {
6786
6898
  const lockHandle = acquireLock({ force: options.force ?? false });
6787
6899
  const devtoolsOpener = new AutoDevtoolsOpener();
6788
6900
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
6901
+ let activeTunnelChildPid = null;
6789
6902
  const router = new DualConnectionRouter({
6790
6903
  bootLazyFor: async (key) => key === "relay-sandbox" ? bootExternalRelayFamily(relayBaseUrl, await readRelayLocalUrl(process.env, options.projectRoot ?? process.cwd())) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
6791
6904
  verifyAuth: buildRelayVerifyAuth(),
@@ -6793,6 +6906,10 @@ async function runMobileDebugServer(options = {}) {
6793
6906
  lockHandle.updateWssUrl(wssUrl);
6794
6907
  qrServer?.notifyStateChange();
6795
6908
  },
6909
+ onTunnelChildPid: (pid) => {
6910
+ activeTunnelChildPid = pid;
6911
+ lockHandle.updateTunnelChildPid(pid);
6912
+ },
6796
6913
  onAuthReject: () => diagnosticsCollector.recordAuthReject()
6797
6914
  }),
6798
6915
  diagnosticsCollector,
@@ -6863,6 +6980,7 @@ async function runMobileDebugServer(options = {}) {
6863
6980
  router,
6864
6981
  aitSource,
6865
6982
  getTunnelStatus: () => router.relayTunnelStatus(),
6983
+ getTunnelChildPid: () => activeTunnelChildPid,
6866
6984
  get qrHttpServer() {
6867
6985
  return qrServer;
6868
6986
  },
@@ -6876,10 +6994,12 @@ async function runMobileDebugServer(options = {}) {
6876
6994
  const transport = new StdioServerTransport();
6877
6995
  let closed = false;
6878
6996
  let parentWatcher = null;
6997
+ let maxAgeWatchdog = null;
6879
6998
  const shutdown = () => {
6880
6999
  if (closed) return;
6881
7000
  closed = true;
6882
7001
  parentWatcher?.stop();
7002
+ maxAgeWatchdog?.stop();
6883
7003
  if (totpRefreshHandle) clearInterval(totpRefreshHandle);
6884
7004
  router.stopWatcher();
6885
7005
  for (const family of router.bootedFamilies()) family.stop();
@@ -6894,6 +7014,7 @@ async function runMobileDebugServer(options = {}) {
6894
7014
  if (!closed) {
6895
7015
  closed = true;
6896
7016
  parentWatcher?.stop();
7017
+ maxAgeWatchdog?.stop();
6897
7018
  if (totpRefreshHandle) clearInterval(totpRefreshHandle);
6898
7019
  router.stopWatcher();
6899
7020
  for (const family of router.bootedFamilies()) family.stop();
@@ -6934,6 +7055,11 @@ async function runMobileDebugServer(options = {}) {
6934
7055
  process.exit(0);
6935
7056
  });
6936
7057
  }
7058
+ if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
7059
+ process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
7060
+ shutdown();
7061
+ process.exit(0);
7062
+ }, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
6937
7063
  }
6938
7064
  //#endregion
6939
7065
  //#region src/mcp/ait-http-source.ts
@@ -7363,7 +7489,7 @@ function createDevServer(deps = {}) {
7363
7489
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
7364
7490
  const server = new Server({
7365
7491
  name: "ait-devtools",
7366
- version: "0.1.89"
7492
+ version: "0.1.91"
7367
7493
  }, { capabilities: { tools: {} } });
7368
7494
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
7369
7495
  server.setRequestHandler(CallToolRequestSchema, async (request) => {