@raysonmeng/agentbridge 0.1.6 → 0.1.7

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.
@@ -6518,7 +6518,7 @@ var require_dist = __commonJS((exports, module) => {
6518
6518
  });
6519
6519
 
6520
6520
  // src/bridge.ts
6521
- import { appendFileSync as appendFileSync2 } from "fs";
6521
+ import { existsSync as existsSync6 } from "fs";
6522
6522
 
6523
6523
  // node_modules/zod/v4/core/core.js
6524
6524
  var NEVER = Object.freeze({
@@ -13662,28 +13662,142 @@ class StdioServerTransport {
13662
13662
  // src/claude-adapter.ts
13663
13663
  import { EventEmitter } from "events";
13664
13664
  import { randomUUID } from "crypto";
13665
- import { appendFileSync } from "fs";
13665
+
13666
+ // src/rotating-log.ts
13667
+ import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "fs";
13668
+ import { dirname } from "path";
13669
+ var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
13670
+ var DEFAULT_KEEP = 3;
13671
+ function appendRotatingLog(path, content, options = {}) {
13672
+ const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
13673
+ const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
13674
+ if (!existsSync(dirname(path)))
13675
+ return;
13676
+ rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
13677
+ appendFileSync(path, content, "utf-8");
13678
+ }
13679
+ function positiveIntFromEnv(name, fallback) {
13680
+ const value = process.env[name];
13681
+ if (!value)
13682
+ return fallback;
13683
+ const parsed = Number(value);
13684
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
13685
+ }
13686
+ function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
13687
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
13688
+ return;
13689
+ if (!existsSync(path))
13690
+ return;
13691
+ const size = statSync(path).size;
13692
+ if (size + incomingBytes <= maxBytes)
13693
+ return;
13694
+ for (let index = keep;index >= 1; index--) {
13695
+ const current = `${path}.${index}`;
13696
+ const next = `${path}.${index + 1}`;
13697
+ if (!existsSync(current))
13698
+ continue;
13699
+ if (index === keep) {
13700
+ unlinkSync(current);
13701
+ } else {
13702
+ renameSync(current, next);
13703
+ }
13704
+ }
13705
+ renameSync(path, `${path}.1`);
13706
+ }
13707
+
13708
+ // src/process-log.ts
13709
+ var stderrStates = new WeakMap;
13710
+ function createProcessLogger(options) {
13711
+ let fatalInProgress = false;
13712
+ const stderr = options.stderr ?? process.stderr;
13713
+ const stderrState = stateForStderr(stderr);
13714
+ const write = (message) => {
13715
+ const line = `[${new Date().toISOString()}] [${options.component}] ${message}
13716
+ `;
13717
+ if (options.logFile) {
13718
+ try {
13719
+ appendRotatingLog(options.logFile, line);
13720
+ } catch {}
13721
+ }
13722
+ if (!stderrState.enabled)
13723
+ return;
13724
+ try {
13725
+ stderr.write(line);
13726
+ } catch (error2) {
13727
+ if (error2?.code === "EPIPE")
13728
+ stderrState.enabled = false;
13729
+ }
13730
+ };
13731
+ return {
13732
+ log: write,
13733
+ fatal(label, error2) {
13734
+ if (fatalInProgress)
13735
+ return;
13736
+ fatalInProgress = true;
13737
+ try {
13738
+ write(`${label}: ${safeFormatError(error2)}`);
13739
+ } finally {
13740
+ fatalInProgress = false;
13741
+ }
13742
+ }
13743
+ };
13744
+ }
13745
+ function stateForStderr(stderr) {
13746
+ const key = stderr;
13747
+ let state = stderrStates.get(key);
13748
+ if (state)
13749
+ return state;
13750
+ state = { enabled: true };
13751
+ stderrStates.set(key, state);
13752
+ if (typeof stderr.on === "function") {
13753
+ stderr.on("error", (error2) => {
13754
+ if (error2?.code === "EPIPE") {
13755
+ state.enabled = false;
13756
+ return;
13757
+ }
13758
+ setTimeout(() => {
13759
+ throw error2;
13760
+ }, 0);
13761
+ });
13762
+ }
13763
+ return state;
13764
+ }
13765
+ function safeFormatError(error2) {
13766
+ try {
13767
+ return formatError2(error2);
13768
+ } catch {
13769
+ return "<failed to format error>";
13770
+ }
13771
+ }
13772
+ function formatError2(error2) {
13773
+ if (error2 instanceof Error)
13774
+ return error2.stack ?? error2.message;
13775
+ if (typeof error2 === "object" && error2 !== null && "stack" in error2) {
13776
+ return String(error2.stack);
13777
+ }
13778
+ return String(error2);
13779
+ }
13666
13780
 
13667
13781
  // src/state-dir.ts
13668
- import { mkdirSync, existsSync } from "fs";
13782
+ import { mkdirSync, existsSync as existsSync2 } from "fs";
13669
13783
  import { join } from "path";
13670
13784
  import { homedir, platform } from "os";
13671
13785
 
13672
13786
  class StateDirResolver {
13673
13787
  stateDir;
13788
+ static platformBaseDir() {
13789
+ if (platform() === "darwin") {
13790
+ return join(homedir(), "Library", "Application Support", "AgentBridge");
13791
+ }
13792
+ const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
13793
+ return join(xdgState, "agentbridge");
13794
+ }
13674
13795
  constructor(envOverride) {
13675
13796
  const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
13676
- if (override) {
13677
- this.stateDir = override;
13678
- } else if (platform() === "darwin") {
13679
- this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
13680
- } else {
13681
- const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
13682
- this.stateDir = join(xdgState, "agentbridge");
13683
- }
13797
+ this.stateDir = override && override.length > 0 ? override : StateDirResolver.platformBaseDir();
13684
13798
  }
13685
13799
  ensure() {
13686
- if (!existsSync(this.stateDir)) {
13800
+ if (!existsSync2(this.stateDir)) {
13687
13801
  mkdirSync(this.stateDir, { recursive: true });
13688
13802
  }
13689
13803
  }
@@ -13705,6 +13819,9 @@ class StateDirResolver {
13705
13819
  get portsFile() {
13706
13820
  return join(this.stateDir, "ports.json");
13707
13821
  }
13822
+ get currentThreadFile() {
13823
+ return join(this.stateDir, "current-thread.json");
13824
+ }
13708
13825
  get logFile() {
13709
13826
  return join(this.stateDir, "agentbridge.log");
13710
13827
  }
@@ -13714,16 +13831,98 @@ class StateDirResolver {
13714
13831
  get killedFile() {
13715
13832
  return join(this.stateDir, "killed");
13716
13833
  }
13834
+ get updateCheckFile() {
13835
+ return join(this.stateDir, "update-check.json");
13836
+ }
13837
+ }
13838
+
13839
+ // src/budget/render.ts
13840
+ function formatEpoch(epochSeconds) {
13841
+ if (!epochSeconds || epochSeconds <= 0)
13842
+ return "\u672A\u77E5";
13843
+ return new Date(epochSeconds * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
13844
+ }
13845
+ function formatWindow(window, label) {
13846
+ if (!window)
13847
+ return `${label} \u672A\u77E5`;
13848
+ return `${label} ${window.util}%\uFF08\u91CD\u7F6E ${formatEpoch(window.resetEpoch)}\uFF09`;
13849
+ }
13850
+ function formatAgent(name, usage, snapshotAt) {
13851
+ if (!usage)
13852
+ return `${name}\uFF1A\u672A\u77E5\uFF08\u63A2\u6D4B\u4E0D\u53EF\u7528\uFF09`;
13853
+ const parts = [
13854
+ formatWindow(usage.fiveHour, "5h"),
13855
+ formatWindow(usage.weekly, "\u5468"),
13856
+ `\u95E8\u63A7 ${usage.gateUtil}%`,
13857
+ `\u9884\u8B66 ${usage.warnUtil}%`
13858
+ ];
13859
+ if (usage.rateLimitedUntil > 0) {
13860
+ parts.push(`\u9650\u6D41\u81F3 ${formatEpoch(usage.rateLimitedUntil)}`);
13861
+ }
13862
+ const ageSec = usage.fetchedAt > 0 ? snapshotAt - usage.fetchedAt : 0;
13863
+ if (ageSec > 300) {
13864
+ parts.push(`\u26A0\uFE0F \u6570\u636E\u91C7\u96C6\u4E8E ${Math.round(ageSec / 60)} \u5206\u949F\u524D`);
13865
+ } else if (usage.stale) {
13866
+ parts.push("\uFF08\u7F13\u5B58\u6570\u636E\uFF09");
13867
+ }
13868
+ return `${name}\uFF1A${parts.join(" \xB7 ")}`;
13869
+ }
13870
+ var PHASE_LABELS = {
13871
+ normal: "normal\uFF08\u6B63\u5E38\uFF09",
13872
+ balance: "balance\uFF08\u9700\u5747\u8861\uFF09",
13873
+ parallel: "parallel\uFF08\u5EFA\u8BAE\u5E76\u884C\u63D0\u901F\uFF09",
13874
+ paused: "paused\uFF08\u9884\u7B97\u5E72\u9884\u4E2D\uFF09"
13875
+ };
13876
+ function renderBudgetSnapshot(snapshot) {
13877
+ const lines = [];
13878
+ lines.push(`\u3010\u9884\u7B97\u5FEB\u7167 \xB7 \u8D26\u53F7\u7EA7\u3011\u9636\u6BB5\uFF1A${PHASE_LABELS[snapshot.phase]} \xB7 \u66F4\u65B0\u4E8E ${formatEpoch(snapshot.updatedAt)}`);
13879
+ lines.push(formatAgent("Claude", snapshot.claude, snapshot.updatedAt));
13880
+ lines.push(formatAgent("Codex", snapshot.codex, snapshot.updatedAt));
13881
+ if (snapshot.claude && snapshot.codex) {
13882
+ const abs = Math.abs(snapshot.driftPct);
13883
+ if (abs > 0) {
13884
+ const heavier = snapshot.driftPct > 0 ? "Claude" : "Codex";
13885
+ const lighter = snapshot.driftPct > 0 ? "Codex" : "Claude";
13886
+ lines.push(`\u6F02\u79FB\uFF1A${heavier} \u6BD4 ${lighter} \u9AD8 ${abs} \u4E2A\u767E\u5206\u70B9`);
13887
+ } else {
13888
+ lines.push("\u6F02\u79FB\uFF1A\u53CC\u65B9\u6301\u5E73");
13889
+ }
13890
+ }
13891
+ if (snapshot.paused) {
13892
+ const resume = snapshot.resumeAfterEpoch ? `\uFF1B\u9884\u8BA1\u6062\u590D ${formatEpoch(snapshot.resumeAfterEpoch)}\uFF08\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09` : "";
13893
+ const reason = snapshot.pauseReason ?? "\u989D\u5EA6\u63A5\u8FD1\u8017\u5C3D";
13894
+ if (snapshot.pauseSide === "claude" && !snapshot.gateClosed) {
13895
+ lines.push(`\u63A5\u529B\u4E2D\uFF1AClaude \u4FA7\u989D\u5EA6\u8017\u5C3D\uFF0C\u5DF2\u4EA4\u63A5 Codex \u7EE7\u7EED\u63A8\u8FDB\uFF08\u95F8\u95E8\u5F00\u653E\uFF09 \u2014 ${reason}${resume}`);
13896
+ } else if (snapshot.pauseSide === "codex") {
13897
+ lines.push(`\u6682\u505C\uFF1ACodex \u4FA7\u989D\u5EA6\u8017\u5C3D\uFF08\u95F8\u95E8\u5173\u95ED\uFF0CClaude \u53EF solo \u63A8\u8FDB\u72EC\u7ACB\u90E8\u5206\uFF09 \u2014 ${reason}${resume}`);
13898
+ } else {
13899
+ lines.push(`\u6682\u505C\uFF1A\u53CC\u4FA7\u8054\u5408\u6682\u505C\uFF08\u95F8\u95E8\u5173\u95ED\uFF09 \u2014 ${reason}${resume}`);
13900
+ }
13901
+ } else {
13902
+ lines.push("\u6682\u505C\uFF1A\u5426");
13903
+ }
13904
+ if (snapshot.parallelRecommended) {
13905
+ lines.push("\u5E76\u884C\u5EFA\u8BAE\uFF1A\u989D\u5EA6\u5BCC\u4F59\u4E14\u4E34\u8FD1\u7ED3\u7B97\uFF0C\u5EFA\u8BAE\u62C6\u5206\u66F4\u591A\u5E76\u884C\u5B50\u4EFB\u52A1");
13906
+ }
13907
+ if (snapshot.codexTier !== "full") {
13908
+ lines.push(`Codex \u6863\u4F4D\uFF1A${snapshot.codexTier}`);
13909
+ }
13910
+ if (snapshot.claudeAdvice) {
13911
+ lines.push(`Claude \u5EFA\u8BAE\uFF1A${snapshot.claudeAdvice}`);
13912
+ }
13913
+ lines.push("\u6CE8\uFF1A\u767E\u5206\u6BD4\u4E3A\u8BA2\u9605\u8D26\u53F7\u7EA7\u7528\u91CF\uFF08\u540C\u673A\u5176\u4ED6\u4F1A\u8BDD\u5171\u4EAB\u540C\u4E00\u989D\u5EA6\u6C60\uFF09\u3002");
13914
+ return lines.join(`
13915
+ `);
13717
13916
  }
13917
+ var BUDGET_UNAVAILABLE_TEXT = "\u9884\u7B97\u611F\u77E5\u4E0D\u53EF\u7528\uFF1A\u672A\u68C0\u6D4B\u5230 agent-quota-guard \u63A2\u9488\uFF08~/.budget-guard/bin/budget-probe\uFF09\u6216 budget \u529F\u80FD\u5DF2\u7981\u7528\u3002\u534F\u4F5C\u4E0D\u53D7\u5F71\u54CD\u3002";
13718
13918
 
13719
13919
  // src/claude-adapter.ts
13720
13920
  var CLAUDE_INSTRUCTIONS = [
13721
13921
  "Codex is an AI coding agent (OpenAI) running in a separate session on the same machine.",
13722
13922
  "",
13723
13923
  "## Message delivery",
13724
- "Messages from Codex may arrive in two ways depending on the connection mode:",
13725
- '- As <channel source="agentbridge" chat_id="..." user="Codex" ...> tags (push mode)',
13726
- "- Via the get_messages tool (pull mode)",
13924
+ 'Messages from Codex arrive as <channel source="agentbridge" chat_id="..." user="Codex" ...> tags (push).',
13925
+ "If a push fails, the message is queued \u2014 call get_messages to drain the fallback queue.",
13727
13926
  "",
13728
13927
  "## Collaboration roles",
13729
13928
  "Default roles in this setup:",
@@ -13748,7 +13947,11 @@ var CLAUDE_INSTRUCTIONS = [
13748
13947
  "## Turn coordination",
13749
13948
  "- When you see '\u23F3 Codex is working', do NOT call the reply tool \u2014 wait for '\u2705 Codex finished'.",
13750
13949
  "- After Codex finishes a turn, you have an attention window to review and respond before new messages arrive.",
13751
- "- If the reply tool returns a busy error, Codex is still executing \u2014 wait and try again later."
13950
+ "- If the reply tool returns a busy error, Codex is still executing \u2014 wait and try again later.",
13951
+ "",
13952
+ "## Budget awareness",
13953
+ "- Use the get_budget tool to check both agents' subscription quota (5h/weekly windows, drift, pause state).",
13954
+ "- If the reply tool returns a budget-pause error, do NOT retry; checkpoint your work and wait for the resume notice."
13752
13955
  ].join(`
13753
13956
  `);
13754
13957
 
@@ -13760,20 +13963,22 @@ class ClaudeAdapter extends EventEmitter {
13760
13963
  instanceId;
13761
13964
  replySender = null;
13762
13965
  logFile;
13763
- configuredMode;
13764
- resolvedMode = null;
13966
+ logger;
13765
13967
  pendingMessages = [];
13766
13968
  maxBufferedMessages;
13767
13969
  droppedMessageCount = 0;
13970
+ budgetSnapshot = null;
13768
13971
  constructor(logFile = new StateDirResolver().logFile) {
13769
13972
  super();
13770
13973
  this.logFile = logFile;
13974
+ this.logger = createProcessLogger({ component: "ClaudeAdapter", logFile: this.logFile });
13771
13975
  this.instanceId = randomUUID().slice(0, 8);
13772
13976
  this.sessionId = `codex_${Date.now()}`;
13773
13977
  this.notificationIdPrefix = randomUUID().replace(/-/g, "").slice(0, 12);
13774
13978
  this.log(`ClaudeAdapter created (instance=${this.instanceId})`);
13775
- const envMode = process.env.AGENTBRIDGE_MODE;
13776
- this.configuredMode = envMode && ["push", "pull", "auto"].includes(envMode) ? envMode : "auto";
13979
+ if (process.env.AGENTBRIDGE_MODE) {
13980
+ this.log(`AGENTBRIDGE_MODE="${process.env.AGENTBRIDGE_MODE}" is no longer supported \u2014 ` + "pull mode was removed; push delivery (with per-message fallback queue) is always used.");
13981
+ }
13777
13982
  this.maxBufferedMessages = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
13778
13983
  this.server = new Server({ name: "agentbridge", version: "0.1.0" }, {
13779
13984
  capabilities: {
@@ -13786,38 +13991,22 @@ class ClaudeAdapter extends EventEmitter {
13786
13991
  }
13787
13992
  async start() {
13788
13993
  const transport = new StdioServerTransport;
13789
- this.resolveMode();
13790
13994
  await this.server.connect(transport);
13791
- this.log(`MCP server connected (mode: ${this.resolvedMode})`);
13995
+ this.log("MCP server connected (push delivery)");
13792
13996
  this.emit("ready");
13793
13997
  }
13794
13998
  setReplySender(sender) {
13795
13999
  this.replySender = sender;
13796
14000
  }
13797
- getDeliveryMode() {
13798
- return this.resolvedMode ?? "pull";
13799
- }
13800
14001
  getPendingMessageCount() {
13801
14002
  return this.pendingMessages.length;
13802
14003
  }
13803
- resolveMode() {
13804
- if (this.resolvedMode)
13805
- return;
13806
- if (this.configuredMode === "push" || this.configuredMode === "pull") {
13807
- this.resolvedMode = this.configuredMode;
13808
- this.log(`Delivery mode set by AGENTBRIDGE_MODE: ${this.resolvedMode}`);
13809
- } else {
13810
- this.resolvedMode = "push";
13811
- this.log("Delivery mode defaulting to push (set AGENTBRIDGE_MODE=pull to use polling instead)");
13812
- }
14004
+ setBudgetSnapshot(snapshot) {
14005
+ this.budgetSnapshot = snapshot;
13813
14006
  }
13814
14007
  async pushNotification(message) {
13815
- this.log(`pushNotification (instance=${this.instanceId}, mode=${this.resolvedMode}, msgId=${message.id}, len=${message.content.length})`);
13816
- if (this.resolvedMode === "push") {
13817
- await this.pushViaChannel(message);
13818
- } else {
13819
- this.queueForPull(message);
13820
- }
14008
+ this.log(`pushNotification (instance=${this.instanceId}, msgId=${message.id}, len=${message.content.length})`);
14009
+ await this.pushViaChannel(message);
13821
14010
  }
13822
14011
  async pushViaChannel(message) {
13823
14012
  const msgId = `codex_msg_${this.notificationIdPrefix}_${++this.notificationSeq}`;
@@ -13840,17 +14029,17 @@ class ClaudeAdapter extends EventEmitter {
13840
14029
  this.log(`Pushed notification: ${msgId}`);
13841
14030
  } catch (e) {
13842
14031
  this.log(`Push notification failed: ${e.message}`);
13843
- this.queueForPull(message);
14032
+ this.queueFallbackMessage(message);
13844
14033
  }
13845
14034
  }
13846
- queueForPull(message) {
14035
+ queueFallbackMessage(message) {
13847
14036
  if (this.pendingMessages.length >= this.maxBufferedMessages) {
13848
14037
  this.pendingMessages.shift();
13849
14038
  this.droppedMessageCount++;
13850
- this.log(`Message queue full, dropped oldest message (total dropped: ${this.droppedMessageCount})`);
14039
+ this.log(`Fallback queue full, dropped oldest message (total dropped: ${this.droppedMessageCount})`);
13851
14040
  }
13852
14041
  this.pendingMessages.push(message);
13853
- this.log(`Queued message for pull (${this.pendingMessages.length} pending, instance=${this.instanceId})`);
14042
+ this.log(`Queued fallback message (${this.pendingMessages.length} pending, instance=${this.instanceId})`);
13854
14043
  }
13855
14044
  drainMessages() {
13856
14045
  this.log(`get_messages called (instance=${this.instanceId}, pending=${this.pendingMessages.length}, dropped=${this.droppedMessageCount})`);
@@ -13923,6 +14112,15 @@ ${formatted}`
13923
14112
  properties: {},
13924
14113
  required: []
13925
14114
  }
14115
+ },
14116
+ {
14117
+ name: "get_budget",
14118
+ description: "Check both agents' subscription quota usage (Claude + Codex): 5h/weekly window percentages, drift between the two sides, joint-pause state and model/effort tier recommendation.",
14119
+ inputSchema: {
14120
+ type: "object",
14121
+ properties: {},
14122
+ required: []
14123
+ }
13926
14124
  }
13927
14125
  ]
13928
14126
  }));
@@ -13934,12 +14132,22 @@ ${formatted}`
13934
14132
  if (name === "get_messages") {
13935
14133
  return this.drainMessages();
13936
14134
  }
14135
+ if (name === "get_budget") {
14136
+ return this.handleGetBudget();
14137
+ }
13937
14138
  return {
13938
14139
  content: [{ type: "text", text: `Unknown tool: ${name}` }],
13939
14140
  isError: true
13940
14141
  };
13941
14142
  });
13942
14143
  }
14144
+ handleGetBudget() {
14145
+ this.log(`get_budget called (instance=${this.instanceId}, hasSnapshot=${this.budgetSnapshot !== null})`);
14146
+ const text = this.budgetSnapshot ? renderBudgetSnapshot(this.budgetSnapshot) : BUDGET_UNAVAILABLE_TEXT;
14147
+ return {
14148
+ content: [{ type: "text", text }]
14149
+ };
14150
+ }
13943
14151
  async handleReply(args) {
13944
14152
  const text = args?.text;
13945
14153
  if (!text) {
@@ -13980,33 +14188,70 @@ ${formatted}`
13980
14188
  };
13981
14189
  }
13982
14190
  log(msg) {
13983
- const line = `[${new Date().toISOString()}] [ClaudeAdapter] ${msg}
13984
- `;
13985
- process.stderr.write(line);
13986
- try {
13987
- appendFileSync(this.logFile, line);
13988
- } catch {}
14191
+ this.logger.log(msg);
13989
14192
  }
13990
14193
  }
13991
14194
 
14195
+ // src/contract-version.ts
14196
+ var CONTRACT_VERSION = 1;
14197
+
14198
+ // src/build-info.ts
14199
+ function defineString(value, fallback) {
14200
+ return typeof value === "string" && value.length > 0 ? value : fallback;
14201
+ }
14202
+ function defineBundle(value) {
14203
+ if (value === "source" || value === "dist" || value === "plugin")
14204
+ return value;
14205
+ return import.meta.url.endsWith(".ts") ? "source" : "dist";
14206
+ }
14207
+ function defineNumber(value, fallback) {
14208
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
14209
+ }
14210
+ var BUILD_INFO = Object.freeze({
14211
+ version: defineString("0.1.7", "0.0.0-source"),
14212
+ commit: defineString("1df8b91", "source"),
14213
+ bundle: defineBundle("plugin"),
14214
+ contractVersion: defineNumber(1, CONTRACT_VERSION)
14215
+ });
14216
+ function sameRuntimeContract(a, b) {
14217
+ if (!a || !b)
14218
+ return false;
14219
+ return a.version === b.version && a.commit === b.commit && a.contractVersion === b.contractVersion;
14220
+ }
14221
+ function compatibleContractVersion(a, b) {
14222
+ if (!a || !b)
14223
+ return false;
14224
+ return a.contractVersion === b.contractVersion;
14225
+ }
14226
+ function formatBuildInfo(build) {
14227
+ if (!build)
14228
+ return "<unknown>";
14229
+ return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}`;
14230
+ }
14231
+
13992
14232
  // src/daemon-client.ts
13993
14233
  import { EventEmitter as EventEmitter2 } from "events";
13994
14234
 
13995
14235
  // src/control-protocol.ts
13996
14236
  var CLOSE_CODE_REPLACED = 4001;
14237
+ var CLOSE_CODE_EVICTED_STALE = 4002;
14238
+ var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
14239
+ var CLOSE_CODE_PAIR_MISMATCH = 4004;
13997
14240
 
13998
14241
  // src/daemon-client.ts
13999
14242
  var nextSocketId = 0;
14000
14243
 
14001
14244
  class DaemonClient extends EventEmitter2 {
14002
14245
  url;
14246
+ options;
14003
14247
  ws = null;
14004
14248
  wsId = 0;
14005
14249
  nextRequestId = 1;
14006
14250
  pendingReplies = new Map;
14007
- constructor(url) {
14251
+ constructor(url, options = {}) {
14008
14252
  super();
14009
14253
  this.url = url;
14254
+ this.options = options;
14010
14255
  }
14011
14256
  async connect() {
14012
14257
  if (this.ws?.readyState === WebSocket.OPEN) {
@@ -14048,7 +14293,81 @@ class DaemonClient extends EventEmitter2 {
14048
14293
  });
14049
14294
  }
14050
14295
  attachClaude() {
14051
- this.send({ type: "claude_connect" });
14296
+ this.send({
14297
+ type: "claude_connect",
14298
+ ...this.options.identity ? { identity: this.options.identity } : {}
14299
+ });
14300
+ }
14301
+ async attachClaudeAndWaitForStatus(timeoutMs = 1000) {
14302
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14303
+ return null;
14304
+ }
14305
+ return await new Promise((resolve) => {
14306
+ let settled = false;
14307
+ let timer = null;
14308
+ const cleanup = () => {
14309
+ if (settled)
14310
+ return;
14311
+ settled = true;
14312
+ if (timer) {
14313
+ clearTimeout(timer);
14314
+ timer = null;
14315
+ }
14316
+ this.off("status", onStatus);
14317
+ this.off("rejected", onRejected);
14318
+ this.off("disconnect", onDisconnect);
14319
+ };
14320
+ const finish = (value) => {
14321
+ cleanup();
14322
+ resolve(value);
14323
+ };
14324
+ const onStatus = (status) => finish(status);
14325
+ const onRejected = () => finish(null);
14326
+ const onDisconnect = () => finish(null);
14327
+ this.on("status", onStatus);
14328
+ this.on("rejected", onRejected);
14329
+ this.on("disconnect", onDisconnect);
14330
+ timer = setTimeout(() => {
14331
+ finish(null);
14332
+ }, timeoutMs);
14333
+ try {
14334
+ this.attachClaude();
14335
+ } catch {
14336
+ finish(null);
14337
+ }
14338
+ });
14339
+ }
14340
+ async probeIncumbent(timeoutMs = 3000) {
14341
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14342
+ return { connected: false, alive: false };
14343
+ }
14344
+ return await new Promise((resolve) => {
14345
+ let settled = false;
14346
+ let timer = null;
14347
+ const finish = (value) => {
14348
+ if (settled)
14349
+ return;
14350
+ settled = true;
14351
+ if (timer)
14352
+ clearTimeout(timer);
14353
+ this.off("incumbentStatus", onStatus);
14354
+ this.off("disconnect", onDisconnect);
14355
+ this.off("rejected", onRejected);
14356
+ resolve(value);
14357
+ };
14358
+ const onStatus = (s) => finish(s);
14359
+ const onDisconnect = () => finish({ connected: false, alive: false });
14360
+ const onRejected = () => finish({ connected: false, alive: false });
14361
+ this.on("incumbentStatus", onStatus);
14362
+ this.on("disconnect", onDisconnect);
14363
+ this.on("rejected", onRejected);
14364
+ timer = setTimeout(() => finish({ connected: false, alive: false }), timeoutMs);
14365
+ try {
14366
+ this.send({ type: "probe_incumbent" });
14367
+ } catch {
14368
+ finish({ connected: false, alive: false });
14369
+ }
14370
+ });
14052
14371
  }
14053
14372
  async disconnect() {
14054
14373
  if (!this.ws)
@@ -14106,6 +14425,9 @@ class DaemonClient extends EventEmitter2 {
14106
14425
  case "status":
14107
14426
  this.emit("status", message.status);
14108
14427
  return;
14428
+ case "incumbent_status":
14429
+ this.emit("incumbentStatus", { connected: message.connected, alive: message.alive });
14430
+ return;
14109
14431
  }
14110
14432
  };
14111
14433
  ws.onclose = (event) => {
@@ -14114,8 +14436,8 @@ class DaemonClient extends EventEmitter2 {
14114
14436
  if (isCurrent) {
14115
14437
  this.ws = null;
14116
14438
  this.rejectPendingReplies("AgentBridge daemon disconnected.");
14117
- if (event.code === CLOSE_CODE_REPLACED) {
14118
- this.emit("rejected");
14439
+ if (event.code === CLOSE_CODE_REPLACED || event.code === CLOSE_CODE_EVICTED_STALE || event.code === CLOSE_CODE_PROBE_IN_PROGRESS || event.code === CLOSE_CODE_PAIR_MISMATCH) {
14440
+ this.emit("rejected", event.code);
14119
14441
  } else {
14120
14442
  this.emit("disconnect");
14121
14443
  }
@@ -14144,10 +14466,30 @@ class DaemonClient extends EventEmitter2 {
14144
14466
 
14145
14467
  // src/daemon-lifecycle.ts
14146
14468
  import { spawn, execFileSync } from "child_process";
14147
- import { existsSync as existsSync2, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
14469
+ import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
14148
14470
  import { fileURLToPath } from "url";
14149
- var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY ?? "./daemon.ts";
14471
+
14472
+ // src/env-utils.ts
14473
+ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
14474
+ const raw = env[name];
14475
+ if (raw == null || raw === "")
14476
+ return fallback;
14477
+ const parsed = Number(raw);
14478
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
14479
+ log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
14480
+ return fallback;
14481
+ }
14482
+ return parsed;
14483
+ }
14484
+
14485
+ // src/daemon-lifecycle.ts
14486
+ var DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
14487
+ var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
14150
14488
  var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
14489
+ var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
14490
+ var REUSE_READY_DELAY_MS = 250;
14491
+ var HEALTH_FETCH_TIMEOUT_MS = 500;
14492
+ var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
14151
14493
 
14152
14494
  class DaemonLifecycle {
14153
14495
  stateDir;
@@ -14167,42 +14509,120 @@ class DaemonLifecycle {
14167
14509
  get controlWsUrl() {
14168
14510
  return `ws://127.0.0.1:${this.controlPort}/ws`;
14169
14511
  }
14512
+ get expectedPairId() {
14513
+ return process.env.AGENTBRIDGE_PAIR_ID || null;
14514
+ }
14515
+ async fetchStatus() {
14516
+ try {
14517
+ const response = await fetchWithTimeout(this.healthUrl);
14518
+ if (!response.ok)
14519
+ return null;
14520
+ return await response.json();
14521
+ } catch {
14522
+ return null;
14523
+ }
14524
+ }
14525
+ isForeignDaemon(status) {
14526
+ const expected = this.expectedPairId;
14527
+ if (!expected)
14528
+ return false;
14529
+ if (!status)
14530
+ return false;
14531
+ const reported = status.pairId;
14532
+ if (reported == null)
14533
+ return true;
14534
+ return reported !== expected;
14535
+ }
14536
+ isRegisteredPairDaemonInManualMode(status) {
14537
+ return !this.expectedPairId && status?.pairId != null;
14538
+ }
14539
+ isBuildDrifted(status) {
14540
+ if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
14541
+ return false;
14542
+ const runtime = status?.build;
14543
+ if (!runtime)
14544
+ return true;
14545
+ return !sameRuntimeContract(runtime, BUILD_INFO);
14546
+ }
14547
+ canReuseDespiteDrift(status) {
14548
+ if (!compatibleContractVersion(status?.build, BUILD_INFO))
14549
+ return false;
14550
+ return status?.tuiConnected === true;
14551
+ }
14170
14552
  async ensureRunning() {
14171
14553
  if (await this.isHealthy()) {
14172
- await this.waitForReady();
14173
- return;
14554
+ const status = await this.fetchStatus();
14555
+ if (this.isRegisteredPairDaemonInManualMode(status)) {
14556
+ throw new Error(`Control port ${this.controlPort} is owned by registered pair ${status?.pairId}. ` + `This session has no pair identity (manual mode) and will not reuse or replace it \u2014 ` + `start with \`agentbridge claude\` from that pair's directory, or set AGENTBRIDGE_CONTROL_PORT to a free port.`);
14557
+ }
14558
+ if (this.isForeignDaemon(status)) {
14559
+ this.log(`Control port ${this.controlPort} held by a daemon for pair ${status?.pairId ?? "<none>"}, ` + `but this pair is ${this.expectedPairId} \u2014 replacing foreign daemon`);
14560
+ await this.replaceUnhealthyDaemon(status?.pid);
14561
+ return;
14562
+ }
14563
+ if (this.isBuildDrifted(status)) {
14564
+ if (this.canReuseDespiteDrift(status)) {
14565
+ this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `(launcher ${formatBuildInfo(BUILD_INFO)}) but a live Codex TUI is attached \u2014 reusing instead of ` + `replacing; the new build is picked up at the next restart (abg kill, then relaunch)`);
14566
+ } else {
14567
+ this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
14568
+ await this.replaceUnhealthyDaemon(status?.pid);
14569
+ return;
14570
+ }
14571
+ }
14572
+ try {
14573
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
14574
+ return;
14575
+ } catch {
14576
+ this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
14577
+ await this.replaceUnhealthyDaemon(status?.pid);
14578
+ return;
14579
+ }
14174
14580
  }
14175
14581
  const existingPid = this.readPid();
14176
14582
  if (existingPid) {
14177
14583
  if (isProcessAlive(existingPid)) {
14178
14584
  if (this.isDaemonProcess(existingPid)) {
14179
14585
  try {
14180
- await this.waitForReady(12, 250);
14586
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
14181
14587
  return;
14182
14588
  } catch {
14183
- throw new Error(`Found existing daemon process ${existingPid}, but control port ${this.controlPort} never became ready.`);
14589
+ this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
14590
+ await this.replaceUnhealthyDaemon(existingPid);
14591
+ return;
14184
14592
  }
14185
14593
  }
14186
14594
  this.log(`Pid ${existingPid} is alive but not an AgentBridge daemon, removing stale pid file`);
14187
14595
  }
14188
14596
  this.removeStalePidFile();
14189
14597
  }
14190
- const lockAcquired = this.acquireLock();
14191
- if (!lockAcquired) {
14192
- this.log("Another process is starting the daemon, waiting for readiness...");
14193
- await this.waitForReady();
14194
- return;
14195
- }
14196
- try {
14598
+ await this.withStartupLockStrict(async (locked) => {
14599
+ if (!locked) {
14600
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
14601
+ await this.waitForReadyAndOurs();
14602
+ return;
14603
+ }
14604
+ if (await this.isHealthy()) {
14605
+ const status = await this.fetchStatus();
14606
+ if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
14607
+ this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
14608
+ await this.kill(3000, status?.pid);
14609
+ } else {
14610
+ try {
14611
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
14612
+ return;
14613
+ } catch {
14614
+ this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
14615
+ await this.kill(3000, status?.pid);
14616
+ }
14617
+ }
14618
+ }
14197
14619
  this.launch();
14198
14620
  await this.waitForReady();
14199
- } finally {
14200
- this.releaseLock();
14201
- }
14621
+ });
14202
14622
  }
14203
14623
  async isHealthy() {
14204
14624
  try {
14205
- const response = await fetch(this.healthUrl);
14625
+ const response = await fetchWithTimeout(this.healthUrl);
14206
14626
  return response.ok;
14207
14627
  } catch {
14208
14628
  return false;
@@ -14218,7 +14638,7 @@ class DaemonLifecycle {
14218
14638
  }
14219
14639
  async isReady() {
14220
14640
  try {
14221
- const response = await fetch(this.readyUrl);
14641
+ const response = await fetchWithTimeout(this.readyUrl);
14222
14642
  return response.ok;
14223
14643
  } catch {
14224
14644
  return false;
@@ -14232,6 +14652,18 @@ class DaemonLifecycle {
14232
14652
  }
14233
14653
  throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
14234
14654
  }
14655
+ async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
14656
+ for (let attempt = 0;attempt < maxRetries; attempt++) {
14657
+ if (await this.isReady()) {
14658
+ const status = await this.fetchStatus();
14659
+ if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
14660
+ return;
14661
+ }
14662
+ }
14663
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
14664
+ }
14665
+ throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
14666
+ }
14235
14667
  readStatus() {
14236
14668
  try {
14237
14669
  const raw = readFileSync(this.stateDir.statusFile, "utf-8");
@@ -14263,12 +14695,12 @@ class DaemonLifecycle {
14263
14695
  }
14264
14696
  removePidFile() {
14265
14697
  try {
14266
- unlinkSync(this.stateDir.pidFile);
14698
+ unlinkSync2(this.stateDir.pidFile);
14267
14699
  } catch {}
14268
14700
  }
14269
14701
  removeStatusFile() {
14270
14702
  try {
14271
- unlinkSync(this.stateDir.statusFile);
14703
+ unlinkSync2(this.stateDir.statusFile);
14272
14704
  } catch {}
14273
14705
  }
14274
14706
  markKilled() {
@@ -14278,11 +14710,11 @@ class DaemonLifecycle {
14278
14710
  }
14279
14711
  clearKilled() {
14280
14712
  try {
14281
- unlinkSync(this.stateDir.killedFile);
14713
+ unlinkSync2(this.stateDir.killedFile);
14282
14714
  } catch {}
14283
14715
  }
14284
14716
  wasKilled() {
14285
- return existsSync2(this.stateDir.killedFile);
14717
+ return existsSync3(this.stateDir.killedFile);
14286
14718
  }
14287
14719
  launch() {
14288
14720
  this.stateDir.ensure();
@@ -14303,45 +14735,99 @@ class DaemonLifecycle {
14303
14735
  this.log("Removing stale pid file");
14304
14736
  this.removePidFile();
14305
14737
  }
14306
- acquireLock(depth = 0) {
14307
- if (depth > 1) {
14308
- this.log("Lock acquisition failed after retry, proceeding without lock");
14309
- return true;
14738
+ async replaceUnhealthyDaemon(statusPid) {
14739
+ await this.withStartupLockStrict(async (locked) => {
14740
+ if (!locked) {
14741
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
14742
+ await this.waitForReadyAndOurs();
14743
+ return;
14744
+ }
14745
+ if (await this.isHealthy()) {
14746
+ const status = await this.fetchStatus();
14747
+ if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
14748
+ try {
14749
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
14750
+ return;
14751
+ } catch {}
14752
+ }
14753
+ }
14754
+ this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
14755
+ await this.kill(3000, statusPid);
14756
+ this.launch();
14757
+ await this.waitForReady();
14758
+ });
14759
+ }
14760
+ async withStartupLockStrict(fn) {
14761
+ const locked = this.acquireLockStrict();
14762
+ try {
14763
+ return await fn(locked);
14764
+ } finally {
14765
+ if (locked)
14766
+ this.releaseLock();
14310
14767
  }
14768
+ }
14769
+ acquireLockStrict(reclaimed = false) {
14311
14770
  this.stateDir.ensure();
14771
+ let fd = null;
14312
14772
  try {
14313
- const fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
14773
+ fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
14314
14774
  writeFileSync(fd, `${process.pid}
14315
14775
  `);
14316
14776
  closeSync(fd);
14317
14777
  return true;
14318
14778
  } catch (err) {
14779
+ if (fd !== null && err.code !== "EEXIST") {
14780
+ try {
14781
+ closeSync(fd);
14782
+ } catch {}
14783
+ this.releaseLock();
14784
+ }
14319
14785
  if (err.code === "EEXIST") {
14786
+ if (reclaimed)
14787
+ return false;
14320
14788
  try {
14321
14789
  const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
14322
14790
  if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
14323
- this.log(`Stale lock file from dead process ${holderPid}, removing`);
14791
+ this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
14792
+ this.releaseLock();
14793
+ return this.acquireLockStrict(true);
14794
+ }
14795
+ if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !this.isAgentBridgeProcess(holderPid)) {
14796
+ this.log(`Startup lock is ${Math.round(this.lockAgeMs() / 1000)}s old and holder pid ${holderPid} ` + `is an unrelated process (pid recycled), reclaiming`);
14324
14797
  this.releaseLock();
14325
- return this.acquireLock(depth + 1);
14798
+ return this.acquireLockStrict(true);
14326
14799
  }
14327
14800
  } catch {
14328
- this.log("Cannot read lock file, removing stale lock");
14329
- this.releaseLock();
14330
- return this.acquireLock(depth + 1);
14801
+ return false;
14331
14802
  }
14332
14803
  return false;
14333
14804
  }
14334
- this.log(`Warning: could not acquire startup lock: ${err.message}`);
14335
- return true;
14805
+ this.log(`Could not acquire strict startup lock: ${err.message}`);
14806
+ return false;
14807
+ }
14808
+ }
14809
+ lockAgeMs() {
14810
+ try {
14811
+ return Date.now() - statSync2(this.stateDir.lockFile).mtimeMs;
14812
+ } catch {
14813
+ return 0;
14814
+ }
14815
+ }
14816
+ isAgentBridgeProcess(pid) {
14817
+ try {
14818
+ const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
14819
+ return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
14820
+ } catch {
14821
+ return false;
14336
14822
  }
14337
14823
  }
14338
14824
  releaseLock() {
14339
14825
  try {
14340
- unlinkSync(this.stateDir.lockFile);
14826
+ unlinkSync2(this.stateDir.lockFile);
14341
14827
  } catch {}
14342
14828
  }
14343
- async kill(gracefulTimeoutMs = 3000) {
14344
- const pid = this.readPid();
14829
+ async kill(gracefulTimeoutMs = 3000, pidOverride) {
14830
+ const pid = pidOverride ?? this.readPid();
14345
14831
  if (!pid) {
14346
14832
  this.log("No daemon pid file found");
14347
14833
  this.cleanup();
@@ -14383,7 +14869,9 @@ class DaemonLifecycle {
14383
14869
  isDaemonProcess(pid) {
14384
14870
  try {
14385
14871
  const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
14386
- return cmd.includes("daemon") && (cmd.includes("agentbridge") || cmd.includes("agent_bridge"));
14872
+ const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
14873
+ const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
14874
+ return hasDaemonEntry && hasAgentbridge;
14387
14875
  } catch {
14388
14876
  return false;
14389
14877
  }
@@ -14391,7 +14879,15 @@ class DaemonLifecycle {
14391
14879
  cleanup() {
14392
14880
  this.removePidFile();
14393
14881
  this.removeStatusFile();
14394
- this.releaseLock();
14882
+ }
14883
+ }
14884
+ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
14885
+ const controller = new AbortController;
14886
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
14887
+ try {
14888
+ return await fetch(url, { signal: controller.signal });
14889
+ } finally {
14890
+ clearTimeout(timer);
14395
14891
  }
14396
14892
  }
14397
14893
  function isProcessAlive(pid) {
@@ -14404,8 +14900,25 @@ function isProcessAlive(pid) {
14404
14900
  }
14405
14901
 
14406
14902
  // src/config-service.ts
14407
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
14903
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
14408
14904
  import { join as join2 } from "path";
14905
+ var DEFAULT_BUDGET_CONFIG = {
14906
+ enabled: true,
14907
+ pollSeconds: 60,
14908
+ pauseAt: 90,
14909
+ resumeBelow: 30,
14910
+ syncDriftPct: 10,
14911
+ parallel: {
14912
+ minRemainingPct: 60,
14913
+ timeWindowSec: 3600
14914
+ },
14915
+ codexTierControl: false,
14916
+ codexTiers: {
14917
+ full: null,
14918
+ balanced: { effort: "medium" },
14919
+ eco: { effort: "low" }
14920
+ }
14921
+ };
14409
14922
  var DEFAULT_CONFIG = {
14410
14923
  version: "1.0",
14411
14924
  codex: {
@@ -14415,7 +14928,8 @@ var DEFAULT_CONFIG = {
14415
14928
  turnCoordination: {
14416
14929
  attentionWindowSeconds: 15
14417
14930
  },
14418
- idleShutdownSeconds: 30
14931
+ idleShutdownSeconds: 30,
14932
+ budget: DEFAULT_BUDGET_CONFIG
14419
14933
  };
14420
14934
  var CONFIG_DIR = ".agentbridge";
14421
14935
  var CONFIG_FILE = "config.json";
@@ -14432,6 +14946,63 @@ function normalizeInteger(value, fallback) {
14432
14946
  }
14433
14947
  return fallback;
14434
14948
  }
14949
+ function normalizeBoundedInteger(value, fallback, min, max) {
14950
+ const parsed = normalizeInteger(value, fallback);
14951
+ if (parsed < min || parsed > max)
14952
+ return fallback;
14953
+ return parsed;
14954
+ }
14955
+ function normalizeBoolean(value, fallback) {
14956
+ if (typeof value === "boolean")
14957
+ return value;
14958
+ if (value === "true" || value === "1")
14959
+ return true;
14960
+ if (value === "false" || value === "0")
14961
+ return false;
14962
+ return fallback;
14963
+ }
14964
+ function normalizeCodexOverride(raw) {
14965
+ if (!isRecord(raw))
14966
+ return null;
14967
+ const override = {};
14968
+ if (typeof raw.model === "string" && raw.model.trim() !== "")
14969
+ override.model = raw.model.trim();
14970
+ if (typeof raw.effort === "string" && raw.effort.trim() !== "")
14971
+ override.effort = raw.effort.trim();
14972
+ return Object.keys(override).length > 0 ? override : null;
14973
+ }
14974
+ function normalizeCodexTiers(raw) {
14975
+ const tiers = isRecord(raw) ? raw : {};
14976
+ return {
14977
+ full: normalizeCodexOverride(tiers.full),
14978
+ balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
14979
+ eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
14980
+ };
14981
+ }
14982
+ function normalizeBudgetConfig(raw) {
14983
+ const budget = isRecord(raw) ? raw : {};
14984
+ const parallel = isRecord(budget.parallel) ? budget.parallel : {};
14985
+ const codexTiers = normalizeCodexTiers(budget.codexTiers);
14986
+ let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
14987
+ let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
14988
+ if (pauseAt <= resumeBelow) {
14989
+ pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
14990
+ resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
14991
+ }
14992
+ return {
14993
+ enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
14994
+ pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
14995
+ pauseAt,
14996
+ resumeBelow,
14997
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
14998
+ parallel: {
14999
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
15000
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
15001
+ },
15002
+ codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
15003
+ codexTiers
15004
+ };
15005
+ }
14435
15006
  function normalizeConfig(raw) {
14436
15007
  if (!isRecord(raw))
14437
15008
  return null;
@@ -14448,7 +15019,8 @@ function normalizeConfig(raw) {
14448
15019
  turnCoordination: {
14449
15020
  attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
14450
15021
  },
14451
- idleShutdownSeconds: normalizeInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds)
15022
+ idleShutdownSeconds: normalizeInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
15023
+ budget: normalizeBudgetConfig(config2.budget)
14452
15024
  };
14453
15025
  }
14454
15026
 
@@ -14461,7 +15033,7 @@ class ConfigService {
14461
15033
  this.configPath = join2(this.configDir, CONFIG_FILE);
14462
15034
  }
14463
15035
  hasConfig() {
14464
- return existsSync3(this.configPath);
15036
+ return existsSync4(this.configPath);
14465
15037
  }
14466
15038
  load() {
14467
15039
  try {
@@ -14482,7 +15054,7 @@ class ConfigService {
14482
15054
  initDefaults() {
14483
15055
  this.ensureConfigDir();
14484
15056
  const created = [];
14485
- if (!existsSync3(this.configPath)) {
15057
+ if (!existsSync4(this.configPath)) {
14486
15058
  this.save(DEFAULT_CONFIG);
14487
15059
  created.push(this.configPath);
14488
15060
  }
@@ -14492,32 +15064,323 @@ class ConfigService {
14492
15064
  return this.configPath;
14493
15065
  }
14494
15066
  ensureConfigDir() {
14495
- if (!existsSync3(this.configDir)) {
15067
+ if (!existsSync4(this.configDir)) {
14496
15068
  mkdirSync2(this.configDir, { recursive: true });
14497
15069
  }
14498
15070
  }
14499
15071
  }
14500
15072
 
15073
+ // src/pair-registry.ts
15074
+ import {
15075
+ closeSync as closeSync2,
15076
+ existsSync as existsSync5,
15077
+ fsyncSync,
15078
+ linkSync,
15079
+ lstatSync,
15080
+ mkdirSync as mkdirSync3,
15081
+ openSync as openSync2,
15082
+ readdirSync,
15083
+ readFileSync as readFileSync3,
15084
+ realpathSync,
15085
+ renameSync as renameSync2,
15086
+ rmSync,
15087
+ statSync as statSync3,
15088
+ unlinkSync as unlinkSync3,
15089
+ writeFileSync as writeFileSync3
15090
+ } from "fs";
15091
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
15092
+ import { basename, join as join3, resolve, sep } from "path";
15093
+ var PAIR_BASE_PORT = 4500;
15094
+ var PAIR_SLOT_STRIDE = 10;
15095
+ var PAIR_ID_REGEX = /^[A-Za-z0-9._-]{1,64}$/;
15096
+ var REGISTRY_FILE_NAME = "registry.json";
15097
+ class PairError extends Error {
15098
+ code;
15099
+ details;
15100
+ constructor(code, message, details) {
15101
+ super(message);
15102
+ this.name = "PairError";
15103
+ this.code = code;
15104
+ this.details = details;
15105
+ }
15106
+ }
15107
+ var MAX_PAIR_SLOT = Math.floor((65535 - 2 - PAIR_BASE_PORT) / PAIR_SLOT_STRIDE);
15108
+ function derivePairId(cwd, name) {
15109
+ let real;
15110
+ try {
15111
+ real = realpathSync(cwd);
15112
+ } catch {
15113
+ real = cwd;
15114
+ }
15115
+ const hash = createHash("sha256").update(real).update("\x00").update(name.toLowerCase()).digest("hex").slice(0, 8);
15116
+ const slug = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "pair";
15117
+ return `${slug}-${hash}`;
15118
+ }
15119
+ function pairsDir(base) {
15120
+ return join3(base, "pairs");
15121
+ }
15122
+ function registryPath(base) {
15123
+ return join3(pairsDir(base), REGISTRY_FILE_NAME);
15124
+ }
15125
+ function readRegistry(base) {
15126
+ const path = registryPath(base);
15127
+ if (!existsSync5(path))
15128
+ return { version: 1, pairs: [] };
15129
+ let parsed;
15130
+ try {
15131
+ parsed = JSON.parse(readFileSync3(path, "utf-8"));
15132
+ } catch (err) {
15133
+ throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry JSON is not parseable at ${path}: ${err.message}`, {
15134
+ path
15135
+ });
15136
+ }
15137
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !Array.isArray(parsed.pairs)) {
15138
+ throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry shape is invalid at ${path}`, { path });
15139
+ }
15140
+ const entries = parsed.pairs;
15141
+ const seenSlots = new Set;
15142
+ const seenIds = new Set;
15143
+ for (const e of entries) {
15144
+ const idValid = e && typeof e.pairId === "string" && e.pairId !== "." && e.pairId !== ".." && PAIR_ID_REGEX.test(e.pairId);
15145
+ if (!idValid || !Number.isInteger(e.slot) || e.slot < 0) {
15146
+ throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry has a malformed entry at ${path}`, { path, entry: e });
15147
+ }
15148
+ const lower = e.pairId.toLowerCase();
15149
+ if (seenSlots.has(e.slot) || seenIds.has(lower)) {
15150
+ throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry has duplicate slot/pairId at ${path}`, {
15151
+ path,
15152
+ pairId: e.pairId,
15153
+ slot: e.slot
15154
+ });
15155
+ }
15156
+ seenSlots.add(e.slot);
15157
+ seenIds.add(lower);
15158
+ }
15159
+ return parsed;
15160
+ }
15161
+
15162
+ // src/pair-resolver.ts
15163
+ function computeBaseDir() {
15164
+ return process.env.AGENTBRIDGE_BASE_DIR || process.env.AGENTBRIDGE_STATE_DIR || StateDirResolver.platformBaseDir();
15165
+ }
15166
+ function findPair(base, pairId) {
15167
+ const lower = pairId.toLowerCase();
15168
+ return readRegistry(base).pairs.find((p) => p.pairId.toLowerCase() === lower) ?? null;
15169
+ }
15170
+
15171
+ // src/pair-command.ts
15172
+ function pairScopedCommand(cmd) {
15173
+ const pairId = process.env.AGENTBRIDGE_PAIR_ID;
15174
+ if (!pairId)
15175
+ return `agentbridge ${cmd}`;
15176
+ let selector = process.env.AGENTBRIDGE_PAIR_NAME;
15177
+ if (!selector) {
15178
+ try {
15179
+ selector = findPair(computeBaseDir(), pairId)?.name || pairId;
15180
+ } catch {
15181
+ selector = pairId;
15182
+ }
15183
+ }
15184
+ return `agentbridge --pair ${selector} ${cmd}`;
15185
+ }
15186
+
14501
15187
  // src/bridge-disabled-state.ts
14502
15188
  function disabledReplyError(reason) {
15189
+ const claudeCmd = pairScopedCommand("claude");
14503
15190
  switch (reason) {
14504
15191
  case "rejected":
14505
- return "AgentBridge rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run `agentbridge kill` to reset.";
15192
+ return `AgentBridge rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run \`${pairScopedCommand("kill")}\` to reset.`;
15193
+ case "evicted":
15194
+ return `AgentBridge evicted this session because it stopped responding to liveness probes \u2014 a newer Claude Code session has taken over. Close this session and start a new one with \`${claudeCmd}\`.`;
15195
+ case "probe_in_progress":
15196
+ return `AgentBridge rejected this session \u2014 a liveness probe is currently checking the incumbent Claude session. Retry in a few seconds with \`${claudeCmd}\`.`;
15197
+ case "auto_recovery_exhausted":
15198
+ return `AgentBridge auto-recovery gave up after exhausting its retry budget for the in-flight liveness probe contention. Retry manually with \`${claudeCmd}\`.`;
14506
15199
  case "killed":
14507
- return "AgentBridge is disabled by `agentbridge kill`. Restart Claude Code (`agentbridge claude`), switch to a new conversation, or run `/resume` to reconnect.";
15200
+ return `AgentBridge is disabled by \`agentbridge kill\`. Restart Claude Code (\`${claudeCmd}\`), switch to a new conversation, or run \`/resume\` to reconnect.`;
15201
+ }
15202
+ }
15203
+
15204
+ // src/env-guard.ts
15205
+ var GENERATED_ENV_KEYS = [
15206
+ "AGENTBRIDGE_BASE_DIR",
15207
+ "AGENTBRIDGE_PAIR_ID",
15208
+ "AGENTBRIDGE_PAIR_NAME",
15209
+ "AGENTBRIDGE_STATE_DIR",
15210
+ "AGENTBRIDGE_CONTROL_PORT",
15211
+ "AGENTBRIDGE_MODE",
15212
+ "AGENTBRIDGE_FILTER_MODE",
15213
+ "AGENTBRIDGE_MAX_BUFFERED_MESSAGES",
15214
+ "AGENTBRIDGE_CODEX_TRANSPORT",
15215
+ "CODEX_WS_PORT",
15216
+ "CODEX_PROXY_PORT"
15217
+ ];
15218
+ function normalizeEnvGuardMode(raw, fallback = "fix") {
15219
+ if (raw === "off" || raw === "warn" || raw === "fix" || raw === "strict")
15220
+ return raw;
15221
+ return fallback;
15222
+ }
15223
+ function inspectAgentBridgeEnv(opts) {
15224
+ const env = opts.env ?? process.env;
15225
+ const actualPairId = nonEmpty(env.AGENTBRIDGE_PAIR_ID);
15226
+ const pairName = nonEmpty(env.AGENTBRIDGE_PAIR_NAME) ?? "main";
15227
+ const stateDir = nonEmpty(env.AGENTBRIDGE_STATE_DIR);
15228
+ const baseDir = nonEmpty(env.AGENTBRIDGE_BASE_DIR);
15229
+ const manualOptIn = env.AGENTBRIDGE_MANUAL === "1";
15230
+ const manualRuntimeEnv = !!stateDir || !!nonEmpty(env.AGENTBRIDGE_CONTROL_PORT) || !!nonEmpty(env.CODEX_WS_PORT) || !!nonEmpty(env.CODEX_PROXY_PORT);
15231
+ const expectedPairId = actualPairId ? derivePairId(opts.cwd, pairName) : null;
15232
+ const reasons = [];
15233
+ if (!actualPairId && manualRuntimeEnv && !manualOptIn) {
15234
+ reasons.push("AgentBridge runtime env is set without AGENTBRIDGE_PAIR_ID or AGENTBRIDGE_MANUAL=1");
15235
+ }
15236
+ if (actualPairId && expectedPairId && actualPairId !== expectedPairId) {
15237
+ reasons.push(`AGENTBRIDGE_PAIR_ID=${actualPairId} does not match cwd-derived ${expectedPairId}`);
15238
+ }
15239
+ if (actualPairId && stateDir && !stateDir.endsWith(`/pairs/${actualPairId}`)) {
15240
+ reasons.push(`AGENTBRIDGE_STATE_DIR does not end with /pairs/${actualPairId}`);
14508
15241
  }
15242
+ if (actualPairId && baseDir && stateDir && !stateDir.startsWith(`${baseDir}/`)) {
15243
+ reasons.push("AGENTBRIDGE_BASE_DIR and AGENTBRIDGE_STATE_DIR disagree");
15244
+ }
15245
+ return {
15246
+ ok: reasons.length === 0,
15247
+ expectedPairId,
15248
+ actualPairId,
15249
+ pairName,
15250
+ reasons
15251
+ };
15252
+ }
15253
+ function guardAgentBridgeEnv(opts) {
15254
+ const env = opts.env ?? process.env;
15255
+ const mode = normalizeEnvGuardMode(opts.mode, "fix");
15256
+ const effectiveMode = mode === "strict" && opts.allowStrict === false ? "fix" : mode;
15257
+ const inspection = inspectAgentBridgeEnv({ cwd: opts.cwd, env });
15258
+ if (effectiveMode === "off" || inspection.ok) {
15259
+ return { ...inspection, action: "none" };
15260
+ }
15261
+ const message = `stale AgentBridge environment detected for ${opts.cwd}: ${inspection.reasons.join("; ")}`;
15262
+ if (effectiveMode === "strict") {
15263
+ throw new Error(message);
15264
+ }
15265
+ opts.log?.(`[agentbridge] ${message}`);
15266
+ if (effectiveMode === "warn") {
15267
+ return { ...inspection, action: "warned" };
15268
+ }
15269
+ for (const key of GENERATED_ENV_KEYS) {
15270
+ delete env[key];
15271
+ }
15272
+ opts.log?.("[agentbridge] cleared stale AgentBridge environment variables");
15273
+ return { ...inspection, action: "fixed" };
15274
+ }
15275
+ function nonEmpty(value) {
15276
+ return value && value.length > 0 ? value : null;
15277
+ }
15278
+
15279
+ // src/trace-log.ts
15280
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
15281
+ import { join as join4 } from "path";
15282
+ var SECRET_KEY_RE = /(token|secret|password|passwd|api[_-]?key|auth|cookie|session)/i;
15283
+ var SECRET_ARG_RE = /^--?(?:token|secret|password|passwd|apikey|api-key|api_key|auth|cookie|session)(?:=.*)?$/i;
15284
+ var RELEVANT_ENV_RE = /^(AGENTBRIDGE_|CODEX_)/;
15285
+ function pickRelevantEnv(env) {
15286
+ const picked = {};
15287
+ for (const [key, value] of Object.entries(env)) {
15288
+ if (!RELEVANT_ENV_RE.test(key))
15289
+ continue;
15290
+ picked[key] = SECRET_KEY_RE.test(key) && value !== undefined ? "<redacted>" : value;
15291
+ }
15292
+ return picked;
15293
+ }
15294
+ function redactArgv(argv) {
15295
+ const redacted = [];
15296
+ let redactNext = false;
15297
+ for (const arg of argv) {
15298
+ if (redactNext) {
15299
+ redacted.push("<redacted>");
15300
+ redactNext = false;
15301
+ continue;
15302
+ }
15303
+ if (SECRET_ARG_RE.test(arg)) {
15304
+ if (arg.includes("=")) {
15305
+ const [key] = arg.split("=", 1);
15306
+ redacted.push(`${key}=<redacted>`);
15307
+ } else {
15308
+ redacted.push(arg);
15309
+ redactNext = true;
15310
+ }
15311
+ continue;
15312
+ }
15313
+ redacted.push(arg);
15314
+ }
15315
+ return redacted;
15316
+ }
15317
+ function traceLogPath(cwd, timestamp) {
15318
+ const day = timestamp.slice(0, 10);
15319
+ return join4(cwd, ".agentbridge", "logs", `trace-${day}.jsonl`);
15320
+ }
15321
+ function appendTraceEvent(input) {
15322
+ const timestamp = input.timestamp ?? new Date().toISOString();
15323
+ const path = traceLogPath(input.cwd, timestamp);
15324
+ const event = {
15325
+ timestamp,
15326
+ event: input.event,
15327
+ cwd: input.cwd,
15328
+ pid: input.pid ?? process.pid,
15329
+ ...input.argv ? { argv: redactArgv(input.argv) } : {},
15330
+ ...input.env ? { env: pickRelevantEnv(input.env) } : {},
15331
+ ...input.data ? { data: redactData(input.data) } : {}
15332
+ };
15333
+ mkdirSync4(join4(input.cwd, ".agentbridge", "logs"), { recursive: true });
15334
+ appendFileSync2(path, JSON.stringify(event) + `
15335
+ `, "utf-8");
15336
+ return path;
15337
+ }
15338
+ function isEnvSnapshot(key, value) {
15339
+ return /env$/i.test(key) && !!value && typeof value === "object" && !Array.isArray(value);
15340
+ }
15341
+ function redactData(value, key = "") {
15342
+ if (typeof value === "string") {
15343
+ return SECRET_KEY_RE.test(key) ? "<redacted>" : value;
15344
+ }
15345
+ if (Array.isArray(value)) {
15346
+ return value.map((item) => redactData(item, key));
15347
+ }
15348
+ if (value && typeof value === "object") {
15349
+ const redacted = {};
15350
+ for (const [childKey, childValue] of Object.entries(value)) {
15351
+ if (SECRET_KEY_RE.test(childKey)) {
15352
+ redacted[childKey] = "<redacted>";
15353
+ } else if (isEnvSnapshot(childKey, childValue)) {
15354
+ redacted[childKey] = pickRelevantEnv(childValue);
15355
+ } else {
15356
+ redacted[childKey] = redactData(childValue, childKey);
15357
+ }
15358
+ }
15359
+ return redacted;
15360
+ }
15361
+ return value;
14509
15362
  }
14510
15363
 
14511
15364
  // src/bridge.ts
15365
+ var originalEnv = { ...process.env };
15366
+ var bootstrapLogger = createProcessLogger({ component: "AgentBridgeFrontend" });
15367
+ var envGuardResult = guardAgentBridgeEnv({
15368
+ cwd: process.cwd(),
15369
+ env: process.env,
15370
+ mode: normalizeEnvGuardMode(process.env.AGENTBRIDGE_ENV_GUARD),
15371
+ allowStrict: false,
15372
+ log: bootstrapLogger.log
15373
+ });
14512
15374
  var stateDir = new StateDirResolver;
14513
15375
  stateDir.ensure();
14514
15376
  var configService = new ConfigService;
14515
15377
  var config2 = configService.loadOrDefault();
14516
15378
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
15379
+ var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
14517
15380
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
14518
15381
  var CONTROL_WS_URL = daemonLifecycle.controlWsUrl;
14519
15382
  var claude = new ClaudeAdapter(stateDir.logFile);
14520
- var daemonClient = new DaemonClient(CONTROL_WS_URL);
15383
+ var daemonClient = new DaemonClient(CONTROL_WS_URL, { identity: currentClientIdentity() });
14521
15384
  var shuttingDown = false;
14522
15385
  var daemonDisabled = false;
14523
15386
  var daemonDisabledReason = null;
@@ -14529,6 +15392,30 @@ var lastDisconnectNotifyTs = 0;
14529
15392
  var lastReconnectNotifyTs = 0;
14530
15393
  var disabledRecoveryTimer = null;
14531
15394
  var disabledRecoveryInFlight = false;
15395
+ var disabledRecoveryAttempts = 0;
15396
+ var DISABLED_RECOVERY_MAX_ATTEMPTS = 6;
15397
+ var DISABLED_RECOVERY_CONFIRM_TIMEOUT_MS = 1000;
15398
+ if (process.env.AGENTBRIDGE_TRACE === "1") {
15399
+ try {
15400
+ appendTraceEvent({
15401
+ cwd: process.cwd(),
15402
+ event: "bridge.start",
15403
+ pid: process.pid,
15404
+ argv: process.argv,
15405
+ env: process.env,
15406
+ data: {
15407
+ originalEnv: pickRelevantEnv(originalEnv),
15408
+ effectiveEnv: pickRelevantEnv(process.env),
15409
+ envGuardAction: envGuardResult.action,
15410
+ pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
15411
+ pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
15412
+ stateDir: stateDir.dir,
15413
+ controlPort: CONTROL_PORT,
15414
+ build: BUILD_INFO
15415
+ }
15416
+ });
15417
+ } catch {}
15418
+ }
14532
15419
  claude.setReplySender(async (msg, requireReply) => {
14533
15420
  if (msg.source !== "claude") {
14534
15421
  return { success: false, error: "Invalid message source" };
@@ -14547,6 +15434,7 @@ daemonClient.on("codexMessage", (message) => {
14547
15434
  });
14548
15435
  daemonClient.on("status", (status) => {
14549
15436
  log(`Daemon status: ready=${status.bridgeReady} tui=${status.tuiConnected} thread=${status.threadId ?? "none"} queued=${status.queuedMessageCount}`);
15437
+ claude.setBudgetSnapshot(status.budget ?? null);
14550
15438
  if (!hasSeenTuiConnect && status.tuiConnected && !previousTuiConnected) {
14551
15439
  hasSeenTuiConnect = true;
14552
15440
  log("First TUI connect detected \u2014 sending kickoff message to Claude");
@@ -14563,6 +15451,7 @@ daemonClient.on("status", (status) => {
14563
15451
  daemonClient.on("disconnect", () => {
14564
15452
  if (shuttingDown || daemonDisabled)
14565
15453
  return;
15454
+ claude.setBudgetSnapshot(null);
14566
15455
  log("Daemon control connection closed \u2014 will attempt to reconnect");
14567
15456
  const now = Date.now();
14568
15457
  if (now - lastDisconnectNotifyTs >= RECONNECT_NOTIFY_COOLDOWN_MS) {
@@ -14573,22 +15462,55 @@ daemonClient.on("disconnect", () => {
14573
15462
  }
14574
15463
  reconnectToDaemon();
14575
15464
  });
14576
- daemonClient.on("rejected", async () => {
15465
+ daemonClient.on("rejected", async (code) => {
14577
15466
  if (shuttingDown || daemonDisabled)
14578
15467
  return;
14579
- log("Daemon rejected this session (close code 4001) \u2014 another Claude session is already connected");
15468
+ let reason;
15469
+ let notificationId;
15470
+ let notificationContent;
15471
+ switch (code) {
15472
+ case CLOSE_CODE_EVICTED_STALE:
15473
+ reason = "evicted";
15474
+ notificationId = "system_bridge_evicted";
15475
+ notificationContent = `\u26A0\uFE0F AgentBridge evicted this session because it stopped responding to liveness probes \u2014 a newer Claude Code session has taken over. Close this session and start a new one with \`${pairScopedCommand("claude")}\` if you want to reconnect. AgentBridge \u56E0\u6B64\u4F1A\u8BDD\u672A\u54CD\u5E94\u5B58\u6D3B\u63A2\u6D4B\u800C\u5C06\u5176\u9A71\u9010\u2014\u2014\u66F4\u65B0\u7684 Claude Code \u4F1A\u8BDD\u5DF2\u63A5\u7BA1\u3002\u5982\u9700\u91CD\u8FDE\uFF0C\u8BF7\u5173\u95ED\u6B64\u4F1A\u8BDD\u5E76\u8FD0\u884C \`${pairScopedCommand("claude")}\` \u542F\u52A8\u65B0\u4F1A\u8BDD\u3002`;
15476
+ break;
15477
+ case CLOSE_CODE_PROBE_IN_PROGRESS:
15478
+ reason = "probe_in_progress";
15479
+ notificationId = "system_bridge_probe_in_progress";
15480
+ notificationContent = `\u26A0\uFE0F AgentBridge rejected this session \u2014 a liveness probe is currently checking whether the incumbent Claude session is still alive. Retry in a few seconds with \`${pairScopedCommand("claude")}\`. AgentBridge \u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u6B63\u5728\u901A\u8FC7\u5B58\u6D3B\u63A2\u6D4B\u68C0\u67E5\u73B0\u6709 Claude \u4F1A\u8BDD\u662F\u5426\u4ECD\u7136\u5728\u7EBF\u3002\u8BF7\u7A0D\u540E\u7528 \`${pairScopedCommand("claude")}\` \u91CD\u8BD5\u3002`;
15481
+ break;
15482
+ case CLOSE_CODE_PAIR_MISMATCH:
15483
+ reason = "rejected";
15484
+ notificationId = "system_bridge_pair_mismatch";
15485
+ notificationContent = `\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 pair/cwd identity mismatch (this daemon belongs to a different pair or directory). Do NOT kill it; start Claude Code from the pair's own directory, or pick another pair name with \`agentbridge --pair <name> claude\`. AgentBridge \u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014pair/\u76EE\u5F55\u8EAB\u4EFD\u4E0D\u5339\u914D\uFF08\u8BE5 daemon \u5C5E\u4E8E\u5176\u4ED6 pair \u6216\u76EE\u5F55\uFF09\u3002\u65E0\u9700 kill\uFF1B\u8BF7\u5230\u5BF9\u5E94\u76EE\u5F55\u542F\u52A8\uFF0C\u6216\u6362\u4E00\u4E2A pair \u540D\uFF1A\`agentbridge --pair <\u540D\u5B57> claude\`\u3002`;
15486
+ break;
15487
+ default:
15488
+ reason = "rejected";
15489
+ notificationId = "system_bridge_replaced";
15490
+ notificationContent = `\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run \`${pairScopedCommand("kill")}\` to reset. AgentBridge \u5B88\u62A4\u8FDB\u7A0B\u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u53E6\u4E00\u4E2A Claude Code \u4F1A\u8BDD\u5DF2\u5728\u8FDE\u63A5\u4E2D\u3002\u8BF7\u5148\u5173\u95ED\u53E6\u4E00\u4E2A\u4F1A\u8BDD\uFF0C\u6216\u8FD0\u884C \`${pairScopedCommand("kill")}\` \u91CD\u7F6E\u3002`;
15491
+ break;
15492
+ }
15493
+ log(`Daemon rejected this session (close code ${code}, reason=${reason})`);
14580
15494
  daemonDisabled = true;
14581
- daemonDisabledReason = "rejected";
14582
- await claude.pushNotification(systemMessage("system_bridge_replaced", "\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run `agentbridge kill` to reset. AgentBridge \u5B88\u62A4\u8FDB\u7A0B\u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u53E6\u4E00\u4E2A Claude Code \u4F1A\u8BDD\u5DF2\u5728\u8FDE\u63A5\u4E2D\u3002\u8BF7\u5148\u5173\u95ED\u53E6\u4E00\u4E2A\u4F1A\u8BDD\uFF0C\u6216\u8FD0\u884C `agentbridge kill` \u91CD\u7F6E\u3002"));
15495
+ daemonDisabledReason = reason;
15496
+ await claude.pushNotification(systemMessage(notificationId, notificationContent));
14583
15497
  await daemonClient.disconnect();
15498
+ if (reason === "probe_in_progress") {
15499
+ disabledRecoveryAttempts = 0;
15500
+ startDisabledRecoveryPoller();
15501
+ }
14584
15502
  });
14585
15503
  claude.on("ready", async () => {
14586
- log(`MCP server ready (delivery mode: ${claude.getDeliveryMode()}) \u2014 ensuring AgentBridge daemon...`);
15504
+ log("MCP server ready (push delivery) \u2014 ensuring AgentBridge daemon...");
14587
15505
  if (daemonLifecycle.wasKilled()) {
14588
- await enterDisabledState("Killed sentinel found \u2014 bridge staying idle", "\u26D4 AgentBridge was stopped by `agentbridge kill`. Bridge is staying idle. Restart Claude Code (`agentbridge claude`), switch to a new conversation, or run `/resume` to reconnect.");
15506
+ await enterDisabledState("Killed sentinel found \u2014 bridge staying idle", `\u26D4 AgentBridge was stopped by \`agentbridge kill\`. Bridge is staying idle. Restart Claude Code (\`${pairScopedCommand("claude")}\`), switch to a new conversation, or run \`/resume\` to reconnect.`);
14589
15507
  return;
14590
15508
  }
14591
- await connectToDaemon();
15509
+ try {
15510
+ await connectToDaemon();
15511
+ } catch {
15512
+ reconnectToDaemon();
15513
+ }
14592
15514
  });
14593
15515
  async function connectToDaemon(isReconnect = false) {
14594
15516
  if (daemonDisabled) {
@@ -14598,10 +15520,14 @@ async function connectToDaemon(isReconnect = false) {
14598
15520
  try {
14599
15521
  await daemonLifecycle.ensureRunning();
14600
15522
  await daemonClient.connect();
14601
- daemonClient.attachClaude();
15523
+ const status = await daemonClient.attachClaudeAndWaitForStatus(5000);
15524
+ if (!status) {
15525
+ throw new Error("Daemon did not confirm Claude attach.");
15526
+ }
15527
+ assertAttachedToExpectedDaemon(status);
14602
15528
  daemonDisabledReason = null;
14603
15529
  if (!isReconnect) {
14604
- claude.pushNotification(systemMessage("system_bridge_ready", "\u2705 AgentBridge bridge is ready. Daemon connected. Start Codex in another terminal with: agentbridge codex"));
15530
+ claude.pushNotification(systemMessage(status.bridgeReady ? "system_bridge_ready" : "system_bridge_waiting", initialAttachMessage(status)));
14605
15531
  }
14606
15532
  } catch (err) {
14607
15533
  log(`Failed to connect to daemon: ${err.message}`);
@@ -14609,6 +15535,21 @@ async function connectToDaemon(isReconnect = false) {
14609
15535
  throw err;
14610
15536
  }
14611
15537
  }
15538
+ function assertAttachedToExpectedDaemon(status) {
15539
+ const expectedPairId = process.env.AGENTBRIDGE_PAIR_ID || null;
15540
+ if (expectedPairId && status.pairId !== expectedPairId) {
15541
+ throw new Error(`Daemon identity mismatch after attach: expected pair ${expectedPairId}, got ${status.pairId ?? "<none>"}.`);
15542
+ }
15543
+ }
15544
+ function initialAttachMessage(status) {
15545
+ if (status.bridgeReady) {
15546
+ return "\u2705 AgentBridge bridge is ready. Codex TUI is connected.";
15547
+ }
15548
+ if (status.tuiConnected) {
15549
+ return "\u23F3 AgentBridge attached to daemon. Waiting for Codex to finish creating a thread.";
15550
+ }
15551
+ return `\u23F3 AgentBridge attached to daemon. Waiting for Codex TUI. Start Codex in another terminal with: ${pairScopedCommand("codex")}`;
15552
+ }
14612
15553
  async function enterDisabledState(logMessage, notificationContent) {
14613
15554
  if (daemonDisabled)
14614
15555
  return;
@@ -14624,7 +15565,13 @@ var reconnectTask = null;
14624
15565
  async function notifyIfDaemonKilled(logMessage) {
14625
15566
  if (!daemonLifecycle.wasKilled())
14626
15567
  return false;
14627
- await enterDisabledState(logMessage, "\u26D4 AgentBridge was stopped by `agentbridge kill`. Bridge is staying idle. Restart Claude Code (`agentbridge claude`), switch to a new conversation, or run `/resume` to reconnect.");
15568
+ await enterDisabledState(logMessage, `\u26D4 AgentBridge was stopped by \`agentbridge kill\`. Bridge is staying idle. Restart Claude Code (\`${pairScopedCommand("claude")}\`), switch to a new conversation, or run \`/resume\` to reconnect.`);
15569
+ return true;
15570
+ }
15571
+ async function notifyIfPairRemoved(logMessage) {
15572
+ if (existsSync6(stateDir.dir))
15573
+ return false;
15574
+ await enterDisabledState(logMessage, `\u26D4 This pair's state directory was removed (\`abg pairs rm\` / \`prune\`). Bridge is staying idle. Start fresh with \`${pairScopedCommand("claude")}\` if you still need this pair. \u8BE5 pair \u7684\u72B6\u6001\u76EE\u5F55\u5DF2\u88AB\u5220\u9664\uFF08pairs rm / prune\uFF09\uFF0C\u6865\u63A5\u4FDD\u6301\u5F85\u673A\uFF1B\u5982\u4ECD\u9700\u8981\u8BF7\u7528 \`${pairScopedCommand("claude")}\` \u91CD\u65B0\u542F\u52A8\u3002`);
14628
15575
  return true;
14629
15576
  }
14630
15577
  function reconnectToDaemon() {
@@ -14640,11 +15587,14 @@ function reconnectToDaemon() {
14640
15587
  if (await notifyIfDaemonKilled("Daemon was intentionally killed by user (killed sentinel found) \u2014 not reconnecting")) {
14641
15588
  return;
14642
15589
  }
15590
+ if (await notifyIfPairRemoved("Pair state directory removed \u2014 not reconnecting")) {
15591
+ return;
15592
+ }
14643
15593
  const delayMs = Math.min(1000 * 2 ** attempt, MAX_RECONNECT_DELAY_MS);
14644
15594
  if (attempt > 0) {
14645
15595
  log(`Reconnect attempt ${attempt + 1}, waiting ${delayMs}ms...`);
14646
15596
  }
14647
- await new Promise((resolve) => setTimeout(resolve, delayMs));
15597
+ await new Promise((resolve2) => setTimeout(resolve2, delayMs));
14648
15598
  if (shuttingDown)
14649
15599
  return;
14650
15600
  if (await notifyIfDaemonKilled("Daemon was intentionally killed during reconnect backoff \u2014 not reconnecting")) {
@@ -14697,20 +15647,72 @@ async function pollDisabledRecovery() {
14697
15647
  if (!healthy) {
14698
15648
  return;
14699
15649
  }
14700
- log("Disabled-state recovery conditions met \u2014 attempting direct daemon reconnect");
14701
- try {
14702
- await daemonClient.connect();
14703
- daemonClient.attachClaude();
14704
- daemonDisabled = false;
14705
- daemonDisabledReason = null;
14706
- stopDisabledRecoveryPoller();
14707
- claude.pushNotification(systemMessage("system_bridge_recovered", "\u2705 AgentBridge recovered after the killed sentinel was cleared. Daemon reconnected."));
14708
- } catch (err) {
14709
- log(`Disabled-state direct reconnect failed: ${err.message}`);
14710
- daemonDisabled = false;
14711
- daemonDisabledReason = null;
14712
- stopDisabledRecoveryPoller();
14713
- reconnectToDaemon();
15650
+ const recoveredFrom = daemonDisabledReason;
15651
+ switch (recoveredFrom) {
15652
+ case "probe_in_progress": {
15653
+ if (disabledRecoveryAttempts >= DISABLED_RECOVERY_MAX_ATTEMPTS) {
15654
+ log(`Disabled-state auto-recovery gave up after ${DISABLED_RECOVERY_MAX_ATTEMPTS} attempts ` + "\u2014 switching to auto_recovery_exhausted terminal state");
15655
+ daemonDisabledReason = "auto_recovery_exhausted";
15656
+ disabledRecoveryAttempts = 0;
15657
+ stopDisabledRecoveryPoller();
15658
+ claude.pushNotification(systemMessage("system_bridge_auto_recovery_gave_up", `\u26A0\uFE0F AgentBridge auto-recovery gave up after exhausting its retry budget for the in-flight liveness probe contention. Retry manually with \`${pairScopedCommand("claude")}\`. AgentBridge \u81EA\u52A8\u6062\u590D\u5DF2\u653E\u5F03\u2014\u2014\u5B58\u6D3B\u63A2\u6D4B\u4E89\u7528\u7684\u91CD\u8BD5\u9884\u7B97\u5DF2\u7528\u5C3D\u3002\u8BF7\u4F7F\u7528 \`${pairScopedCommand("claude")}\` \u624B\u52A8\u91CD\u8BD5\u3002`));
15659
+ return;
15660
+ }
15661
+ disabledRecoveryAttempts += 1;
15662
+ log(`Disabled-state recovery attempt ${disabledRecoveryAttempts}/${DISABLED_RECOVERY_MAX_ATTEMPTS} ` + "for probe_in_progress \u2014 attempting direct daemon reconnect");
15663
+ try {
15664
+ await daemonClient.connect();
15665
+ const attached = await daemonClient.attachClaudeAndWaitForStatus(DISABLED_RECOVERY_CONFIRM_TIMEOUT_MS);
15666
+ if (!attached) {
15667
+ log(`Disabled-state probe_in_progress recovery attempt ${disabledRecoveryAttempts} did not confirm readiness`);
15668
+ await daemonClient.disconnect();
15669
+ return;
15670
+ }
15671
+ daemonDisabled = false;
15672
+ daemonDisabledReason = null;
15673
+ disabledRecoveryAttempts = 0;
15674
+ stopDisabledRecoveryPoller();
15675
+ claude.pushNotification(systemMessage("system_bridge_recovered", "\u2705 AgentBridge recovered after the liveness probe completed. Daemon reconnected."));
15676
+ } catch (err) {
15677
+ log(`Disabled-state probe_in_progress recovery attempt failed: ${err.message}`);
15678
+ await daemonClient.disconnect();
15679
+ }
15680
+ return;
15681
+ }
15682
+ case "killed": {
15683
+ log("Disabled-state recovery conditions met \u2014 attempting direct daemon reconnect");
15684
+ try {
15685
+ await daemonClient.connect();
15686
+ const attached = await daemonClient.attachClaudeAndWaitForStatus(DISABLED_RECOVERY_CONFIRM_TIMEOUT_MS);
15687
+ if (!attached) {
15688
+ throw new Error("daemon did not confirm reconnect");
15689
+ }
15690
+ daemonDisabled = false;
15691
+ daemonDisabledReason = null;
15692
+ disabledRecoveryAttempts = 0;
15693
+ stopDisabledRecoveryPoller();
15694
+ claude.pushNotification(systemMessage("system_bridge_recovered", "\u2705 AgentBridge recovered after the killed sentinel was cleared. Daemon reconnected."));
15695
+ } catch (err) {
15696
+ log(`Disabled-state direct reconnect failed: ${err.message}`);
15697
+ daemonDisabled = false;
15698
+ daemonDisabledReason = null;
15699
+ disabledRecoveryAttempts = 0;
15700
+ stopDisabledRecoveryPoller();
15701
+ reconnectToDaemon();
15702
+ }
15703
+ return;
15704
+ }
15705
+ case "evicted":
15706
+ case "rejected":
15707
+ case "auto_recovery_exhausted":
15708
+ case null:
15709
+ log(`Disabled-state recovery poller encountered terminal/unexpected reason ${recoveredFrom ?? "null"} \u2014 stopping`);
15710
+ stopDisabledRecoveryPoller();
15711
+ return;
15712
+ default: {
15713
+ const exhaustive = recoveredFrom;
15714
+ return exhaustive;
15715
+ }
14714
15716
  }
14715
15717
  } finally {
14716
15718
  disabledRecoveryInFlight = false;
@@ -14724,6 +15726,17 @@ function systemMessage(idPrefix, content) {
14724
15726
  timestamp: Date.now()
14725
15727
  };
14726
15728
  }
15729
+ function currentClientIdentity() {
15730
+ return {
15731
+ pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
15732
+ pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
15733
+ cwd: process.cwd(),
15734
+ baseDir: process.env.AGENTBRIDGE_BASE_DIR ?? null,
15735
+ stateDir: stateDir.dir,
15736
+ clientPid: process.pid,
15737
+ contractVersion: BUILD_INFO.contractVersion
15738
+ };
15739
+ }
14727
15740
  function shutdown(reason) {
14728
15741
  if (shuttingDown)
14729
15742
  return;
@@ -14749,18 +15762,13 @@ process.on("exit", () => {
14749
15762
  daemonClient.disconnect();
14750
15763
  });
14751
15764
  process.on("uncaughtException", (err) => {
14752
- log(`UNCAUGHT EXCEPTION: ${err.stack ?? err.message}`);
15765
+ processLogger.fatal("UNCAUGHT EXCEPTION", err);
14753
15766
  });
14754
15767
  process.on("unhandledRejection", (reason) => {
14755
- log(`UNHANDLED REJECTION: ${reason?.stack ?? reason}`);
15768
+ processLogger.fatal("UNHANDLED REJECTION", reason);
14756
15769
  });
14757
15770
  function log(msg) {
14758
- const line = `[${new Date().toISOString()}] [AgentBridgeFrontend] ${msg}
14759
- `;
14760
- process.stderr.write(line);
14761
- try {
14762
- appendFileSync2(stateDir.logFile, line);
14763
- } catch {}
15771
+ processLogger.log(msg);
14764
15772
  }
14765
15773
  log(`Starting AgentBridge frontend (daemon ws ${CONTROL_WS_URL})`);
14766
15774
  (async () => {