@raysonmeng/agentbridge 0.1.11 → 0.1.13

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 { existsSync as existsSync6 } from "fs";
6521
+ import { existsSync as existsSync7 } from "fs";
6522
6522
 
6523
6523
  // node_modules/zod/v4/core/core.js
6524
6524
  var NEVER = Object.freeze({
@@ -13662,19 +13662,21 @@ class StdioServerTransport {
13662
13662
  // src/claude-adapter.ts
13663
13663
  import { EventEmitter } from "events";
13664
13664
  import { randomUUID } from "crypto";
13665
+ import { performance } from "perf_hooks";
13665
13666
 
13666
13667
  // src/rotating-log.ts
13667
13668
  import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "fs";
13668
13669
  import { dirname } from "path";
13669
13670
  var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
13670
13671
  var DEFAULT_KEEP = 3;
13671
- function appendRotatingLog(path, content, options = {}) {
13672
+ var REAL_FS_OPS = { statSync, renameSync, unlinkSync, appendFileSync, existsSync };
13673
+ function appendRotatingLog(path, content, options = {}, fsOps = REAL_FS_OPS) {
13672
13674
  const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
13673
13675
  const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
13674
- if (!existsSync(dirname(path)))
13676
+ if (!fsOps.existsSync(dirname(path)))
13675
13677
  return;
13676
- rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
13677
- appendFileSync(path, content, "utf-8");
13678
+ rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep, fsOps);
13679
+ fsOps.appendFileSync(path, content, "utf-8");
13678
13680
  }
13679
13681
  function positiveIntFromEnv(name, fallback) {
13680
13682
  const value = process.env[name];
@@ -13683,26 +13685,48 @@ function positiveIntFromEnv(name, fallback) {
13683
13685
  const parsed = Number(value);
13684
13686
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
13685
13687
  }
13686
- function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
13688
+ function isEnoent(error2) {
13689
+ return !!error2 && error2.code === "ENOENT";
13690
+ }
13691
+ function renameIfPresent(from, to, fsOps) {
13692
+ try {
13693
+ fsOps.renameSync(from, to);
13694
+ } catch (error2) {
13695
+ if (!isEnoent(error2))
13696
+ throw error2;
13697
+ }
13698
+ }
13699
+ function unlinkIfPresent(path, fsOps) {
13700
+ try {
13701
+ fsOps.unlinkSync(path);
13702
+ } catch (error2) {
13703
+ if (!isEnoent(error2))
13704
+ throw error2;
13705
+ }
13706
+ }
13707
+ function rotateIfNeeded(path, incomingBytes, maxBytes, keep, fsOps) {
13687
13708
  if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
13688
13709
  return;
13689
- if (!existsSync(path))
13690
- return;
13691
- const size = statSync(path).size;
13710
+ let size;
13711
+ try {
13712
+ size = fsOps.statSync(path).size;
13713
+ } catch (error2) {
13714
+ if (isEnoent(error2))
13715
+ return;
13716
+ throw error2;
13717
+ }
13692
13718
  if (size + incomingBytes <= maxBytes)
13693
13719
  return;
13694
13720
  for (let index = keep;index >= 1; index--) {
13695
13721
  const current = `${path}.${index}`;
13696
13722
  const next = `${path}.${index + 1}`;
13697
- if (!existsSync(current))
13698
- continue;
13699
13723
  if (index === keep) {
13700
- unlinkSync(current);
13724
+ unlinkIfPresent(current, fsOps);
13701
13725
  } else {
13702
- renameSync(current, next);
13726
+ renameIfPresent(current, next, fsOps);
13703
13727
  }
13704
13728
  }
13705
- renameSync(path, `${path}.1`);
13729
+ renameIfPresent(path, `${path}.1`, fsOps);
13706
13730
  }
13707
13731
 
13708
13732
  // src/process-log.ts
@@ -13782,6 +13806,10 @@ function formatError2(error2) {
13782
13806
  import { mkdirSync, existsSync as existsSync2 } from "fs";
13783
13807
  import { join } from "path";
13784
13808
  import { homedir, platform } from "os";
13809
+ function resolveXdgStateBase(rawXdg = process.env.XDG_STATE_HOME) {
13810
+ const xdgState = rawXdg && rawXdg.length > 0 ? rawXdg : join(homedir(), ".local", "state");
13811
+ return join(xdgState, "agentbridge");
13812
+ }
13785
13813
 
13786
13814
  class StateDirResolver {
13787
13815
  stateDir;
@@ -13789,8 +13817,7 @@ class StateDirResolver {
13789
13817
  if (platform() === "darwin") {
13790
13818
  return join(homedir(), "Library", "Application Support", "AgentBridge");
13791
13819
  }
13792
- const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
13793
- return join(xdgState, "agentbridge");
13820
+ return resolveXdgStateBase(process.env.XDG_STATE_HOME);
13794
13821
  }
13795
13822
  constructor(envOverride) {
13796
13823
  const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
@@ -13816,8 +13843,8 @@ class StateDirResolver {
13816
13843
  get statusFile() {
13817
13844
  return join(this.stateDir, "status.json");
13818
13845
  }
13819
- get portsFile() {
13820
- return join(this.stateDir, "ports.json");
13846
+ get daemonRecordFile() {
13847
+ return join(this.stateDir, "daemon.json");
13821
13848
  }
13822
13849
  get currentThreadFile() {
13823
13850
  return join(this.stateDir, "current-thread.json");
@@ -13859,6 +13886,9 @@ function formatAgent(name, usage, snapshotAt) {
13859
13886
  if (usage.rateLimitedUntil > 0) {
13860
13887
  parts.push(`\u9650\u6D41\u81F3 ${formatEpoch(usage.rateLimitedUntil)}`);
13861
13888
  }
13889
+ if (usage.parsedVia === "positional") {
13890
+ parts.push("\u26A0\uFE0F \u7A97\u53E3\u8BC6\u522B\u4F7F\u7528\u4F4D\u7F6E\u515C\u5E95");
13891
+ }
13862
13892
  const ageSec = usage.fetchedAt > 0 ? snapshotAt - usage.fetchedAt : 0;
13863
13893
  if (ageSec > 300) {
13864
13894
  parts.push(`\u26A0\uFE0F \u6570\u636E\u91C7\u96C6\u4E8E ${Math.round(ageSec / 60)} \u5206\u949F\u524D`);
@@ -13917,6 +13947,10 @@ function renderBudgetSnapshot(snapshot) {
13917
13947
  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";
13918
13948
 
13919
13949
  // src/claude-adapter.ts
13950
+ var DEFAULT_MAX_BUFFERED_MESSAGES = 100;
13951
+ var DEFAULT_MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
13952
+ var DEFAULT_DEDUPE_CAPACITY = 2048;
13953
+ var DEFAULT_DEDUPE_TTL_MS = 20 * 60 * 1000;
13920
13954
  var CLAUDE_INSTRUCTIONS = [
13921
13955
  "Codex is an AI coding agent (OpenAI) running in a separate session on the same machine.",
13922
13956
  "",
@@ -13947,7 +13981,7 @@ var CLAUDE_INSTRUCTIONS = [
13947
13981
  "## Turn coordination",
13948
13982
  "- When you see '\u23F3 Codex is working', do NOT call the reply tool \u2014 wait for '\u2705 Codex finished'.",
13949
13983
  "- After Codex finishes a turn, you have an attention window to review and respond before new messages arrive.",
13950
- '- If the reply tool returns a busy error, Codex is still executing. You decide: wait and retry later, or resend with on_busy="steer" to feed the message INTO the running turn (good for mid-course corrections; it does not interrupt or restart the work).',
13984
+ '- If the reply tool returns a busy error, Codex is still executing. You decide: wait and retry later, resend with on_busy="steer" to feed the message INTO the running turn (good for mid-course corrections; it does not interrupt or restart the work), or resend with on_busy="interrupt" to STOP the running turn and start a new one with your message (use only when the current work is obsolete \u2014 prefer steer otherwise).',
13951
13985
  "",
13952
13986
  "## Budget awareness",
13953
13987
  "- Use the get_budget tool to check both agents' subscription quota (5h/weekly windows, drift, pause state).",
@@ -13965,10 +13999,20 @@ class ClaudeAdapter extends EventEmitter {
13965
13999
  logFile;
13966
14000
  logger;
13967
14001
  pendingMessages = [];
14002
+ pendingMessageByteSizes = [];
14003
+ pendingMessageBytes = 0;
13968
14004
  maxBufferedMessages;
14005
+ maxBufferedBytes;
13969
14006
  droppedMessageCount = 0;
14007
+ oversizedMessageCount = 0;
14008
+ oversizedMessageBytes = 0;
14009
+ oversizedMessageSourceCounts = {};
14010
+ dedupeCapacity;
14011
+ dedupeTtlMs;
14012
+ monotonicNow;
14013
+ deliveredMessageIds = new Map;
13970
14014
  budgetSnapshot = null;
13971
- constructor(logFile = new StateDirResolver().logFile) {
14015
+ constructor(logFile = new StateDirResolver().logFile, options = {}) {
13972
14016
  super();
13973
14017
  this.logFile = logFile;
13974
14018
  this.logger = createProcessLogger({ component: "ClaudeAdapter", logFile: this.logFile });
@@ -13979,7 +14023,11 @@ class ClaudeAdapter extends EventEmitter {
13979
14023
  if (process.env.AGENTBRIDGE_MODE) {
13980
14024
  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
14025
  }
13982
- this.maxBufferedMessages = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
14026
+ this.maxBufferedMessages = positiveIntegerOr(options.maxBufferedMessages, parsePositiveIntegerEnv("AGENTBRIDGE_MAX_BUFFERED_MESSAGES", DEFAULT_MAX_BUFFERED_MESSAGES));
14027
+ this.maxBufferedBytes = positiveIntegerOr(options.maxBufferedBytes, parsePositiveIntegerEnv("AGENTBRIDGE_MAX_BUFFERED_BYTES", DEFAULT_MAX_BUFFERED_BYTES));
14028
+ this.dedupeCapacity = positiveIntegerOr(options.dedupeCapacity, DEFAULT_DEDUPE_CAPACITY);
14029
+ this.dedupeTtlMs = positiveIntegerOr(options.dedupeTtlMs, DEFAULT_DEDUPE_TTL_MS);
14030
+ this.monotonicNow = options.now ?? (() => performance.now());
13983
14031
  this.server = new Server({ name: "agentbridge", version: "0.1.0" }, {
13984
14032
  capabilities: {
13985
14033
  experimental: { "claude/channel": {} },
@@ -14006,10 +14054,12 @@ class ClaudeAdapter extends EventEmitter {
14006
14054
  }
14007
14055
  async pushNotification(message) {
14008
14056
  this.log(`pushNotification (instance=${this.instanceId}, msgId=${message.id}, len=${message.content.length})`);
14057
+ if (!this.rememberDelivery(message))
14058
+ return;
14009
14059
  await this.pushViaChannel(message);
14010
14060
  }
14011
14061
  async pushViaChannel(message) {
14012
- const msgId = `codex_msg_${this.notificationIdPrefix}_${++this.notificationSeq}`;
14062
+ const deliveryAttemptId = `codex_msg_${this.notificationIdPrefix}_${++this.notificationSeq}`;
14013
14063
  const ts = new Date(message.timestamp).toISOString();
14014
14064
  try {
14015
14065
  await this.server.notification({
@@ -14018,7 +14068,8 @@ class ClaudeAdapter extends EventEmitter {
14018
14068
  content: message.content,
14019
14069
  meta: {
14020
14070
  chat_id: this.sessionId,
14021
- message_id: msgId,
14071
+ message_id: message.id,
14072
+ delivery_attempt_id: deliveryAttemptId,
14022
14073
  user: "Codex",
14023
14074
  user_id: "codex",
14024
14075
  ts,
@@ -14026,39 +14077,93 @@ class ClaudeAdapter extends EventEmitter {
14026
14077
  }
14027
14078
  }
14028
14079
  });
14029
- this.log(`Pushed notification: ${msgId}`);
14080
+ this.log(`Pushed notification: ${message.id} (attempt=${deliveryAttemptId})`);
14030
14081
  } catch (e) {
14031
14082
  this.log(`Push notification failed: ${e.message}`);
14032
14083
  this.queueFallbackMessage(message);
14033
14084
  }
14034
14085
  }
14086
+ rememberDelivery(message) {
14087
+ const now = this.monotonicNow();
14088
+ this.pruneDeliveredMessageIds(now);
14089
+ if (this.deliveredMessageIds.has(message.id)) {
14090
+ this.deliveredMessageIds.delete(message.id);
14091
+ this.deliveredMessageIds.set(message.id, now);
14092
+ this.log(`Duplicate Codex message suppressed (msgId=${message.id}, source=${message.source}, ` + `instance=${this.instanceId})`);
14093
+ return false;
14094
+ }
14095
+ this.deliveredMessageIds.set(message.id, now);
14096
+ while (this.deliveredMessageIds.size > this.dedupeCapacity) {
14097
+ const oldest = this.deliveredMessageIds.keys().next().value;
14098
+ if (oldest === undefined)
14099
+ break;
14100
+ this.deliveredMessageIds.delete(oldest);
14101
+ }
14102
+ return true;
14103
+ }
14104
+ pruneDeliveredMessageIds(now) {
14105
+ for (const [id, seenAt] of this.deliveredMessageIds) {
14106
+ if (now - seenAt <= this.dedupeTtlMs)
14107
+ break;
14108
+ this.deliveredMessageIds.delete(id);
14109
+ }
14110
+ }
14035
14111
  queueFallbackMessage(message) {
14036
- if (this.pendingMessages.length >= this.maxBufferedMessages) {
14037
- this.pendingMessages.shift();
14112
+ const messageBytes = utf8ByteLength(message.content);
14113
+ if (messageBytes > this.maxBufferedBytes) {
14114
+ this.oversizedMessageCount++;
14115
+ this.oversizedMessageBytes += messageBytes;
14116
+ this.oversizedMessageSourceCounts[message.source] = (this.oversizedMessageSourceCounts[message.source] ?? 0) + 1;
14117
+ this.log(`Fallback queue omitted oversized ${message.source} message ` + `(${formatBytes(messageBytes)} > ${formatBytes(this.maxBufferedBytes)}; ` + `total oversized: ${this.oversizedMessageCount})`);
14118
+ return;
14119
+ }
14120
+ let dropped = 0;
14121
+ while (this.pendingMessages.length >= this.maxBufferedMessages || this.pendingMessageBytes + messageBytes > this.maxBufferedBytes) {
14122
+ const droppedMessage = this.pendingMessages.shift();
14123
+ const droppedBytes = this.pendingMessageByteSizes.shift() ?? 0;
14124
+ if (!droppedMessage)
14125
+ break;
14126
+ this.pendingMessageBytes = Math.max(0, this.pendingMessageBytes - droppedBytes);
14038
14127
  this.droppedMessageCount++;
14039
- this.log(`Fallback queue full, dropped oldest message (total dropped: ${this.droppedMessageCount})`);
14128
+ dropped++;
14129
+ }
14130
+ if (dropped > 0) {
14131
+ this.log(`Fallback queue overflow: dropped ${dropped} oldest message${dropped > 1 ? "s" : ""} ` + `(${this.pendingMessages.length} pending, ${formatBytes(this.pendingMessageBytes)} buffered, ` + `${this.droppedMessageCount} dropped since last drain)`);
14040
14132
  }
14041
14133
  this.pendingMessages.push(message);
14042
- this.log(`Queued fallback message (${this.pendingMessages.length} pending, instance=${this.instanceId})`);
14134
+ this.pendingMessageByteSizes.push(messageBytes);
14135
+ this.pendingMessageBytes += messageBytes;
14136
+ this.log(`Queued fallback message (${this.pendingMessages.length} pending, ` + `${formatBytes(this.pendingMessageBytes)} buffered, instance=${this.instanceId})`);
14043
14137
  }
14044
14138
  drainMessages() {
14045
- this.log(`get_messages called (instance=${this.instanceId}, pending=${this.pendingMessages.length}, dropped=${this.droppedMessageCount})`);
14046
- if (this.pendingMessages.length === 0 && this.droppedMessageCount === 0) {
14139
+ this.log(`get_messages called (instance=${this.instanceId}, pending=${this.pendingMessages.length}, ` + `bytes=${this.pendingMessageBytes}, dropped=${this.droppedMessageCount}, oversized=${this.oversizedMessageCount})`);
14140
+ if (this.pendingMessages.length === 0 && this.droppedMessageCount === 0 && this.oversizedMessageCount === 0) {
14047
14141
  return {
14048
14142
  content: [{ type: "text", text: "No new messages from Codex." }]
14049
14143
  };
14050
14144
  }
14051
14145
  const messages = this.pendingMessages;
14052
14146
  this.pendingMessages = [];
14147
+ this.pendingMessageByteSizes = [];
14148
+ this.pendingMessageBytes = 0;
14053
14149
  const dropped = this.droppedMessageCount;
14054
14150
  this.droppedMessageCount = 0;
14151
+ const oversizedSourceCounts = this.oversizedMessageSourceCounts;
14152
+ const oversized = this.oversizedMessageCount;
14153
+ const oversizedBytes = this.oversizedMessageBytes;
14154
+ this.oversizedMessageSourceCounts = {};
14155
+ this.oversizedMessageCount = 0;
14156
+ this.oversizedMessageBytes = 0;
14055
14157
  const count = messages.length;
14056
- let header = `[${count} new message${count > 1 ? "s" : ""} from Codex]`;
14158
+ const notices = [];
14057
14159
  if (dropped > 0) {
14058
- header += ` (${dropped} older message${dropped > 1 ? "s" : ""} were dropped due to queue overflow)`;
14160
+ notices.push(`${dropped} older message${dropped > 1 ? "s" : ""} ` + `${dropped > 1 ? "were" : "was"} dropped due to fallback queue overflow`);
14161
+ }
14162
+ if (oversized > 0) {
14163
+ for (const [source, sourceCount] of Object.entries(oversizedSourceCounts)) {
14164
+ notices.push(`${sourceCount} oversized message${sourceCount === 1 ? "" : "s"} ` + `from ${formatSource(source)} omitted ` + `(>${formatBytes(this.maxBufferedBytes)})`);
14165
+ }
14059
14166
  }
14060
- header += `
14061
- chat_id: ${this.sessionId}`;
14062
14167
  const formatted = messages.map((msg, i) => {
14063
14168
  const ts = new Date(msg.timestamp).toISOString();
14064
14169
  return `---
@@ -14067,14 +14172,25 @@ Codex: ${msg.content}`;
14067
14172
  }).join(`
14068
14173
 
14069
14174
  `);
14070
- this.log(`get_messages returning ${count} message(s) (instance=${this.instanceId}, dropped=${dropped})`);
14175
+ const noticeText = notices.map((notice) => `WARNING: ${notice}`).join(`
14176
+ `);
14177
+ const parts = [];
14178
+ if (count > 0) {
14179
+ parts.push(`[${count} new message${count > 1 ? "s" : ""} from Codex]
14180
+ chat_id: ${this.sessionId}`);
14181
+ }
14182
+ if (noticeText)
14183
+ parts.push(noticeText);
14184
+ if (formatted)
14185
+ parts.push(formatted);
14186
+ this.log(`get_messages returning ${count} message(s) ` + `(instance=${this.instanceId}, dropped=${dropped}, oversized=${oversized}, oversizedBytes=${oversizedBytes})`);
14071
14187
  return {
14072
14188
  content: [
14073
14189
  {
14074
14190
  type: "text",
14075
- text: `${header}
14191
+ text: parts.join(`
14076
14192
 
14077
- ${formatted}`
14193
+ `)
14078
14194
  }
14079
14195
  ]
14080
14196
  };
@@ -14098,12 +14214,16 @@ ${formatted}`
14098
14214
  },
14099
14215
  require_reply: {
14100
14216
  type: "boolean",
14101
- description: "When true, Codex is required to send a reply. All Codex messages from this turn will be forwarded immediately (bypassing STATUS buffering). Use this when you need a direct answer from Codex."
14217
+ description: 'When true, Codex is required to send a reply. All Codex messages from this turn will be forwarded immediately (bypassing STATUS buffering). Use this when you need a direct answer from Codex. Combinable with on_busy="steer": the reply expectation arms once the steer is accepted into the running turn.'
14102
14218
  },
14103
14219
  on_busy: {
14104
14220
  type: "string",
14105
- enum: ["reject", "steer"],
14106
- description: `What to do when Codex is mid-turn. "reject" (default): fail with a busy error \u2014 wait and retry. "steer": feed this message INTO the running turn \u2014 Codex sees it immediately and integrates it without losing work. Use steer for mid-course corrections, added constraints, or updated acceptance criteria; it does NOT start a new turn, so don't combine it with require_reply. If you need Codex to STOP and do something else, wait for the turn to finish (interrupt support is coming separately).`
14221
+ enum: ["reject", "steer", "interrupt"],
14222
+ description: 'What to do when Codex is mid-turn. "reject" (default): fail with a busy error \u2014 wait and retry. "steer": feed this message INTO the running turn \u2014 Codex sees it immediately and integrates it without losing work; use it for mid-course corrections, added constraints, or updated acceptance criteria (it does NOT start a new turn). "interrupt": STOP the running turn, wait for it to terminate, then send this message as a NEW turn \u2014 use only when the current work is obsolete; prefer steer otherwise.'
14223
+ },
14224
+ idempotency_key: {
14225
+ type: "string",
14226
+ description: "Optional client-generated key (non-empty, max 128 chars) that makes this reply idempotent: a retry carrying the same key is NOT re-injected \u2014 the bridge answers duplicate_in_flight / duplicate_terminal instead. Use a fresh key per logical message."
14107
14227
  }
14108
14228
  },
14109
14229
  required: ["text"]
@@ -14163,19 +14283,29 @@ ${formatted}`
14163
14283
  }
14164
14284
  const requireReply = args?.require_reply === true;
14165
14285
  const onBusyRaw = args?.on_busy;
14166
- const onBusy = onBusyRaw === "steer" ? "steer" : "reject";
14167
- if (onBusyRaw !== undefined && onBusyRaw !== "reject" && onBusyRaw !== "steer") {
14286
+ if (onBusyRaw !== undefined && onBusyRaw !== "reject" && onBusyRaw !== "steer" && onBusyRaw !== "interrupt") {
14168
14287
  return {
14169
- content: [{ type: "text", text: `Error: invalid on_busy value ${JSON.stringify(onBusyRaw)} \u2014 use "reject" or "steer".` }],
14288
+ content: [{ type: "text", text: `Error: invalid on_busy value ${JSON.stringify(onBusyRaw)} \u2014 use "reject", "steer" or "interrupt".` }],
14170
14289
  isError: true
14171
14290
  };
14172
14291
  }
14173
- if (onBusy === "steer" && requireReply) {
14174
- return {
14175
- content: [{ type: "text", text: 'Error: require_reply cannot be combined with on_busy="steer" yet \u2014 a steer joins the RUNNING turn instead of starting a new one, so reply tracking would mis-arm. Send the steer without require_reply.' }],
14176
- isError: true
14177
- };
14292
+ const onBusy = onBusyRaw === "steer" || onBusyRaw === "interrupt" ? onBusyRaw : "reject";
14293
+ const idempotencyKeyRaw = args?.idempotency_key;
14294
+ if (idempotencyKeyRaw !== undefined) {
14295
+ if (typeof idempotencyKeyRaw !== "string" || idempotencyKeyRaw.length === 0) {
14296
+ return {
14297
+ content: [{ type: "text", text: "Error: idempotency_key must be a non-empty string." }],
14298
+ isError: true
14299
+ };
14300
+ }
14301
+ if (idempotencyKeyRaw.length > 128) {
14302
+ return {
14303
+ content: [{ type: "text", text: `Error: idempotency_key is too long (${idempotencyKeyRaw.length} chars, max 128).` }],
14304
+ isError: true
14305
+ };
14306
+ }
14178
14307
  }
14308
+ const idempotencyKey = idempotencyKeyRaw;
14179
14309
  const bridgeMsg = {
14180
14310
  id: args?.chat_id ?? `reply_${Date.now()}`,
14181
14311
  source: "claude",
@@ -14189,16 +14319,22 @@ ${formatted}`
14189
14319
  isError: true
14190
14320
  };
14191
14321
  }
14192
- const result = await this.replySender(bridgeMsg, requireReply, onBusy);
14322
+ const result = await this.replySender(bridgeMsg, requireReply, onBusy, idempotencyKey);
14193
14323
  if (!result.success) {
14194
- this.log(`Reply delivery failed: ${result.error}`);
14324
+ this.log(`Reply delivery failed: ${result.error}${result.code ? ` (code=${result.code})` : ""}`);
14325
+ const codePrefix = result.code ? ` [${result.code}]` : "";
14195
14326
  return {
14196
- content: [{ type: "text", text: `Error: ${result.error}` }],
14327
+ content: [{ type: "text", text: `Error${codePrefix}: ${result.error}` }],
14197
14328
  isError: true
14198
14329
  };
14199
14330
  }
14200
14331
  const pending = this.pendingMessages.length;
14201
- let responseText = onBusy === "steer" ? "Reply sent to Codex (will be steered into the running turn if one is active; watch for a system_steer_failed notice if the app-server rejects it)." : "Reply sent to Codex.";
14332
+ let responseText = "Reply sent to Codex.";
14333
+ if (onBusy === "steer") {
14334
+ responseText = "Reply sent to Codex (will be steered into the running turn if one is active; watch for a system_steer_failed notice if the app-server rejects it).";
14335
+ } else if (onBusy === "interrupt") {
14336
+ responseText = "Reply sent to Codex as a new turn (any turn still running was interrupted first; if it had already finished, your message was simply injected).";
14337
+ }
14202
14338
  if (pending > 0) {
14203
14339
  responseText += ` Note: ${pending} unread Codex message${pending > 1 ? "s" : ""} already waiting \u2014 call get_messages to read them.`;
14204
14340
  }
@@ -14210,11 +14346,37 @@ ${formatted}`
14210
14346
  this.logger.log(msg);
14211
14347
  }
14212
14348
  }
14349
+ function parsePositiveIntegerEnv(name, fallback) {
14350
+ return positiveIntegerOr(parseInt(process.env[name] ?? "", 10), fallback);
14351
+ }
14352
+ function positiveIntegerOr(value, fallback) {
14353
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
14354
+ }
14355
+ function utf8ByteLength(value) {
14356
+ return Buffer.byteLength(value, "utf8");
14357
+ }
14358
+ function formatSource(source) {
14359
+ return source === "codex" ? "Codex" : "Claude";
14360
+ }
14361
+ function formatBytes(bytes) {
14362
+ if (bytes < 1024)
14363
+ return `${bytes}B`;
14364
+ if (bytes % (1024 * 1024) === 0)
14365
+ return `${bytes / (1024 * 1024)}MiB`;
14366
+ if (bytes % 1024 === 0)
14367
+ return `${bytes / 1024}KiB`;
14368
+ return `${bytes}B`;
14369
+ }
14213
14370
 
14214
14371
  // src/contract-version.ts
14215
14372
  var CONTRACT_VERSION = 1;
14216
14373
 
14217
14374
  // src/build-info.ts
14375
+ var CODE_HASH_SENTINEL = "source";
14376
+ function hasValidCodeHash(build) {
14377
+ const hash = build?.codeHash;
14378
+ return typeof hash === "string" && hash.length > 0 && hash !== CODE_HASH_SENTINEL;
14379
+ }
14218
14380
  function defineString(value, fallback) {
14219
14381
  return typeof value === "string" && value.length > 0 ? value : fallback;
14220
14382
  }
@@ -14227,15 +14389,23 @@ function defineNumber(value, fallback) {
14227
14389
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
14228
14390
  }
14229
14391
  var BUILD_INFO = Object.freeze({
14230
- version: defineString("0.1.11", "0.0.0-source"),
14231
- commit: defineString("48eb0ed", "source"),
14392
+ version: defineString("0.1.13", "0.0.0-source"),
14393
+ commit: defineString("7a71869", "source"),
14232
14394
  bundle: defineBundle("plugin"),
14233
- contractVersion: defineNumber(1, CONTRACT_VERSION)
14395
+ contractVersion: defineNumber(1, CONTRACT_VERSION),
14396
+ codeHash: defineString("e1fd67d07c62", "source")
14234
14397
  });
14235
14398
  function sameRuntimeContract(a, b) {
14236
14399
  if (!a || !b)
14237
14400
  return false;
14238
- return a.version === b.version && a.commit === b.commit && a.contractVersion === b.contractVersion;
14401
+ if (a.version !== b.version || a.contractVersion !== b.contractVersion)
14402
+ return false;
14403
+ if (hasValidCodeHash(a) && hasValidCodeHash(b))
14404
+ return a.codeHash === b.codeHash;
14405
+ return a.commit === b.commit;
14406
+ }
14407
+ function runtimeContractComparisonBasis(a, b) {
14408
+ return hasValidCodeHash(a) && hasValidCodeHash(b) ? "codeHash" : "commit";
14239
14409
  }
14240
14410
  function compatibleContractVersion(a, b) {
14241
14411
  if (!a || !b)
@@ -14245,7 +14415,8 @@ function compatibleContractVersion(a, b) {
14245
14415
  function formatBuildInfo(build) {
14246
14416
  if (!build)
14247
14417
  return "<unknown>";
14248
- return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}`;
14418
+ const codeHash = hasValidCodeHash(build) ? `/code-${build.codeHash}` : "";
14419
+ return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}${codeHash}`;
14249
14420
  }
14250
14421
 
14251
14422
  // src/daemon-client.ts
@@ -14256,6 +14427,83 @@ var CLOSE_CODE_REPLACED = 4001;
14256
14427
  var CLOSE_CODE_EVICTED_STALE = 4002;
14257
14428
  var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
14258
14429
  var CLOSE_CODE_PAIR_MISMATCH = 4004;
14430
+ var CLOSE_CODE_TOKEN_MISMATCH = 4005;
14431
+ var CLOSE_CODE_CONTRACT_MISMATCH = 4006;
14432
+
14433
+ // src/interrupt-timing.ts
14434
+ var CLIENT_REPLY_TIMEOUT_MS = 15000;
14435
+ var INTERRUPT_CLIENT_MARGIN_MS = 2000;
14436
+ var MAX_INTERRUPT_TIMEOUT_MS = CLIENT_REPLY_TIMEOUT_MS - INTERRUPT_CLIENT_MARGIN_MS;
14437
+
14438
+ // src/pending-request-registry.ts
14439
+ class PendingRequestRegistry {
14440
+ entries = new Map;
14441
+ setTimer;
14442
+ clearTimer;
14443
+ constructor(deps = {}) {
14444
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
14445
+ this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
14446
+ }
14447
+ get size() {
14448
+ return this.entries.size;
14449
+ }
14450
+ has(id) {
14451
+ return this.entries.has(id);
14452
+ }
14453
+ register(id, options) {
14454
+ const existing = this.entries.get(id);
14455
+ if (existing) {
14456
+ this.clearTimer(existing.timer);
14457
+ this.entries.delete(id);
14458
+ }
14459
+ return new Promise((resolve, reject) => {
14460
+ const timer = this.setTimer(() => {
14461
+ if (!this.entries.has(id))
14462
+ return;
14463
+ this.entries.delete(id);
14464
+ options.onTimeout({ resolve, reject });
14465
+ }, options.timeoutMs);
14466
+ if (options.unref) {
14467
+ timer.unref?.();
14468
+ }
14469
+ this.entries.set(id, { resolve, reject, timer });
14470
+ });
14471
+ }
14472
+ settle(id, value) {
14473
+ const entry = this.entries.get(id);
14474
+ if (!entry)
14475
+ return false;
14476
+ this.clearTimer(entry.timer);
14477
+ this.entries.delete(id);
14478
+ entry.resolve(value);
14479
+ return true;
14480
+ }
14481
+ reject(id, error2) {
14482
+ const entry = this.entries.get(id);
14483
+ if (!entry)
14484
+ return false;
14485
+ this.clearTimer(entry.timer);
14486
+ this.entries.delete(id);
14487
+ entry.reject(error2);
14488
+ return true;
14489
+ }
14490
+ settleAll(value) {
14491
+ const make = typeof value === "function" ? value : () => value;
14492
+ for (const [id, entry] of this.entries) {
14493
+ this.clearTimer(entry.timer);
14494
+ this.entries.delete(id);
14495
+ entry.resolve(make(id));
14496
+ }
14497
+ }
14498
+ rejectAll(error2) {
14499
+ const make = typeof error2 === "function" ? error2 : () => error2;
14500
+ for (const [id, entry] of this.entries) {
14501
+ this.clearTimer(entry.timer);
14502
+ this.entries.delete(id);
14503
+ entry.reject(make(id));
14504
+ }
14505
+ }
14506
+ }
14259
14507
 
14260
14508
  // src/daemon-client.ts
14261
14509
  var nextSocketId = 0;
@@ -14266,7 +14514,8 @@ class DaemonClient extends EventEmitter2 {
14266
14514
  ws = null;
14267
14515
  wsId = 0;
14268
14516
  nextRequestId = 1;
14269
- pendingReplies = new Map;
14517
+ pendingReplies = new PendingRequestRegistry;
14518
+ pendingEventWaiters = new PendingRequestRegistry;
14270
14519
  constructor(url, options = {}) {
14271
14520
  super();
14272
14521
  this.url = url;
@@ -14312,81 +14561,72 @@ class DaemonClient extends EventEmitter2 {
14312
14561
  });
14313
14562
  }
14314
14563
  attachClaude() {
14564
+ const identity = this.resolveIdentity();
14315
14565
  this.send({
14316
14566
  type: "claude_connect",
14317
- ...this.options.identity ? { identity: this.options.identity } : {}
14567
+ ...identity ? { identity } : {}
14318
14568
  });
14319
14569
  }
14570
+ resolveIdentity() {
14571
+ const opt = this.options.identity;
14572
+ return typeof opt === "function" ? opt() : opt;
14573
+ }
14320
14574
  async attachClaudeAndWaitForStatus(timeoutMs = 1000) {
14321
14575
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14322
14576
  return null;
14323
14577
  }
14324
- return await new Promise((resolve) => {
14325
- let settled = false;
14326
- let timer = null;
14327
- const cleanup = () => {
14328
- if (settled)
14329
- return;
14330
- settled = true;
14331
- if (timer) {
14332
- clearTimeout(timer);
14333
- timer = null;
14334
- }
14335
- this.off("status", onStatus);
14336
- this.off("rejected", onRejected);
14337
- this.off("disconnect", onDisconnect);
14338
- };
14339
- const finish = (value) => {
14340
- cleanup();
14341
- resolve(value);
14342
- };
14343
- const onStatus = (status) => finish(status);
14344
- const onRejected = () => finish(null);
14345
- const onDisconnect = () => finish(null);
14346
- this.on("status", onStatus);
14347
- this.on("rejected", onRejected);
14348
- this.on("disconnect", onDisconnect);
14349
- timer = setTimeout(() => {
14350
- finish(null);
14351
- }, timeoutMs);
14352
- try {
14353
- this.attachClaude();
14354
- } catch {
14355
- finish(null);
14356
- }
14578
+ return this.awaitTypedResponse({
14579
+ key: "status",
14580
+ successEvent: "status",
14581
+ successValue: (status) => status,
14582
+ failValue: null,
14583
+ timeoutMs,
14584
+ send: () => this.attachClaude()
14357
14585
  });
14358
14586
  }
14359
14587
  async probeIncumbent(timeoutMs = 3000) {
14360
14588
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14361
14589
  return { connected: false, alive: false };
14362
14590
  }
14363
- return await new Promise((resolve) => {
14364
- let settled = false;
14365
- let timer = null;
14366
- const finish = (value) => {
14367
- if (settled)
14368
- return;
14369
- settled = true;
14370
- if (timer)
14371
- clearTimeout(timer);
14372
- this.off("incumbentStatus", onStatus);
14373
- this.off("disconnect", onDisconnect);
14374
- this.off("rejected", onRejected);
14375
- resolve(value);
14376
- };
14377
- const onStatus = (s) => finish(s);
14378
- const onDisconnect = () => finish({ connected: false, alive: false });
14379
- const onRejected = () => finish({ connected: false, alive: false });
14380
- this.on("incumbentStatus", onStatus);
14381
- this.on("disconnect", onDisconnect);
14382
- this.on("rejected", onRejected);
14383
- timer = setTimeout(() => finish({ connected: false, alive: false }), timeoutMs);
14384
- try {
14385
- this.send({ type: "probe_incumbent" });
14386
- } catch {
14387
- finish({ connected: false, alive: false });
14388
- }
14591
+ return this.awaitTypedResponse({
14592
+ key: "incumbent_status",
14593
+ successEvent: "incumbentStatus",
14594
+ successValue: (s) => s,
14595
+ failValue: { connected: false, alive: false },
14596
+ timeoutMs,
14597
+ send: () => this.send({ type: "probe_incumbent" })
14598
+ });
14599
+ }
14600
+ awaitTypedResponse(opts) {
14601
+ const { key, successEvent, successValue, failValue, timeoutMs, send } = opts;
14602
+ const onSuccess = (payload) => {
14603
+ this.pendingEventWaiters.settle(key, successValue(payload));
14604
+ };
14605
+ const onRejected = () => {
14606
+ this.pendingEventWaiters.settle(key, failValue);
14607
+ };
14608
+ const onDisconnect = () => {
14609
+ this.pendingEventWaiters.settle(key, failValue);
14610
+ };
14611
+ const pending = this.pendingEventWaiters.register(key, {
14612
+ timeoutMs,
14613
+ onTimeout: ({ resolve }) => resolve(failValue)
14389
14614
  });
14615
+ const cleanup = () => {
14616
+ this.off(successEvent, onSuccess);
14617
+ this.off("rejected", onRejected);
14618
+ this.off("disconnect", onDisconnect);
14619
+ };
14620
+ pending.finally(cleanup);
14621
+ this.on(successEvent, onSuccess);
14622
+ this.on("rejected", onRejected);
14623
+ this.on("disconnect", onDisconnect);
14624
+ try {
14625
+ send();
14626
+ } catch {
14627
+ this.pendingEventWaiters.settle(key, failValue);
14628
+ }
14629
+ return pending;
14390
14630
  }
14391
14631
  async disconnect() {
14392
14632
  if (!this.ws)
@@ -14400,25 +14640,24 @@ class DaemonClient extends EventEmitter2 {
14400
14640
  this.ws = null;
14401
14641
  this.rejectPendingReplies("Daemon connection closed");
14402
14642
  }
14403
- async sendReply(message, requireReply, onBusy) {
14643
+ async sendReply(message, requireReply, onBusy, idempotencyKey) {
14404
14644
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14405
14645
  return { success: false, error: "AgentBridge daemon is not connected." };
14406
14646
  }
14407
14647
  const requestId = `reply_${Date.now()}_${this.nextRequestId++}`;
14408
- return new Promise((resolve) => {
14409
- const timer = setTimeout(() => {
14410
- this.pendingReplies.delete(requestId);
14411
- resolve({ success: false, error: "Timed out waiting for AgentBridge daemon reply." });
14412
- }, 15000);
14413
- this.pendingReplies.set(requestId, { resolve, timer });
14414
- this.send({
14415
- type: "claude_to_codex",
14416
- requestId,
14417
- message,
14418
- ...requireReply ? { requireReply: true } : {},
14419
- ...onBusy && onBusy !== "reject" ? { onBusy } : {}
14420
- });
14648
+ const pending = this.pendingReplies.register(requestId, {
14649
+ timeoutMs: CLIENT_REPLY_TIMEOUT_MS,
14650
+ onTimeout: ({ resolve }) => resolve({ success: false, error: "Timed out waiting for AgentBridge daemon reply." })
14421
14651
  });
14652
+ this.send({
14653
+ type: "claude_to_codex",
14654
+ requestId,
14655
+ message,
14656
+ ...requireReply ? { requireReply: true } : {},
14657
+ ...onBusy && onBusy !== "reject" ? { onBusy } : {},
14658
+ ...idempotencyKey ? { idempotencyKey } : {}
14659
+ });
14660
+ return pending;
14422
14661
  }
14423
14662
  attachSocketHandlers(ws, socketId) {
14424
14663
  ws.onmessage = (event) => {
@@ -14434,14 +14673,23 @@ class DaemonClient extends EventEmitter2 {
14434
14673
  this.emit("codexMessage", message.message);
14435
14674
  return;
14436
14675
  case "claude_to_codex_result": {
14437
- const pending = this.pendingReplies.get(message.requestId);
14438
- if (!pending)
14439
- return;
14440
- clearTimeout(pending.timer);
14441
- this.pendingReplies.delete(message.requestId);
14442
- pending.resolve({ success: message.success, error: message.error });
14676
+ this.pendingReplies.settle(message.requestId, {
14677
+ success: message.success,
14678
+ error: message.error,
14679
+ ...message.code !== undefined ? { code: message.code } : {},
14680
+ ...message.phase !== undefined ? { phase: message.phase } : {},
14681
+ ...message.retryAfterMs !== undefined ? { retryAfterMs: message.retryAfterMs } : {}
14682
+ });
14443
14683
  return;
14444
14684
  }
14685
+ case "turn_started":
14686
+ this.emit("turnStarted", {
14687
+ requestId: message.requestId,
14688
+ ...message.idempotencyKey !== undefined ? { idempotencyKey: message.idempotencyKey } : {},
14689
+ threadId: message.threadId,
14690
+ turnId: message.turnId
14691
+ });
14692
+ return;
14445
14693
  case "status":
14446
14694
  this.emit("status", message.status);
14447
14695
  return;
@@ -14456,7 +14704,7 @@ class DaemonClient extends EventEmitter2 {
14456
14704
  if (isCurrent) {
14457
14705
  this.ws = null;
14458
14706
  this.rejectPendingReplies("AgentBridge daemon disconnected.");
14459
- 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) {
14707
+ 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 || event.code === CLOSE_CODE_TOKEN_MISMATCH || event.code === CLOSE_CODE_CONTRACT_MISMATCH) {
14460
14708
  this.emit("rejected", event.code);
14461
14709
  } else {
14462
14710
  this.emit("disconnect");
@@ -14466,11 +14714,7 @@ class DaemonClient extends EventEmitter2 {
14466
14714
  ws.onerror = () => {};
14467
14715
  }
14468
14716
  rejectPendingReplies(error2) {
14469
- for (const [requestId, pending] of this.pendingReplies.entries()) {
14470
- clearTimeout(pending.timer);
14471
- pending.resolve({ success: false, error: error2 });
14472
- this.pendingReplies.delete(requestId);
14473
- }
14717
+ this.pendingReplies.settleAll(() => ({ success: false, error: error2 }));
14474
14718
  }
14475
14719
  send(message) {
14476
14720
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -14486,9 +14730,44 @@ class DaemonClient extends EventEmitter2 {
14486
14730
 
14487
14731
  // src/daemon-lifecycle.ts
14488
14732
  import { spawn } from "child_process";
14489
- import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
14733
+ import { existsSync as existsSync3, readFileSync as readFileSync2, statSync as statSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync2, openSync as openSync2, closeSync as closeSync2, constants } from "fs";
14490
14734
  import { fileURLToPath } from "url";
14491
14735
 
14736
+ // src/atomic-json.ts
14737
+ import * as fs from "fs";
14738
+ import { randomUUID as randomUUID2 } from "crypto";
14739
+ import { dirname as dirname2 } from "path";
14740
+ function tmpPathFor(targetPath) {
14741
+ return `${targetPath}.tmp.${process.pid}.${randomUUID2()}`;
14742
+ }
14743
+ function atomicWriteText(path, content, options = {}) {
14744
+ fs.mkdirSync(dirname2(path), { recursive: true });
14745
+ const tmp = tmpPathFor(path);
14746
+ let renamed = false;
14747
+ const fd = fs.openSync(tmp, "w", options.mode ?? 438);
14748
+ try {
14749
+ try {
14750
+ fs.writeFileSync(fd, content, "utf-8");
14751
+ if (options.fsync)
14752
+ fs.fsyncSync(fd);
14753
+ } finally {
14754
+ fs.closeSync(fd);
14755
+ }
14756
+ fs.renameSync(tmp, path);
14757
+ renamed = true;
14758
+ } finally {
14759
+ if (!renamed) {
14760
+ try {
14761
+ fs.unlinkSync(tmp);
14762
+ } catch {}
14763
+ }
14764
+ }
14765
+ }
14766
+ function atomicWriteJson(path, value, options = {}) {
14767
+ atomicWriteText(path, JSON.stringify(value, null, 2) + `
14768
+ `, options);
14769
+ }
14770
+
14492
14771
  // src/env-utils.ts
14493
14772
  function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
14494
14773
  const raw = env[name];
@@ -14537,23 +14816,208 @@ function isAgentBridgeProcess(pid, lookup = commandForPid) {
14537
14816
  return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
14538
14817
  }
14539
14818
 
14819
+ // src/daemon-record.ts
14820
+ import { readFileSync } from "fs";
14821
+ var defaultRead = (path) => readFileSync(path, "utf-8");
14822
+ function writeDaemonRecord(path, record3) {
14823
+ atomicWriteJson(path, record3);
14824
+ }
14825
+ function sanitizePorts(value) {
14826
+ if (typeof value !== "object" || value === null)
14827
+ return;
14828
+ const raw = value;
14829
+ const ports = {};
14830
+ if (typeof raw.appPort === "number")
14831
+ ports.appPort = raw.appPort;
14832
+ if (typeof raw.proxyPort === "number")
14833
+ ports.proxyPort = raw.proxyPort;
14834
+ if (typeof raw.controlPort === "number")
14835
+ ports.controlPort = raw.controlPort;
14836
+ return Object.keys(ports).length > 0 ? ports : undefined;
14837
+ }
14838
+ function readDaemonRecord(path, read = defaultRead) {
14839
+ let parsed;
14840
+ try {
14841
+ parsed = JSON.parse(read(path));
14842
+ } catch {
14843
+ return null;
14844
+ }
14845
+ if (typeof parsed !== "object" || parsed === null)
14846
+ return null;
14847
+ const obj = parsed;
14848
+ if (typeof obj.pid !== "number" || !Number.isFinite(obj.pid))
14849
+ return null;
14850
+ const phase = obj.phase === "ready" ? "ready" : "booting";
14851
+ const record3 = { pid: obj.pid, phase };
14852
+ if (typeof obj.startedAt === "number")
14853
+ record3.startedAt = obj.startedAt;
14854
+ if (typeof obj.nonce === "string")
14855
+ record3.nonce = obj.nonce;
14856
+ if (obj.pairId === null || typeof obj.pairId === "string")
14857
+ record3.pairId = obj.pairId;
14858
+ if (obj.cwd === null || typeof obj.cwd === "string")
14859
+ record3.cwd = obj.cwd;
14860
+ if (obj.stateDir === null || typeof obj.stateDir === "string")
14861
+ record3.stateDir = obj.stateDir;
14862
+ if (typeof obj.proxyUrl === "string")
14863
+ record3.proxyUrl = obj.proxyUrl;
14864
+ if (typeof obj.appServerUrl === "string")
14865
+ record3.appServerUrl = obj.appServerUrl;
14866
+ const ports = sanitizePorts(obj.ports);
14867
+ if (ports !== undefined)
14868
+ record3.ports = ports;
14869
+ if (typeof obj.build === "object" && obj.build !== null) {
14870
+ record3.build = obj.build;
14871
+ }
14872
+ if (typeof obj.turnPhase === "string")
14873
+ record3.turnPhase = obj.turnPhase;
14874
+ if (typeof obj.turnInProgress === "boolean")
14875
+ record3.turnInProgress = obj.turnInProgress;
14876
+ if (typeof obj.attentionWindowActive === "boolean") {
14877
+ record3.attentionWindowActive = obj.attentionWindowActive;
14878
+ }
14879
+ return record3;
14880
+ }
14881
+ function synthesizeLegacyRecord(pidFilePath, statusFilePath, read = defaultRead) {
14882
+ let pidFromPidFile = null;
14883
+ try {
14884
+ const raw = read(pidFilePath).trim();
14885
+ const n = Number.parseInt(raw, 10);
14886
+ if (Number.isFinite(n))
14887
+ pidFromPidFile = n;
14888
+ } catch {}
14889
+ let status = null;
14890
+ try {
14891
+ const parsed = JSON.parse(read(statusFilePath));
14892
+ if (typeof parsed === "object" && parsed !== null)
14893
+ status = parsed;
14894
+ } catch {}
14895
+ const pidFromStatus = status && typeof status.pid === "number" && Number.isFinite(status.pid) ? status.pid : null;
14896
+ const pid = pidFromPidFile ?? pidFromStatus;
14897
+ if (pid === null)
14898
+ return null;
14899
+ const record3 = {
14900
+ pid,
14901
+ phase: status ? "ready" : "booting"
14902
+ };
14903
+ if (status) {
14904
+ if (typeof status.proxyUrl === "string")
14905
+ record3.proxyUrl = status.proxyUrl;
14906
+ if (typeof status.appServerUrl === "string")
14907
+ record3.appServerUrl = status.appServerUrl;
14908
+ const controlPort = typeof status.controlPort === "number" ? status.controlPort : undefined;
14909
+ const proxyPort = portFromUrl(status.proxyUrl);
14910
+ const appPort = portFromUrl(status.appServerUrl);
14911
+ if (controlPort !== undefined || proxyPort !== undefined || appPort !== undefined) {
14912
+ record3.ports = {};
14913
+ if (appPort !== undefined)
14914
+ record3.ports.appPort = appPort;
14915
+ if (proxyPort !== undefined)
14916
+ record3.ports.proxyPort = proxyPort;
14917
+ if (controlPort !== undefined)
14918
+ record3.ports.controlPort = controlPort;
14919
+ }
14920
+ if (status.pairId === null || typeof status.pairId === "string")
14921
+ record3.pairId = status.pairId;
14922
+ if (status.cwd === null || typeof status.cwd === "string")
14923
+ record3.cwd = status.cwd;
14924
+ if (status.stateDir === null || typeof status.stateDir === "string")
14925
+ record3.stateDir = status.stateDir;
14926
+ if (typeof status.build === "object" && status.build !== null) {
14927
+ record3.build = status.build;
14928
+ }
14929
+ if (typeof status.turnPhase === "string")
14930
+ record3.turnPhase = status.turnPhase;
14931
+ if (typeof status.turnInProgress === "boolean")
14932
+ record3.turnInProgress = status.turnInProgress;
14933
+ if (typeof status.attentionWindowActive === "boolean") {
14934
+ record3.attentionWindowActive = status.attentionWindowActive;
14935
+ }
14936
+ }
14937
+ return record3;
14938
+ }
14939
+ function readUnifiedDaemonRecord(paths, read = defaultRead) {
14940
+ return readDaemonRecord(paths.daemonRecordFile, read) ?? synthesizeLegacyRecord(paths.pidFile, paths.statusFile, read);
14941
+ }
14942
+ function portFromUrl(url) {
14943
+ if (typeof url !== "string")
14944
+ return;
14945
+ const match = url.match(/:(\d+)(?:[/?]|$)/);
14946
+ return match ? Number.parseInt(match[1], 10) : undefined;
14947
+ }
14948
+
14540
14949
  // src/daemon-lifecycle.ts
14541
14950
  var DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
14542
14951
  var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
14543
14952
  var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
14544
14953
  var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
14545
14954
  var REUSE_READY_DELAY_MS = 250;
14955
+ var WAIT_READY_RETRIES = 40;
14956
+ var WAIT_READY_DELAY_MS = 250;
14546
14957
  var HEALTH_FETCH_TIMEOUT_MS = 500;
14547
14958
  var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
14959
+ function isReuseVerdict(verdict) {
14960
+ return verdict === "reuse" || verdict === "reuse-despite-drift";
14961
+ }
14962
+ function classifyDaemon(expectedPairId, status, buildInfo) {
14963
+ if (!status) {
14964
+ return { verdict: "unreachable", reason: "daemon status is unavailable or unparseable" };
14965
+ }
14966
+ const reportedPairId = status.pairId;
14967
+ if (!expectedPairId && reportedPairId != null) {
14968
+ return {
14969
+ verdict: "manual-conflict",
14970
+ reason: `manual mode must not adopt registered pair ${reportedPairId}`
14971
+ };
14972
+ }
14973
+ if (expectedPairId) {
14974
+ if (reportedPairId == null) {
14975
+ return {
14976
+ verdict: "replace-foreign",
14977
+ reason: `pair ${expectedPairId} found daemon without pair identity`
14978
+ };
14979
+ }
14980
+ if (reportedPairId !== expectedPairId) {
14981
+ return {
14982
+ verdict: "replace-foreign",
14983
+ reason: `pair ${expectedPairId} found daemon for pair ${reportedPairId}`
14984
+ };
14985
+ }
14986
+ }
14987
+ if (!sameRuntimeContract(status.build, buildInfo)) {
14988
+ if (compatibleContractVersion(status.build, buildInfo) && status.tuiConnected === true) {
14989
+ return {
14990
+ verdict: "reuse-despite-drift",
14991
+ reason: "runtime build drift has a compatible contract and a live Codex TUI is attached"
14992
+ };
14993
+ }
14994
+ const basis = runtimeContractComparisonBasis(status.build, buildInfo) === "codeHash" ? "compared by codeHash" : "compared by commit stamp; legacy build without codeHash";
14995
+ return {
14996
+ verdict: "replace-drifted",
14997
+ reason: `runtime build ${formatBuildInfo(status.build)} does not match launcher ` + `${formatBuildInfo(buildInfo)} (${basis})`
14998
+ };
14999
+ }
15000
+ return { verdict: "reuse", reason: "daemon pair and runtime contract match" };
15001
+ }
15002
+ function resolveTiming(timing) {
15003
+ return {
15004
+ reuseReadyRetries: timing?.reuseReadyRetries ?? REUSE_READY_RETRIES,
15005
+ reuseReadyDelayMs: timing?.reuseReadyDelayMs ?? REUSE_READY_DELAY_MS,
15006
+ waitReadyRetries: timing?.waitReadyRetries ?? WAIT_READY_RETRIES,
15007
+ waitReadyDelayMs: timing?.waitReadyDelayMs ?? WAIT_READY_DELAY_MS
15008
+ };
15009
+ }
14548
15010
 
14549
15011
  class DaemonLifecycle {
14550
15012
  stateDir;
14551
15013
  controlPort;
14552
15014
  log;
15015
+ timing;
14553
15016
  constructor(opts) {
14554
15017
  this.stateDir = opts.stateDir;
14555
15018
  this.controlPort = opts.controlPort;
14556
15019
  this.log = opts.log;
15020
+ this.timing = resolveTiming(opts.timing);
14557
15021
  }
14558
15022
  get healthUrl() {
14559
15023
  return `http://127.0.0.1:${this.controlPort}/healthz`;
@@ -14577,55 +15041,40 @@ class DaemonLifecycle {
14577
15041
  return null;
14578
15042
  }
14579
15043
  }
14580
- isForeignDaemon(status) {
14581
- const expected = this.expectedPairId;
14582
- if (!expected)
14583
- return false;
14584
- if (!status)
14585
- return false;
14586
- const reported = status.pairId;
14587
- if (reported == null)
14588
- return true;
14589
- return reported !== expected;
14590
- }
14591
- isRegisteredPairDaemonInManualMode(status) {
14592
- return !this.expectedPairId && status?.pairId != null;
14593
- }
14594
- isBuildDrifted(status) {
14595
- if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
14596
- return false;
14597
- const runtime = status?.build;
14598
- if (!runtime)
14599
- return true;
14600
- return !sameRuntimeContract(runtime, BUILD_INFO);
15044
+ classifyDaemon(status) {
15045
+ const classification = classifyDaemon(this.expectedPairId, status, BUILD_INFO);
15046
+ if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1" && (classification.verdict === "replace-drifted" || classification.verdict === "unreachable")) {
15047
+ return { verdict: "reuse", reason: "build drift replacement disabled by AGENTBRIDGE_ALLOW_BUILD_DRIFT" };
15048
+ }
15049
+ return classification;
14601
15050
  }
14602
- canReuseDespiteDrift(status) {
14603
- if (!compatibleContractVersion(status?.build, BUILD_INFO))
14604
- return false;
14605
- return status?.tuiConnected === true;
15051
+ manualConflictError(status) {
15052
+ return 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.`);
14606
15053
  }
14607
15054
  async ensureRunning() {
14608
15055
  if (await this.isHealthy()) {
14609
15056
  const status = await this.fetchStatus();
14610
- if (this.isRegisteredPairDaemonInManualMode(status)) {
14611
- 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.`);
14612
- }
14613
- if (this.isForeignDaemon(status)) {
14614
- 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`);
14615
- await this.replaceUnhealthyDaemon(status?.pid);
14616
- return;
14617
- }
14618
- if (this.isBuildDrifted(status)) {
14619
- if (this.canReuseDespiteDrift(status)) {
14620
- 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)`);
14621
- } else {
15057
+ const classification = this.classifyDaemon(status);
15058
+ switch (classification.verdict) {
15059
+ case "manual-conflict":
15060
+ throw this.manualConflictError(status);
15061
+ case "replace-foreign":
15062
+ 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`);
15063
+ await this.replaceUnhealthyDaemon(status?.pid);
15064
+ return;
15065
+ case "replace-drifted":
15066
+ case "unreachable":
14622
15067
  this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
14623
15068
  await this.replaceUnhealthyDaemon(status?.pid);
14624
15069
  return;
14625
- }
15070
+ case "reuse-despite-drift":
15071
+ 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)`);
15072
+ break;
15073
+ case "reuse":
15074
+ break;
14626
15075
  }
14627
15076
  try {
14628
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
15077
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
14629
15078
  return;
14630
15079
  } catch {
14631
15080
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
@@ -14638,7 +15087,7 @@ class DaemonLifecycle {
14638
15087
  if (isProcessAlive(existingPid)) {
14639
15088
  if (isAgentBridgeDaemon(existingPid)) {
14640
15089
  try {
14641
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
15090
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
14642
15091
  return;
14643
15092
  } catch {
14644
15093
  this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
@@ -14652,18 +15101,21 @@ class DaemonLifecycle {
14652
15101
  }
14653
15102
  await this.withStartupLockStrict(async (locked) => {
14654
15103
  if (!locked) {
14655
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
14656
- await this.waitForReadyAndOurs();
15104
+ await this.waitForContendedStartupLock();
14657
15105
  return;
14658
15106
  }
14659
15107
  if (await this.isHealthy()) {
14660
15108
  const status = await this.fetchStatus();
14661
- if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
14662
- this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
15109
+ const classification = this.classifyDaemon(status);
15110
+ if (classification.verdict === "manual-conflict") {
15111
+ throw this.manualConflictError(status);
15112
+ }
15113
+ if (!isReuseVerdict(classification.verdict)) {
15114
+ this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}, ` + `reason=${classification.reason}) \u2014 replacing`);
14663
15115
  await this.kill(3000, status?.pid);
14664
15116
  } else {
14665
15117
  try {
14666
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
15118
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
14667
15119
  return;
14668
15120
  } catch {
14669
15121
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
@@ -14672,7 +15124,7 @@ class DaemonLifecycle {
14672
15124
  }
14673
15125
  }
14674
15126
  this.launch();
14675
- await this.waitForReady();
15127
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
14676
15128
  });
14677
15129
  }
14678
15130
  async isHealthy() {
@@ -14699,7 +15151,7 @@ class DaemonLifecycle {
14699
15151
  return false;
14700
15152
  }
14701
15153
  }
14702
- async waitForReady(maxRetries = 40, delayMs = 250) {
15154
+ async waitForReady(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
14703
15155
  for (let attempt = 0;attempt < maxRetries; attempt++) {
14704
15156
  if (await this.isReady())
14705
15157
  return;
@@ -14707,11 +15159,15 @@ class DaemonLifecycle {
14707
15159
  }
14708
15160
  throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
14709
15161
  }
14710
- async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
15162
+ async waitForReadyAndOurs(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
14711
15163
  for (let attempt = 0;attempt < maxRetries; attempt++) {
14712
15164
  if (await this.isReady()) {
14713
15165
  const status = await this.fetchStatus();
14714
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
15166
+ const classification = this.classifyDaemon(status);
15167
+ if (classification.verdict === "manual-conflict") {
15168
+ throw this.manualConflictError(status);
15169
+ }
15170
+ if (isReuseVerdict(classification.verdict)) {
14715
15171
  return;
14716
15172
  }
14717
15173
  }
@@ -14719,22 +15175,35 @@ class DaemonLifecycle {
14719
15175
  }
14720
15176
  throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
14721
15177
  }
15178
+ readDaemonRecord() {
15179
+ return readUnifiedDaemonRecord({
15180
+ daemonRecordFile: this.stateDir.daemonRecordFile,
15181
+ pidFile: this.stateDir.pidFile,
15182
+ statusFile: this.stateDir.statusFile
15183
+ });
15184
+ }
15185
+ writeDaemonRecord(record3) {
15186
+ writeDaemonRecord(this.stateDir.daemonRecordFile, record3);
15187
+ }
15188
+ removeDaemonRecord() {
15189
+ try {
15190
+ unlinkSync3(this.stateDir.daemonRecordFile);
15191
+ } catch {}
15192
+ }
14722
15193
  readStatus() {
14723
15194
  try {
14724
- const raw = readFileSync(this.stateDir.statusFile, "utf-8");
15195
+ const raw = readFileSync2(this.stateDir.statusFile, "utf-8");
14725
15196
  return JSON.parse(raw);
14726
15197
  } catch {
14727
15198
  return null;
14728
15199
  }
14729
15200
  }
14730
15201
  writeStatus(status) {
14731
- this.stateDir.ensure();
14732
- writeFileSync(this.stateDir.statusFile, JSON.stringify(status, null, 2) + `
14733
- `, "utf-8");
15202
+ atomicWriteJson(this.stateDir.statusFile, status);
14734
15203
  }
14735
15204
  readPid() {
14736
15205
  try {
14737
- const raw = readFileSync(this.stateDir.pidFile, "utf-8").trim();
15206
+ const raw = readFileSync2(this.stateDir.pidFile, "utf-8").trim();
14738
15207
  if (!raw)
14739
15208
  return null;
14740
15209
  const pid = Number.parseInt(raw, 10);
@@ -14744,28 +15213,27 @@ class DaemonLifecycle {
14744
15213
  }
14745
15214
  }
14746
15215
  writePid(pid) {
14747
- this.stateDir.ensure();
14748
- writeFileSync(this.stateDir.pidFile, `${pid ?? process.pid}
14749
- `, "utf-8");
15216
+ atomicWriteText(this.stateDir.pidFile, `${pid ?? process.pid}
15217
+ `);
14750
15218
  }
14751
15219
  removePidFile() {
14752
15220
  try {
14753
- unlinkSync2(this.stateDir.pidFile);
15221
+ unlinkSync3(this.stateDir.pidFile);
14754
15222
  } catch {}
14755
15223
  }
14756
15224
  removeStatusFile() {
14757
15225
  try {
14758
- unlinkSync2(this.stateDir.statusFile);
15226
+ unlinkSync3(this.stateDir.statusFile);
14759
15227
  } catch {}
14760
15228
  }
14761
15229
  markKilled() {
14762
15230
  this.stateDir.ensure();
14763
- writeFileSync(this.stateDir.killedFile, `${Date.now()}
15231
+ writeFileSync2(this.stateDir.killedFile, `${Date.now()}
14764
15232
  `, "utf-8");
14765
15233
  }
14766
15234
  clearKilled() {
14767
15235
  try {
14768
- unlinkSync2(this.stateDir.killedFile);
15236
+ unlinkSync3(this.stateDir.killedFile);
14769
15237
  } catch {}
14770
15238
  }
14771
15239
  wasKilled() {
@@ -14787,21 +15255,26 @@ class DaemonLifecycle {
14787
15255
  daemonProc.unref();
14788
15256
  }
14789
15257
  removeStalePidFile() {
14790
- this.log("Removing stale pid file");
15258
+ this.log("Removing stale daemon identity files");
14791
15259
  this.removePidFile();
15260
+ this.removeStatusFile();
15261
+ this.removeDaemonRecord();
14792
15262
  }
14793
15263
  async replaceUnhealthyDaemon(statusPid) {
14794
15264
  await this.withStartupLockStrict(async (locked) => {
14795
15265
  if (!locked) {
14796
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
14797
- await this.waitForReadyAndOurs();
15266
+ await this.waitForContendedStartupLock();
14798
15267
  return;
14799
15268
  }
14800
15269
  if (await this.isHealthy()) {
14801
15270
  const status = await this.fetchStatus();
14802
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
15271
+ const classification = this.classifyDaemon(status);
15272
+ if (classification.verdict === "manual-conflict") {
15273
+ throw this.manualConflictError(status);
15274
+ }
15275
+ if (isReuseVerdict(classification.verdict)) {
14803
15276
  try {
14804
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
15277
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
14805
15278
  return;
14806
15279
  } catch {}
14807
15280
  }
@@ -14809,9 +15282,13 @@ class DaemonLifecycle {
14809
15282
  this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
14810
15283
  await this.kill(3000, statusPid);
14811
15284
  this.launch();
14812
- await this.waitForReady();
15285
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
14813
15286
  });
14814
15287
  }
15288
+ async waitForContendedStartupLock() {
15289
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
15290
+ await this.waitForReadyAndOurs(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
15291
+ }
14815
15292
  async withStartupLockStrict(fn) {
14816
15293
  const locked = this.acquireLockStrict();
14817
15294
  try {
@@ -14825,15 +15302,15 @@ class DaemonLifecycle {
14825
15302
  this.stateDir.ensure();
14826
15303
  let fd = null;
14827
15304
  try {
14828
- fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
14829
- writeFileSync(fd, `${process.pid}
15305
+ fd = openSync2(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
15306
+ writeFileSync2(fd, `${process.pid}
14830
15307
  `);
14831
- closeSync(fd);
15308
+ closeSync2(fd);
14832
15309
  return true;
14833
15310
  } catch (err) {
14834
15311
  if (fd !== null && err.code !== "EEXIST") {
14835
15312
  try {
14836
- closeSync(fd);
15313
+ closeSync2(fd);
14837
15314
  } catch {}
14838
15315
  this.releaseLock();
14839
15316
  }
@@ -14841,7 +15318,7 @@ class DaemonLifecycle {
14841
15318
  if (reclaimed)
14842
15319
  return false;
14843
15320
  try {
14844
- const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
15321
+ const holderPid = Number.parseInt(readFileSync2(this.stateDir.lockFile, "utf-8").trim(), 10);
14845
15322
  if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
14846
15323
  this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
14847
15324
  this.releaseLock();
@@ -14870,7 +15347,7 @@ class DaemonLifecycle {
14870
15347
  }
14871
15348
  releaseLock() {
14872
15349
  try {
14873
- unlinkSync2(this.stateDir.lockFile);
15350
+ unlinkSync3(this.stateDir.lockFile);
14874
15351
  } catch {}
14875
15352
  }
14876
15353
  async kill(gracefulTimeoutMs = 3000, pidOverride) {
@@ -14916,6 +15393,7 @@ class DaemonLifecycle {
14916
15393
  cleanup() {
14917
15394
  this.removePidFile();
14918
15395
  this.removeStatusFile();
15396
+ this.removeDaemonRecord();
14919
15397
  }
14920
15398
  }
14921
15399
  async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
@@ -14929,11 +15407,11 @@ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
14929
15407
  }
14930
15408
 
14931
15409
  // src/config-service.ts
14932
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
15410
+ import { readFileSync as readFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
14933
15411
  import { join as join2 } from "path";
14934
15412
  var DEFAULT_BUDGET_CONFIG = {
14935
15413
  enabled: true,
14936
- pollSeconds: 60,
15414
+ pollSeconds: 300,
14937
15415
  pauseAt: 90,
14938
15416
  resumeBelow: 30,
14939
15417
  syncDriftPct: 10,
@@ -14962,9 +15440,52 @@ var DEFAULT_CONFIG = {
14962
15440
  };
14963
15441
  var CONFIG_DIR = ".agentbridge";
14964
15442
  var CONFIG_FILE = "config.json";
15443
+ var NOOP_LOGGER = () => {};
14965
15444
  function isRecord(value) {
14966
15445
  return typeof value === "object" && value !== null && !Array.isArray(value);
14967
15446
  }
15447
+ function isCoercibleNumber(value) {
15448
+ if (typeof value === "number")
15449
+ return Number.isFinite(value);
15450
+ if (typeof value === "string")
15451
+ return Number.isFinite(Number(value));
15452
+ return false;
15453
+ }
15454
+ function findShapeViolation(raw) {
15455
+ if ("idleShutdownSeconds" in raw && !isCoercibleNumber(raw.idleShutdownSeconds)) {
15456
+ return "idleShutdownSeconds is present but not a number";
15457
+ }
15458
+ if ("budget" in raw) {
15459
+ const budget = raw.budget;
15460
+ if (!isRecord(budget)) {
15461
+ return "budget is present but not an object";
15462
+ }
15463
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
15464
+ for (const key of numericKeys) {
15465
+ if (key in budget && !isCoercibleNumber(budget[key])) {
15466
+ return `budget.${key} is present but not a number`;
15467
+ }
15468
+ }
15469
+ if ("parallel" in budget) {
15470
+ const parallel = budget.parallel;
15471
+ if (!isRecord(parallel)) {
15472
+ return "budget.parallel is present but not an object";
15473
+ }
15474
+ for (const key of ["minRemainingPct", "timeWindowSec"]) {
15475
+ if (key in parallel && !isCoercibleNumber(parallel[key])) {
15476
+ return `budget.parallel.${key} is present but not a number`;
15477
+ }
15478
+ }
15479
+ }
15480
+ }
15481
+ return null;
15482
+ }
15483
+ function hasCustomDecisionValues(config2) {
15484
+ const d = DEFAULT_CONFIG;
15485
+ const b = config2.budget;
15486
+ const db = d.budget;
15487
+ return config2.idleShutdownSeconds !== d.idleShutdownSeconds || config2.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config2.codex.appPort !== d.codex.appPort || config2.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.pauseAt !== db.pauseAt || b.resumeBelow !== db.resumeBelow || b.syncDriftPct !== db.syncDriftPct || b.parallel.minRemainingPct !== db.parallel.minRemainingPct || b.parallel.timeWindowSec !== db.parallel.timeWindowSec || b.codexTierControl !== db.codexTierControl;
15488
+ }
14968
15489
  function normalizeInteger(value, fallback) {
14969
15490
  if (typeof value === "number" && Number.isFinite(value))
14970
15491
  return value;
@@ -15000,35 +15521,35 @@ function normalizeCodexOverride(raw) {
15000
15521
  override.effort = raw.effort.trim();
15001
15522
  return Object.keys(override).length > 0 ? override : null;
15002
15523
  }
15003
- function normalizeCodexTiers(raw) {
15524
+ function normalizeCodexTiers(raw, fallback = DEFAULT_BUDGET_CONFIG.codexTiers) {
15004
15525
  const tiers = isRecord(raw) ? raw : {};
15005
15526
  return {
15006
15527
  full: normalizeCodexOverride(tiers.full),
15007
- balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
15008
- eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
15528
+ balanced: normalizeCodexOverride(tiers.balanced) ?? fallback.balanced,
15529
+ eco: normalizeCodexOverride(tiers.eco) ?? fallback.eco
15009
15530
  };
15010
15531
  }
15011
- function normalizeBudgetConfig(raw) {
15532
+ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
15012
15533
  const budget = isRecord(raw) ? raw : {};
15013
15534
  const parallel = isRecord(budget.parallel) ? budget.parallel : {};
15014
- const codexTiers = normalizeCodexTiers(budget.codexTiers);
15015
- let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
15016
- let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
15535
+ const codexTiers = normalizeCodexTiers(budget.codexTiers, fallback.codexTiers);
15536
+ let pauseAt = normalizeBoundedInteger(budget.pauseAt, fallback.pauseAt, 1, 100);
15537
+ let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, fallback.resumeBelow, 0, 99);
15017
15538
  if (pauseAt <= resumeBelow) {
15018
15539
  pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
15019
15540
  resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
15020
15541
  }
15021
15542
  return {
15022
- enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
15023
- pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
15543
+ enabled: normalizeBoolean(budget.enabled, fallback.enabled),
15544
+ pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
15024
15545
  pauseAt,
15025
15546
  resumeBelow,
15026
- syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
15547
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
15027
15548
  parallel: {
15028
- minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
15029
- timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
15549
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, fallback.parallel.minRemainingPct, 1, 100),
15550
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
15030
15551
  },
15031
- codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
15552
+ codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
15032
15553
  codexTiers
15033
15554
  };
15034
15555
  }
@@ -15042,13 +15563,13 @@ function normalizeConfig(raw) {
15042
15563
  return {
15043
15564
  version: typeof config2.version === "string" ? config2.version : DEFAULT_CONFIG.version,
15044
15565
  codex: {
15045
- appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort),
15046
- proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort)
15566
+ appPort: normalizeBoundedInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort, 1, 65535),
15567
+ proxyPort: normalizeBoundedInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort, 1, 65535)
15047
15568
  },
15048
15569
  turnCoordination: {
15049
- attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
15570
+ attentionWindowSeconds: normalizeBoundedInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds, 0, Number.MAX_SAFE_INTEGER)
15050
15571
  },
15051
- idleShutdownSeconds: normalizeInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
15572
+ idleShutdownSeconds: normalizeBoundedInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds, 1, Number.MAX_SAFE_INTEGER),
15052
15573
  budget: normalizeBudgetConfig(config2.budget)
15053
15574
  };
15054
15575
  }
@@ -15065,20 +15586,62 @@ class ConfigService {
15065
15586
  return existsSync4(this.configPath);
15066
15587
  }
15067
15588
  load() {
15589
+ let raw;
15068
15590
  try {
15069
- const raw = readFileSync2(this.configPath, "utf-8");
15070
- return normalizeConfig(JSON.parse(raw));
15071
- } catch {
15072
- return null;
15591
+ raw = readFileSync3(this.configPath, "utf-8");
15592
+ } catch (err) {
15593
+ if (err?.code === "ENOENT") {
15594
+ return { state: "absent" };
15595
+ }
15596
+ return { state: "corrupt", reason: `config.json is unreadable: ${err.message}` };
15073
15597
  }
15598
+ let parsed;
15599
+ try {
15600
+ parsed = JSON.parse(raw);
15601
+ } catch (err) {
15602
+ return {
15603
+ state: "corrupt",
15604
+ reason: `config.json is not valid JSON: ${err.message}`
15605
+ };
15606
+ }
15607
+ if (!isRecord(parsed)) {
15608
+ return { state: "corrupt", reason: "config.json is not a JSON object" };
15609
+ }
15610
+ const violation = findShapeViolation(parsed);
15611
+ if (violation) {
15612
+ return { state: "corrupt", reason: `config.json is shape-invalid: ${violation}` };
15613
+ }
15614
+ const config2 = normalizeConfig(parsed);
15615
+ if (!config2) {
15616
+ return { state: "corrupt", reason: "config.json could not be normalized" };
15617
+ }
15618
+ return { state: "parsed", config: config2 };
15619
+ }
15620
+ loadOrDefault(log = NOOP_LOGGER) {
15621
+ const result = this.load();
15622
+ if (result.state === "parsed")
15623
+ return result.config;
15624
+ if (result.state === "corrupt") {
15625
+ log(`config.json at ${this.configPath} is unusable (${result.reason}); ` + "falling back to defaults \u2014 your custom budget thresholds / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them.");
15626
+ }
15627
+ return structuredClone(DEFAULT_CONFIG);
15074
15628
  }
15075
- loadOrDefault() {
15076
- return this.load() ?? structuredClone(DEFAULT_CONFIG);
15629
+ describeConfig() {
15630
+ const result = this.load();
15631
+ if (result.state === "absent") {
15632
+ return { state: "absent", path: this.configPath, customValues: false };
15633
+ }
15634
+ if (result.state === "corrupt") {
15635
+ return { state: "corrupt", path: this.configPath, reason: result.reason, customValues: false };
15636
+ }
15637
+ return {
15638
+ state: "parsed",
15639
+ path: this.configPath,
15640
+ customValues: hasCustomDecisionValues(result.config)
15641
+ };
15077
15642
  }
15078
15643
  save(config2) {
15079
- this.ensureConfigDir();
15080
- writeFileSync2(this.configPath, JSON.stringify(config2, null, 2) + `
15081
- `, "utf-8");
15644
+ atomicWriteJson(this.configPath, config2);
15082
15645
  }
15083
15646
  initDefaults() {
15084
15647
  this.ensureConfigDir();
@@ -15094,34 +15657,46 @@ class ConfigService {
15094
15657
  }
15095
15658
  ensureConfigDir() {
15096
15659
  if (!existsSync4(this.configDir)) {
15097
- mkdirSync2(this.configDir, { recursive: true });
15660
+ mkdirSync3(this.configDir, { recursive: true });
15098
15661
  }
15099
15662
  }
15100
15663
  }
15101
15664
 
15665
+ // src/cli-invocation.ts
15666
+ import { basename } from "path";
15667
+ var CLI_NAMES = ["abg", "agentbridge"];
15668
+ var DEFAULT_CLI_NAME = "abg";
15669
+ function cliInvocationName(argv = process.argv) {
15670
+ const raw = argv[1];
15671
+ if (typeof raw !== "string" || raw.length === 0)
15672
+ return DEFAULT_CLI_NAME;
15673
+ const name = basename(raw).replace(/\.(ts|js|mjs|cjs)$/, "");
15674
+ return isCliName(name) ? name : DEFAULT_CLI_NAME;
15675
+ }
15676
+ function isCliName(value) {
15677
+ return CLI_NAMES.includes(value);
15678
+ }
15679
+
15102
15680
  // src/pair-registry.ts
15103
15681
  import {
15104
- closeSync as closeSync2,
15105
15682
  existsSync as existsSync5,
15106
- fsyncSync,
15107
15683
  linkSync,
15108
15684
  lstatSync,
15109
- mkdirSync as mkdirSync3,
15110
- openSync as openSync2,
15685
+ mkdirSync as mkdirSync4,
15111
15686
  readdirSync,
15112
- readFileSync as readFileSync3,
15687
+ readFileSync as readFileSync4,
15113
15688
  realpathSync,
15114
- renameSync as renameSync2,
15115
15689
  rmSync,
15116
15690
  statSync as statSync3,
15117
- unlinkSync as unlinkSync3,
15691
+ unlinkSync as unlinkSync4,
15118
15692
  writeFileSync as writeFileSync3
15119
15693
  } from "fs";
15120
- import { createHash, randomUUID as randomUUID2 } from "crypto";
15121
- import { basename, join as join3, resolve, sep } from "path";
15694
+ import { createHash, randomUUID as randomUUID3 } from "crypto";
15695
+ import { basename as basename2, join as join3, resolve, sep } from "path";
15122
15696
  var PAIR_BASE_PORT = 4500;
15123
15697
  var PAIR_SLOT_STRIDE = 10;
15124
15698
  var PAIR_ID_REGEX = /^[A-Za-z0-9._-]{1,64}$/;
15699
+ var RECLAIMABLE_MIN_AGE_MS = 24 * 60 * 60 * 1000;
15125
15700
  var REGISTRY_FILE_NAME = "registry.json";
15126
15701
  class PairError extends Error {
15127
15702
  code;
@@ -15157,7 +15732,7 @@ function readRegistry(base) {
15157
15732
  return { version: 1, pairs: [] };
15158
15733
  let parsed;
15159
15734
  try {
15160
- parsed = JSON.parse(readFileSync3(path, "utf-8"));
15735
+ parsed = JSON.parse(readFileSync4(path, "utf-8"));
15161
15736
  } catch (err) {
15162
15737
  throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry JSON is not parseable at ${path}: ${err.message}`, {
15163
15738
  path
@@ -15198,10 +15773,10 @@ function findPair(base, pairId) {
15198
15773
  }
15199
15774
 
15200
15775
  // src/pair-command.ts
15201
- function pairScopedCommand(cmd) {
15776
+ function pairScopedCommand(cmd, name = cliInvocationName()) {
15202
15777
  const pairId = process.env.AGENTBRIDGE_PAIR_ID;
15203
15778
  if (!pairId)
15204
- return `agentbridge ${cmd}`;
15779
+ return `${name} ${cmd}`;
15205
15780
  let selector = process.env.AGENTBRIDGE_PAIR_NAME;
15206
15781
  if (!selector) {
15207
15782
  try {
@@ -15210,10 +15785,13 @@ function pairScopedCommand(cmd) {
15210
15785
  selector = pairId;
15211
15786
  }
15212
15787
  }
15213
- return `agentbridge --pair ${selector} ${cmd}`;
15788
+ return `${name} --pair ${selector} ${cmd}`;
15214
15789
  }
15215
15790
 
15216
15791
  // src/bridge-disabled-state.ts
15792
+ function shouldEmitReconnectSuccess(state) {
15793
+ return !state.daemonDisabled;
15794
+ }
15217
15795
  function disabledReplyError(reason) {
15218
15796
  const claudeCmd = pairScopedCommand("claude");
15219
15797
  switch (reason) {
@@ -15226,7 +15804,7 @@ function disabledReplyError(reason) {
15226
15804
  case "auto_recovery_exhausted":
15227
15805
  return `AgentBridge auto-recovery gave up after exhausting its retry budget for the in-flight liveness probe contention. Retry manually with \`${claudeCmd}\`.`;
15228
15806
  case "killed":
15229
- return `AgentBridge is disabled by \`agentbridge kill\`. Restart Claude Code (\`${claudeCmd}\`), switch to a new conversation, or run \`/resume\` to reconnect.`;
15807
+ return `AgentBridge is disabled by \`${pairScopedCommand("kill")}\`. Restart Claude Code (\`${claudeCmd}\`), switch to a new conversation, or run \`/resume\` to reconnect.`;
15230
15808
  }
15231
15809
  }
15232
15810
 
@@ -15305,9 +15883,27 @@ function nonEmpty(value) {
15305
15883
  return value && value.length > 0 ? value : null;
15306
15884
  }
15307
15885
 
15308
- // src/trace-log.ts
15309
- import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
15886
+ // src/control-token.ts
15887
+ import { chmodSync, readFileSync as readFileSync5 } from "fs";
15310
15888
  import { join as join4 } from "path";
15889
+ var CONTROL_TOKEN_FILENAME = "control-token";
15890
+ function resolveControlTokenPath(stateDir) {
15891
+ return join4(stateDir, CONTROL_TOKEN_FILENAME);
15892
+ }
15893
+ function readControlToken(path) {
15894
+ try {
15895
+ const raw = readFileSync5(path, "utf-8").trim();
15896
+ return raw.length > 0 ? raw : null;
15897
+ } catch {
15898
+ return null;
15899
+ }
15900
+ }
15901
+
15902
+ // src/trace-log.ts
15903
+ import { appendFileSync as appendFileSync2, existsSync as existsSync6, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync4, unlinkSync as unlinkSync5 } from "fs";
15904
+ import { join as join5 } from "path";
15905
+ var TRACE_RETENTION_DAYS = 7;
15906
+ var TRACE_FILE_RE = /^trace-\d{4}-\d{2}-\d{2}\.jsonl$/;
15311
15907
  var SECRET_KEY_RE = /(token|secret|password|passwd|api[_-]?key|auth|cookie|session)/i;
15312
15908
  var SECRET_ARG_RE = /^--?(?:token|secret|password|passwd|apikey|api-key|api_key|auth|cookie|session)(?:=.*)?$/i;
15313
15909
  var RELEVANT_ENV_RE = /^(AGENTBRIDGE_|CODEX_)/;
@@ -15345,7 +15941,7 @@ function redactArgv(argv) {
15345
15941
  }
15346
15942
  function traceLogPath(cwd, timestamp) {
15347
15943
  const day = timestamp.slice(0, 10);
15348
- return join4(cwd, ".agentbridge", "logs", `trace-${day}.jsonl`);
15944
+ return join5(cwd, ".agentbridge", "logs", `trace-${day}.jsonl`);
15349
15945
  }
15350
15946
  function appendTraceEvent(input) {
15351
15947
  const timestamp = input.timestamp ?? new Date().toISOString();
@@ -15359,11 +15955,39 @@ function appendTraceEvent(input) {
15359
15955
  ...input.env ? { env: pickRelevantEnv(input.env) } : {},
15360
15956
  ...input.data ? { data: redactData(input.data) } : {}
15361
15957
  };
15362
- mkdirSync4(join4(input.cwd, ".agentbridge", "logs"), { recursive: true });
15958
+ const logsDir = join5(input.cwd, ".agentbridge", "logs");
15959
+ const isNewDayFile = !existsSync6(path);
15960
+ mkdirSync5(logsDir, { recursive: true });
15961
+ if (isNewDayFile) {
15962
+ pruneOldTraceLogs(logsDir, path, Date.parse(timestamp));
15963
+ }
15363
15964
  appendFileSync2(path, JSON.stringify(event) + `
15364
15965
  `, "utf-8");
15365
15966
  return path;
15366
15967
  }
15968
+ function pruneOldTraceLogs(logsDir, keepPath, nowMs) {
15969
+ if (!Number.isFinite(nowMs))
15970
+ return;
15971
+ const cutoff = nowMs - TRACE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
15972
+ let entries;
15973
+ try {
15974
+ entries = readdirSync2(logsDir);
15975
+ } catch {
15976
+ return;
15977
+ }
15978
+ for (const name of entries) {
15979
+ if (!TRACE_FILE_RE.test(name))
15980
+ continue;
15981
+ const filePath = join5(logsDir, name);
15982
+ if (filePath === keepPath)
15983
+ continue;
15984
+ try {
15985
+ if (statSync4(filePath).mtimeMs < cutoff) {
15986
+ unlinkSync5(filePath);
15987
+ }
15988
+ } catch {}
15989
+ }
15990
+ }
15367
15991
  function isEnvSnapshot(key, value) {
15368
15992
  return /env$/i.test(key) && !!value && typeof value === "object" && !Array.isArray(value);
15369
15993
  }
@@ -15402,14 +16026,14 @@ var envGuardResult = guardAgentBridgeEnv({
15402
16026
  });
15403
16027
  var stateDir = new StateDirResolver;
15404
16028
  stateDir.ensure();
16029
+ var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
15405
16030
  var configService = new ConfigService;
15406
- var config2 = configService.loadOrDefault();
16031
+ var config2 = configService.loadOrDefault(processLogger.log);
15407
16032
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
15408
- var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
15409
16033
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
15410
16034
  var CONTROL_WS_URL = daemonLifecycle.controlWsUrl;
15411
16035
  var claude = new ClaudeAdapter(stateDir.logFile);
15412
- var daemonClient = new DaemonClient(CONTROL_WS_URL, { identity: currentClientIdentity() });
16036
+ var daemonClient = new DaemonClient(CONTROL_WS_URL, { identity: currentClientIdentity });
15413
16037
  var shuttingDown = false;
15414
16038
  var daemonDisabled = false;
15415
16039
  var daemonDisabledReason = null;
@@ -15422,6 +16046,7 @@ var lastReconnectNotifyTs = 0;
15422
16046
  var disabledRecoveryTimer = null;
15423
16047
  var disabledRecoveryInFlight = false;
15424
16048
  var disabledRecoveryAttempts = 0;
16049
+ var nextSystemMessageId = 0;
15425
16050
  var DISABLED_RECOVERY_MAX_ATTEMPTS = 6;
15426
16051
  var DISABLED_RECOVERY_CONFIRM_TIMEOUT_MS = 1000;
15427
16052
  if (process.env.AGENTBRIDGE_TRACE === "1") {
@@ -15445,7 +16070,7 @@ if (process.env.AGENTBRIDGE_TRACE === "1") {
15445
16070
  });
15446
16071
  } catch {}
15447
16072
  }
15448
- claude.setReplySender(async (msg, requireReply, onBusy) => {
16073
+ claude.setReplySender(async (msg, requireReply, onBusy, idempotencyKey) => {
15449
16074
  if (msg.source !== "claude") {
15450
16075
  return { success: false, error: "Invalid message source" };
15451
16076
  }
@@ -15455,7 +16080,10 @@ claude.setReplySender(async (msg, requireReply, onBusy) => {
15455
16080
  error: disabledReplyError(daemonDisabledReason ?? "killed")
15456
16081
  };
15457
16082
  }
15458
- return daemonClient.sendReply(msg, requireReply, onBusy);
16083
+ return daemonClient.sendReply(msg, requireReply, onBusy, idempotencyKey);
16084
+ });
16085
+ daemonClient.on("turnStarted", ({ requestId, idempotencyKey, threadId, turnId }) => {
16086
+ log(`Codex turn started for reply ${requestId} (turn=${turnId}, thread=${threadId}` + `${idempotencyKey ? `, idempotencyKey=${idempotencyKey}` : ""})`);
15459
16087
  });
15460
16088
  daemonClient.on("codexMessage", (message) => {
15461
16089
  log(`Forwarding daemon \u2192 Claude (${message.content.length} chars)`);
@@ -15513,6 +16141,16 @@ daemonClient.on("rejected", async (code) => {
15513
16141
  notificationId = "system_bridge_pair_mismatch";
15514
16142
  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`;
15515
16143
  break;
16144
+ case CLOSE_CODE_TOKEN_MISMATCH:
16145
+ reason = "rejected";
16146
+ notificationId = "system_bridge_token_mismatch";
16147
+ notificationContent = `\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 control token mismatch (the daemon likely restarted and rotated its token). Start a fresh session with \`${pairScopedCommand("claude")}\` to pick up the current token. AgentBridge \u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u63A7\u5236\u4EE4\u724C\u4E0D\u5339\u914D\uFF08daemon \u53EF\u80FD\u5DF2\u91CD\u542F\u5E76\u8F6E\u6362\u4EE4\u724C\uFF09\u3002\u8BF7\u7528 \`${pairScopedCommand("claude")}\` \u91CD\u65B0\u542F\u52A8\u4EE5\u83B7\u53D6\u6700\u65B0\u4EE4\u724C\u3002`;
16148
+ break;
16149
+ case CLOSE_CODE_CONTRACT_MISMATCH:
16150
+ reason = "rejected";
16151
+ notificationId = "system_bridge_contract_mismatch";
16152
+ notificationContent = `\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 protocol contract mismatch. The installed plugin and the running daemon are built from out-of-sync protocol versions. Run \`bun run install:global\` to rebuild + reinstall, then close and reopen Claude Code. Do NOT kill other pairs \u2014 this is local build skew, not a session conflict. AgentBridge \u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u534F\u8BAE\u5951\u7EA6\u7248\u672C\u4E0D\u5339\u914D\u3002\u5DF2\u5B89\u88C5\u7684\u63D2\u4EF6\u4E0E\u8FD0\u884C\u4E2D\u7684 daemon \u534F\u8BAE\u7248\u672C\u4E0D\u4E00\u81F4\u3002\u8BF7\u8FD0\u884C \`bun run install:global\` \u91CD\u65B0\u7F16\u8BD1\u5E76\u5B89\u88C5\uFF0C\u7136\u540E\u5173\u95ED\u5E76\u91CD\u65B0\u6253\u5F00 Claude Code\u3002\u8BF7\u52FF kill \u5176\u5B83 pair\u2014\u2014\u8FD9\u662F\u672C\u5730\u6784\u5EFA\u7248\u672C\u6F02\u79FB\uFF0C\u4E0D\u662F\u4F1A\u8BDD\u51B2\u7A81\u3002`;
16153
+ break;
15516
16154
  default:
15517
16155
  reason = "rejected";
15518
16156
  notificationId = "system_bridge_replaced";
@@ -15532,7 +16170,7 @@ daemonClient.on("rejected", async (code) => {
15532
16170
  claude.on("ready", async () => {
15533
16171
  log("MCP server ready (push delivery) \u2014 ensuring AgentBridge daemon...");
15534
16172
  if (daemonLifecycle.wasKilled()) {
15535
- 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.`);
16173
+ await enterDisabledState("Killed sentinel found \u2014 bridge staying idle", `\u26D4 AgentBridge was stopped by \`${pairScopedCommand("kill")}\`. Bridge is staying idle. Restart Claude Code (\`${pairScopedCommand("claude")}\`), switch to a new conversation, or run \`/resume\` to reconnect.`);
15536
16174
  return;
15537
16175
  }
15538
16176
  try {
@@ -15594,11 +16232,11 @@ var reconnectTask = null;
15594
16232
  async function notifyIfDaemonKilled(logMessage) {
15595
16233
  if (!daemonLifecycle.wasKilled())
15596
16234
  return false;
15597
- 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.`);
16235
+ await enterDisabledState(logMessage, `\u26D4 AgentBridge was stopped by \`${pairScopedCommand("kill")}\`. Bridge is staying idle. Restart Claude Code (\`${pairScopedCommand("claude")}\`), switch to a new conversation, or run \`/resume\` to reconnect.`);
15598
16236
  return true;
15599
16237
  }
15600
16238
  async function notifyIfPairRemoved(logMessage) {
15601
- if (existsSync6(stateDir.dir))
16239
+ if (existsSync7(stateDir.dir))
15602
16240
  return false;
15603
16241
  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`);
15604
16242
  return true;
@@ -15631,6 +16269,9 @@ function reconnectToDaemon() {
15631
16269
  }
15632
16270
  try {
15633
16271
  await connectToDaemon(true);
16272
+ if (!shouldEmitReconnectSuccess({ daemonDisabled })) {
16273
+ return;
16274
+ }
15634
16275
  log("Reconnected to AgentBridge daemon successfully");
15635
16276
  const now = Date.now();
15636
16277
  if (now - lastReconnectNotifyTs >= RECONNECT_NOTIFY_COOLDOWN_MS) {
@@ -15749,13 +16390,14 @@ async function pollDisabledRecovery() {
15749
16390
  }
15750
16391
  function systemMessage(idPrefix, content) {
15751
16392
  return {
15752
- id: `${idPrefix}_${Date.now()}`,
16393
+ id: `${idPrefix}_${++nextSystemMessageId}`,
15753
16394
  source: "codex",
15754
16395
  content,
15755
16396
  timestamp: Date.now()
15756
16397
  };
15757
16398
  }
15758
16399
  function currentClientIdentity() {
16400
+ const controlToken = readControlToken(resolveControlTokenPath(stateDir.dir));
15759
16401
  return {
15760
16402
  pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
15761
16403
  pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
@@ -15763,7 +16405,8 @@ function currentClientIdentity() {
15763
16405
  baseDir: process.env.AGENTBRIDGE_BASE_DIR ?? null,
15764
16406
  stateDir: stateDir.dir,
15765
16407
  clientPid: process.pid,
15766
- contractVersion: BUILD_INFO.contractVersion
16408
+ contractVersion: BUILD_INFO.contractVersion,
16409
+ ...controlToken ? { controlToken } : {}
15767
16410
  };
15768
16411
  }
15769
16412
  function shutdown(reason) {