@raysonmeng/agentbridge 0.1.10 → 0.1.12

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({
@@ -13668,13 +13668,14 @@ import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "fs
13668
13668
  import { dirname } from "path";
13669
13669
  var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
13670
13670
  var DEFAULT_KEEP = 3;
13671
- function appendRotatingLog(path, content, options = {}) {
13671
+ var REAL_FS_OPS = { statSync, renameSync, unlinkSync, appendFileSync, existsSync };
13672
+ function appendRotatingLog(path, content, options = {}, fsOps = REAL_FS_OPS) {
13672
13673
  const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
13673
13674
  const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
13674
- if (!existsSync(dirname(path)))
13675
+ if (!fsOps.existsSync(dirname(path)))
13675
13676
  return;
13676
- rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
13677
- appendFileSync(path, content, "utf-8");
13677
+ rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep, fsOps);
13678
+ fsOps.appendFileSync(path, content, "utf-8");
13678
13679
  }
13679
13680
  function positiveIntFromEnv(name, fallback) {
13680
13681
  const value = process.env[name];
@@ -13683,26 +13684,48 @@ function positiveIntFromEnv(name, fallback) {
13683
13684
  const parsed = Number(value);
13684
13685
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
13685
13686
  }
13686
- function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
13687
+ function isEnoent(error2) {
13688
+ return !!error2 && error2.code === "ENOENT";
13689
+ }
13690
+ function renameIfPresent(from, to, fsOps) {
13691
+ try {
13692
+ fsOps.renameSync(from, to);
13693
+ } catch (error2) {
13694
+ if (!isEnoent(error2))
13695
+ throw error2;
13696
+ }
13697
+ }
13698
+ function unlinkIfPresent(path, fsOps) {
13699
+ try {
13700
+ fsOps.unlinkSync(path);
13701
+ } catch (error2) {
13702
+ if (!isEnoent(error2))
13703
+ throw error2;
13704
+ }
13705
+ }
13706
+ function rotateIfNeeded(path, incomingBytes, maxBytes, keep, fsOps) {
13687
13707
  if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
13688
13708
  return;
13689
- if (!existsSync(path))
13690
- return;
13691
- const size = statSync(path).size;
13709
+ let size;
13710
+ try {
13711
+ size = fsOps.statSync(path).size;
13712
+ } catch (error2) {
13713
+ if (isEnoent(error2))
13714
+ return;
13715
+ throw error2;
13716
+ }
13692
13717
  if (size + incomingBytes <= maxBytes)
13693
13718
  return;
13694
13719
  for (let index = keep;index >= 1; index--) {
13695
13720
  const current = `${path}.${index}`;
13696
13721
  const next = `${path}.${index + 1}`;
13697
- if (!existsSync(current))
13698
- continue;
13699
13722
  if (index === keep) {
13700
- unlinkSync(current);
13723
+ unlinkIfPresent(current, fsOps);
13701
13724
  } else {
13702
- renameSync(current, next);
13725
+ renameIfPresent(current, next, fsOps);
13703
13726
  }
13704
13727
  }
13705
- renameSync(path, `${path}.1`);
13728
+ renameIfPresent(path, `${path}.1`, fsOps);
13706
13729
  }
13707
13730
 
13708
13731
  // src/process-log.ts
@@ -13859,6 +13882,9 @@ function formatAgent(name, usage, snapshotAt) {
13859
13882
  if (usage.rateLimitedUntil > 0) {
13860
13883
  parts.push(`\u9650\u6D41\u81F3 ${formatEpoch(usage.rateLimitedUntil)}`);
13861
13884
  }
13885
+ if (usage.parsedVia === "positional") {
13886
+ parts.push("\u26A0\uFE0F \u7A97\u53E3\u8BC6\u522B\u4F7F\u7528\u4F4D\u7F6E\u515C\u5E95");
13887
+ }
13862
13888
  const ageSec = usage.fetchedAt > 0 ? snapshotAt - usage.fetchedAt : 0;
13863
13889
  if (ageSec > 300) {
13864
13890
  parts.push(`\u26A0\uFE0F \u6570\u636E\u91C7\u96C6\u4E8E ${Math.round(ageSec / 60)} \u5206\u949F\u524D`);
@@ -13947,7 +13973,7 @@ var CLAUDE_INSTRUCTIONS = [
13947
13973
  "## Turn coordination",
13948
13974
  "- When you see '\u23F3 Codex is working', do NOT call the reply tool \u2014 wait for '\u2705 Codex finished'.",
13949
13975
  "- 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).',
13976
+ '- 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
13977
  "",
13952
13978
  "## Budget awareness",
13953
13979
  "- Use the get_budget tool to check both agents' subscription quota (5h/weekly windows, drift, pause state).",
@@ -14098,12 +14124,16 @@ ${formatted}`
14098
14124
  },
14099
14125
  require_reply: {
14100
14126
  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."
14127
+ 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
14128
  },
14103
14129
  on_busy: {
14104
14130
  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).`
14131
+ enum: ["reject", "steer", "interrupt"],
14132
+ 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.'
14133
+ },
14134
+ idempotency_key: {
14135
+ type: "string",
14136
+ 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
14137
  }
14108
14138
  },
14109
14139
  required: ["text"]
@@ -14163,19 +14193,29 @@ ${formatted}`
14163
14193
  }
14164
14194
  const requireReply = args?.require_reply === true;
14165
14195
  const onBusyRaw = args?.on_busy;
14166
- const onBusy = onBusyRaw === "steer" ? "steer" : "reject";
14167
- if (onBusyRaw !== undefined && onBusyRaw !== "reject" && onBusyRaw !== "steer") {
14196
+ if (onBusyRaw !== undefined && onBusyRaw !== "reject" && onBusyRaw !== "steer" && onBusyRaw !== "interrupt") {
14168
14197
  return {
14169
- content: [{ type: "text", text: `Error: invalid on_busy value ${JSON.stringify(onBusyRaw)} \u2014 use "reject" or "steer".` }],
14198
+ content: [{ type: "text", text: `Error: invalid on_busy value ${JSON.stringify(onBusyRaw)} \u2014 use "reject", "steer" or "interrupt".` }],
14170
14199
  isError: true
14171
14200
  };
14172
14201
  }
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
- };
14202
+ const onBusy = onBusyRaw === "steer" || onBusyRaw === "interrupt" ? onBusyRaw : "reject";
14203
+ const idempotencyKeyRaw = args?.idempotency_key;
14204
+ if (idempotencyKeyRaw !== undefined) {
14205
+ if (typeof idempotencyKeyRaw !== "string" || idempotencyKeyRaw.length === 0) {
14206
+ return {
14207
+ content: [{ type: "text", text: "Error: idempotency_key must be a non-empty string." }],
14208
+ isError: true
14209
+ };
14210
+ }
14211
+ if (idempotencyKeyRaw.length > 128) {
14212
+ return {
14213
+ content: [{ type: "text", text: `Error: idempotency_key is too long (${idempotencyKeyRaw.length} chars, max 128).` }],
14214
+ isError: true
14215
+ };
14216
+ }
14178
14217
  }
14218
+ const idempotencyKey = idempotencyKeyRaw;
14179
14219
  const bridgeMsg = {
14180
14220
  id: args?.chat_id ?? `reply_${Date.now()}`,
14181
14221
  source: "claude",
@@ -14189,16 +14229,22 @@ ${formatted}`
14189
14229
  isError: true
14190
14230
  };
14191
14231
  }
14192
- const result = await this.replySender(bridgeMsg, requireReply, onBusy);
14232
+ const result = await this.replySender(bridgeMsg, requireReply, onBusy, idempotencyKey);
14193
14233
  if (!result.success) {
14194
- this.log(`Reply delivery failed: ${result.error}`);
14234
+ this.log(`Reply delivery failed: ${result.error}${result.code ? ` (code=${result.code})` : ""}`);
14235
+ const codePrefix = result.code ? ` [${result.code}]` : "";
14195
14236
  return {
14196
- content: [{ type: "text", text: `Error: ${result.error}` }],
14237
+ content: [{ type: "text", text: `Error${codePrefix}: ${result.error}` }],
14197
14238
  isError: true
14198
14239
  };
14199
14240
  }
14200
14241
  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.";
14242
+ let responseText = "Reply sent to Codex.";
14243
+ if (onBusy === "steer") {
14244
+ 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).";
14245
+ } else if (onBusy === "interrupt") {
14246
+ 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).";
14247
+ }
14202
14248
  if (pending > 0) {
14203
14249
  responseText += ` Note: ${pending} unread Codex message${pending > 1 ? "s" : ""} already waiting \u2014 call get_messages to read them.`;
14204
14250
  }
@@ -14227,8 +14273,8 @@ function defineNumber(value, fallback) {
14227
14273
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
14228
14274
  }
14229
14275
  var BUILD_INFO = Object.freeze({
14230
- version: defineString("0.1.10", "0.0.0-source"),
14231
- commit: defineString("51a44cb", "source"),
14276
+ version: defineString("0.1.12", "0.0.0-source"),
14277
+ commit: defineString("eec6018", "source"),
14232
14278
  bundle: defineBundle("plugin"),
14233
14279
  contractVersion: defineNumber(1, CONTRACT_VERSION)
14234
14280
  });
@@ -14257,6 +14303,11 @@ var CLOSE_CODE_EVICTED_STALE = 4002;
14257
14303
  var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
14258
14304
  var CLOSE_CODE_PAIR_MISMATCH = 4004;
14259
14305
 
14306
+ // src/interrupt-timing.ts
14307
+ var CLIENT_REPLY_TIMEOUT_MS = 15000;
14308
+ var INTERRUPT_CLIENT_MARGIN_MS = 2000;
14309
+ var MAX_INTERRUPT_TIMEOUT_MS = CLIENT_REPLY_TIMEOUT_MS - INTERRUPT_CLIENT_MARGIN_MS;
14310
+
14260
14311
  // src/daemon-client.ts
14261
14312
  var nextSocketId = 0;
14262
14313
 
@@ -14400,7 +14451,7 @@ class DaemonClient extends EventEmitter2 {
14400
14451
  this.ws = null;
14401
14452
  this.rejectPendingReplies("Daemon connection closed");
14402
14453
  }
14403
- async sendReply(message, requireReply, onBusy) {
14454
+ async sendReply(message, requireReply, onBusy, idempotencyKey) {
14404
14455
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14405
14456
  return { success: false, error: "AgentBridge daemon is not connected." };
14406
14457
  }
@@ -14409,14 +14460,15 @@ class DaemonClient extends EventEmitter2 {
14409
14460
  const timer = setTimeout(() => {
14410
14461
  this.pendingReplies.delete(requestId);
14411
14462
  resolve({ success: false, error: "Timed out waiting for AgentBridge daemon reply." });
14412
- }, 15000);
14463
+ }, CLIENT_REPLY_TIMEOUT_MS);
14413
14464
  this.pendingReplies.set(requestId, { resolve, timer });
14414
14465
  this.send({
14415
14466
  type: "claude_to_codex",
14416
14467
  requestId,
14417
14468
  message,
14418
14469
  ...requireReply ? { requireReply: true } : {},
14419
- ...onBusy && onBusy !== "reject" ? { onBusy } : {}
14470
+ ...onBusy && onBusy !== "reject" ? { onBusy } : {},
14471
+ ...idempotencyKey ? { idempotencyKey } : {}
14420
14472
  });
14421
14473
  });
14422
14474
  }
@@ -14439,9 +14491,23 @@ class DaemonClient extends EventEmitter2 {
14439
14491
  return;
14440
14492
  clearTimeout(pending.timer);
14441
14493
  this.pendingReplies.delete(message.requestId);
14442
- pending.resolve({ success: message.success, error: message.error });
14494
+ pending.resolve({
14495
+ success: message.success,
14496
+ error: message.error,
14497
+ ...message.code !== undefined ? { code: message.code } : {},
14498
+ ...message.phase !== undefined ? { phase: message.phase } : {},
14499
+ ...message.retryAfterMs !== undefined ? { retryAfterMs: message.retryAfterMs } : {}
14500
+ });
14443
14501
  return;
14444
14502
  }
14503
+ case "turn_started":
14504
+ this.emit("turnStarted", {
14505
+ requestId: message.requestId,
14506
+ ...message.idempotencyKey !== undefined ? { idempotencyKey: message.idempotencyKey } : {},
14507
+ threadId: message.threadId,
14508
+ turnId: message.turnId
14509
+ });
14510
+ return;
14445
14511
  case "status":
14446
14512
  this.emit("status", message.status);
14447
14513
  return;
@@ -14485,7 +14551,7 @@ class DaemonClient extends EventEmitter2 {
14485
14551
  }
14486
14552
 
14487
14553
  // src/daemon-lifecycle.ts
14488
- import { spawn, execFileSync } from "child_process";
14554
+ import { spawn } from "child_process";
14489
14555
  import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
14490
14556
  import { fileURLToPath } from "url";
14491
14557
 
@@ -14502,6 +14568,41 @@ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env)
14502
14568
  return parsed;
14503
14569
  }
14504
14570
 
14571
+ // src/process-lifecycle.ts
14572
+ import { execFileSync } from "child_process";
14573
+ function commandForPid(pid) {
14574
+ try {
14575
+ return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
14576
+ } catch {
14577
+ return null;
14578
+ }
14579
+ }
14580
+ function pidLooksAlive(pid) {
14581
+ if (!Number.isInteger(pid) || pid <= 0)
14582
+ return false;
14583
+ try {
14584
+ process.kill(pid, 0);
14585
+ return true;
14586
+ } catch (err) {
14587
+ return err?.code === "EPERM";
14588
+ }
14589
+ }
14590
+ var isProcessAlive = pidLooksAlive;
14591
+ function isAgentBridgeDaemon(pid, lookup = commandForPid) {
14592
+ const cmd = lookup(pid);
14593
+ if (cmd === null)
14594
+ return false;
14595
+ const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
14596
+ const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
14597
+ return hasDaemonEntry && hasAgentbridge;
14598
+ }
14599
+ function isAgentBridgeProcess(pid, lookup = commandForPid) {
14600
+ const cmd = lookup(pid);
14601
+ if (cmd === null)
14602
+ return false;
14603
+ return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
14604
+ }
14605
+
14505
14606
  // src/daemon-lifecycle.ts
14506
14607
  var DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
14507
14608
  var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
@@ -14510,6 +14611,48 @@ var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES",
14510
14611
  var REUSE_READY_DELAY_MS = 250;
14511
14612
  var HEALTH_FETCH_TIMEOUT_MS = 500;
14512
14613
  var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
14614
+ function isReuseVerdict(verdict) {
14615
+ return verdict === "reuse" || verdict === "reuse-despite-drift";
14616
+ }
14617
+ function classifyDaemon(expectedPairId, status, buildInfo) {
14618
+ if (!status) {
14619
+ return { verdict: "unreachable", reason: "daemon status is unavailable or unparseable" };
14620
+ }
14621
+ const reportedPairId = status.pairId;
14622
+ if (!expectedPairId && reportedPairId != null) {
14623
+ return {
14624
+ verdict: "manual-conflict",
14625
+ reason: `manual mode must not adopt registered pair ${reportedPairId}`
14626
+ };
14627
+ }
14628
+ if (expectedPairId) {
14629
+ if (reportedPairId == null) {
14630
+ return {
14631
+ verdict: "replace-foreign",
14632
+ reason: `pair ${expectedPairId} found daemon without pair identity`
14633
+ };
14634
+ }
14635
+ if (reportedPairId !== expectedPairId) {
14636
+ return {
14637
+ verdict: "replace-foreign",
14638
+ reason: `pair ${expectedPairId} found daemon for pair ${reportedPairId}`
14639
+ };
14640
+ }
14641
+ }
14642
+ if (!sameRuntimeContract(status.build, buildInfo)) {
14643
+ if (compatibleContractVersion(status.build, buildInfo) && status.tuiConnected === true) {
14644
+ return {
14645
+ verdict: "reuse-despite-drift",
14646
+ reason: "runtime build drift has a compatible contract and a live Codex TUI is attached"
14647
+ };
14648
+ }
14649
+ return {
14650
+ verdict: "replace-drifted",
14651
+ reason: `runtime build ${formatBuildInfo(status.build)} does not match launcher ${formatBuildInfo(buildInfo)}`
14652
+ };
14653
+ }
14654
+ return { verdict: "reuse", reason: "daemon pair and runtime contract match" };
14655
+ }
14513
14656
 
14514
14657
  class DaemonLifecycle {
14515
14658
  stateDir;
@@ -14542,52 +14685,37 @@ class DaemonLifecycle {
14542
14685
  return null;
14543
14686
  }
14544
14687
  }
14545
- isForeignDaemon(status) {
14546
- const expected = this.expectedPairId;
14547
- if (!expected)
14548
- return false;
14549
- if (!status)
14550
- return false;
14551
- const reported = status.pairId;
14552
- if (reported == null)
14553
- return true;
14554
- return reported !== expected;
14555
- }
14556
- isRegisteredPairDaemonInManualMode(status) {
14557
- return !this.expectedPairId && status?.pairId != null;
14558
- }
14559
- isBuildDrifted(status) {
14560
- if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
14561
- return false;
14562
- const runtime = status?.build;
14563
- if (!runtime)
14564
- return true;
14565
- return !sameRuntimeContract(runtime, BUILD_INFO);
14688
+ classifyDaemon(status) {
14689
+ const classification = classifyDaemon(this.expectedPairId, status, BUILD_INFO);
14690
+ if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1" && (classification.verdict === "replace-drifted" || classification.verdict === "unreachable")) {
14691
+ return { verdict: "reuse", reason: "build drift replacement disabled by AGENTBRIDGE_ALLOW_BUILD_DRIFT" };
14692
+ }
14693
+ return classification;
14566
14694
  }
14567
- canReuseDespiteDrift(status) {
14568
- if (!compatibleContractVersion(status?.build, BUILD_INFO))
14569
- return false;
14570
- return status?.tuiConnected === true;
14695
+ manualConflictError(status) {
14696
+ 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.`);
14571
14697
  }
14572
14698
  async ensureRunning() {
14573
14699
  if (await this.isHealthy()) {
14574
14700
  const status = await this.fetchStatus();
14575
- if (this.isRegisteredPairDaemonInManualMode(status)) {
14576
- 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.`);
14577
- }
14578
- if (this.isForeignDaemon(status)) {
14579
- 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`);
14580
- await this.replaceUnhealthyDaemon(status?.pid);
14581
- return;
14582
- }
14583
- if (this.isBuildDrifted(status)) {
14584
- if (this.canReuseDespiteDrift(status)) {
14585
- 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)`);
14586
- } else {
14701
+ const classification = this.classifyDaemon(status);
14702
+ switch (classification.verdict) {
14703
+ case "manual-conflict":
14704
+ throw this.manualConflictError(status);
14705
+ case "replace-foreign":
14706
+ 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`);
14707
+ await this.replaceUnhealthyDaemon(status?.pid);
14708
+ return;
14709
+ case "replace-drifted":
14710
+ case "unreachable":
14587
14711
  this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
14588
14712
  await this.replaceUnhealthyDaemon(status?.pid);
14589
14713
  return;
14590
- }
14714
+ case "reuse-despite-drift":
14715
+ 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)`);
14716
+ break;
14717
+ case "reuse":
14718
+ break;
14591
14719
  }
14592
14720
  try {
14593
14721
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
@@ -14601,7 +14729,7 @@ class DaemonLifecycle {
14601
14729
  const existingPid = this.readPid();
14602
14730
  if (existingPid) {
14603
14731
  if (isProcessAlive(existingPid)) {
14604
- if (this.isDaemonProcess(existingPid)) {
14732
+ if (isAgentBridgeDaemon(existingPid)) {
14605
14733
  try {
14606
14734
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
14607
14735
  return;
@@ -14617,14 +14745,17 @@ class DaemonLifecycle {
14617
14745
  }
14618
14746
  await this.withStartupLockStrict(async (locked) => {
14619
14747
  if (!locked) {
14620
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
14621
- await this.waitForReadyAndOurs();
14748
+ await this.waitForContendedStartupLock();
14622
14749
  return;
14623
14750
  }
14624
14751
  if (await this.isHealthy()) {
14625
14752
  const status = await this.fetchStatus();
14626
- if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
14627
- this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
14753
+ const classification = this.classifyDaemon(status);
14754
+ if (classification.verdict === "manual-conflict") {
14755
+ throw this.manualConflictError(status);
14756
+ }
14757
+ if (!isReuseVerdict(classification.verdict)) {
14758
+ 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`);
14628
14759
  await this.kill(3000, status?.pid);
14629
14760
  } else {
14630
14761
  try {
@@ -14676,7 +14807,11 @@ class DaemonLifecycle {
14676
14807
  for (let attempt = 0;attempt < maxRetries; attempt++) {
14677
14808
  if (await this.isReady()) {
14678
14809
  const status = await this.fetchStatus();
14679
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
14810
+ const classification = this.classifyDaemon(status);
14811
+ if (classification.verdict === "manual-conflict") {
14812
+ throw this.manualConflictError(status);
14813
+ }
14814
+ if (isReuseVerdict(classification.verdict)) {
14680
14815
  return;
14681
14816
  }
14682
14817
  }
@@ -14758,13 +14893,16 @@ class DaemonLifecycle {
14758
14893
  async replaceUnhealthyDaemon(statusPid) {
14759
14894
  await this.withStartupLockStrict(async (locked) => {
14760
14895
  if (!locked) {
14761
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
14762
- await this.waitForReadyAndOurs();
14896
+ await this.waitForContendedStartupLock();
14763
14897
  return;
14764
14898
  }
14765
14899
  if (await this.isHealthy()) {
14766
14900
  const status = await this.fetchStatus();
14767
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
14901
+ const classification = this.classifyDaemon(status);
14902
+ if (classification.verdict === "manual-conflict") {
14903
+ throw this.manualConflictError(status);
14904
+ }
14905
+ if (isReuseVerdict(classification.verdict)) {
14768
14906
  try {
14769
14907
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
14770
14908
  return;
@@ -14777,6 +14915,10 @@ class DaemonLifecycle {
14777
14915
  await this.waitForReady();
14778
14916
  });
14779
14917
  }
14918
+ async waitForContendedStartupLock() {
14919
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
14920
+ await this.waitForReadyAndOurs();
14921
+ }
14780
14922
  async withStartupLockStrict(fn) {
14781
14923
  const locked = this.acquireLockStrict();
14782
14924
  try {
@@ -14812,7 +14954,7 @@ class DaemonLifecycle {
14812
14954
  this.releaseLock();
14813
14955
  return this.acquireLockStrict(true);
14814
14956
  }
14815
- if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !this.isAgentBridgeProcess(holderPid)) {
14957
+ if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !isAgentBridgeProcess(holderPid)) {
14816
14958
  this.log(`Startup lock is ${Math.round(this.lockAgeMs() / 1000)}s old and holder pid ${holderPid} ` + `is an unrelated process (pid recycled), reclaiming`);
14817
14959
  this.releaseLock();
14818
14960
  return this.acquireLockStrict(true);
@@ -14833,14 +14975,6 @@ class DaemonLifecycle {
14833
14975
  return 0;
14834
14976
  }
14835
14977
  }
14836
- isAgentBridgeProcess(pid) {
14837
- try {
14838
- const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
14839
- return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
14840
- } catch {
14841
- return false;
14842
- }
14843
- }
14844
14978
  releaseLock() {
14845
14979
  try {
14846
14980
  unlinkSync2(this.stateDir.lockFile);
@@ -14858,7 +14992,7 @@ class DaemonLifecycle {
14858
14992
  this.cleanup();
14859
14993
  return false;
14860
14994
  }
14861
- if (!this.isDaemonProcess(pid)) {
14995
+ if (!isAgentBridgeDaemon(pid)) {
14862
14996
  this.log(`Pid ${pid} is alive but is NOT an AgentBridge daemon \u2014 refusing to kill. Cleaning up stale pid file.`);
14863
14997
  this.cleanup();
14864
14998
  return false;
@@ -14886,16 +15020,6 @@ class DaemonLifecycle {
14886
15020
  this.cleanup();
14887
15021
  return true;
14888
15022
  }
14889
- isDaemonProcess(pid) {
14890
- try {
14891
- const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
14892
- const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
14893
- const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
14894
- return hasDaemonEntry && hasAgentbridge;
14895
- } catch {
14896
- return false;
14897
- }
14898
- }
14899
15023
  cleanup() {
14900
15024
  this.removePidFile();
14901
15025
  this.removeStatusFile();
@@ -14910,21 +15034,13 @@ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
14910
15034
  clearTimeout(timer);
14911
15035
  }
14912
15036
  }
14913
- function isProcessAlive(pid) {
14914
- try {
14915
- process.kill(pid, 0);
14916
- return true;
14917
- } catch {
14918
- return false;
14919
- }
14920
- }
14921
15037
 
14922
15038
  // src/config-service.ts
14923
15039
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
14924
15040
  import { join as join2 } from "path";
14925
15041
  var DEFAULT_BUDGET_CONFIG = {
14926
15042
  enabled: true,
14927
- pollSeconds: 60,
15043
+ pollSeconds: 300,
14928
15044
  pauseAt: 90,
14929
15045
  resumeBelow: 30,
14930
15046
  syncDriftPct: 10,
@@ -14953,9 +15069,52 @@ var DEFAULT_CONFIG = {
14953
15069
  };
14954
15070
  var CONFIG_DIR = ".agentbridge";
14955
15071
  var CONFIG_FILE = "config.json";
15072
+ var NOOP_LOGGER = () => {};
14956
15073
  function isRecord(value) {
14957
15074
  return typeof value === "object" && value !== null && !Array.isArray(value);
14958
15075
  }
15076
+ function isCoercibleNumber(value) {
15077
+ if (typeof value === "number")
15078
+ return Number.isFinite(value);
15079
+ if (typeof value === "string")
15080
+ return Number.isFinite(Number(value));
15081
+ return false;
15082
+ }
15083
+ function findShapeViolation(raw) {
15084
+ if ("idleShutdownSeconds" in raw && !isCoercibleNumber(raw.idleShutdownSeconds)) {
15085
+ return "idleShutdownSeconds is present but not a number";
15086
+ }
15087
+ if ("budget" in raw) {
15088
+ const budget = raw.budget;
15089
+ if (!isRecord(budget)) {
15090
+ return "budget is present but not an object";
15091
+ }
15092
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
15093
+ for (const key of numericKeys) {
15094
+ if (key in budget && !isCoercibleNumber(budget[key])) {
15095
+ return `budget.${key} is present but not a number`;
15096
+ }
15097
+ }
15098
+ if ("parallel" in budget) {
15099
+ const parallel = budget.parallel;
15100
+ if (!isRecord(parallel)) {
15101
+ return "budget.parallel is present but not an object";
15102
+ }
15103
+ for (const key of ["minRemainingPct", "timeWindowSec"]) {
15104
+ if (key in parallel && !isCoercibleNumber(parallel[key])) {
15105
+ return `budget.parallel.${key} is present but not a number`;
15106
+ }
15107
+ }
15108
+ }
15109
+ }
15110
+ return null;
15111
+ }
15112
+ function hasCustomDecisionValues(config2) {
15113
+ const d = DEFAULT_CONFIG;
15114
+ const b = config2.budget;
15115
+ const db = d.budget;
15116
+ 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;
15117
+ }
14959
15118
  function normalizeInteger(value, fallback) {
14960
15119
  if (typeof value === "number" && Number.isFinite(value))
14961
15120
  return value;
@@ -14991,35 +15150,35 @@ function normalizeCodexOverride(raw) {
14991
15150
  override.effort = raw.effort.trim();
14992
15151
  return Object.keys(override).length > 0 ? override : null;
14993
15152
  }
14994
- function normalizeCodexTiers(raw) {
15153
+ function normalizeCodexTiers(raw, fallback = DEFAULT_BUDGET_CONFIG.codexTiers) {
14995
15154
  const tiers = isRecord(raw) ? raw : {};
14996
15155
  return {
14997
15156
  full: normalizeCodexOverride(tiers.full),
14998
- balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
14999
- eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
15157
+ balanced: normalizeCodexOverride(tiers.balanced) ?? fallback.balanced,
15158
+ eco: normalizeCodexOverride(tiers.eco) ?? fallback.eco
15000
15159
  };
15001
15160
  }
15002
- function normalizeBudgetConfig(raw) {
15161
+ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
15003
15162
  const budget = isRecord(raw) ? raw : {};
15004
15163
  const parallel = isRecord(budget.parallel) ? budget.parallel : {};
15005
- const codexTiers = normalizeCodexTiers(budget.codexTiers);
15006
- let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
15007
- let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
15164
+ const codexTiers = normalizeCodexTiers(budget.codexTiers, fallback.codexTiers);
15165
+ let pauseAt = normalizeBoundedInteger(budget.pauseAt, fallback.pauseAt, 1, 100);
15166
+ let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, fallback.resumeBelow, 0, 99);
15008
15167
  if (pauseAt <= resumeBelow) {
15009
15168
  pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
15010
15169
  resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
15011
15170
  }
15012
15171
  return {
15013
- enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
15014
- pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
15172
+ enabled: normalizeBoolean(budget.enabled, fallback.enabled),
15173
+ pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
15015
15174
  pauseAt,
15016
15175
  resumeBelow,
15017
- syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
15176
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
15018
15177
  parallel: {
15019
- minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
15020
- timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
15178
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, fallback.parallel.minRemainingPct, 1, 100),
15179
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
15021
15180
  },
15022
- codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
15181
+ codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
15023
15182
  codexTiers
15024
15183
  };
15025
15184
  }
@@ -15056,15 +15215,59 @@ class ConfigService {
15056
15215
  return existsSync4(this.configPath);
15057
15216
  }
15058
15217
  load() {
15218
+ let raw;
15059
15219
  try {
15060
- const raw = readFileSync2(this.configPath, "utf-8");
15061
- return normalizeConfig(JSON.parse(raw));
15062
- } catch {
15063
- return null;
15220
+ raw = readFileSync2(this.configPath, "utf-8");
15221
+ } catch (err) {
15222
+ if (err?.code === "ENOENT") {
15223
+ return { state: "absent" };
15224
+ }
15225
+ return { state: "corrupt", reason: `config.json is unreadable: ${err.message}` };
15064
15226
  }
15227
+ let parsed;
15228
+ try {
15229
+ parsed = JSON.parse(raw);
15230
+ } catch (err) {
15231
+ return {
15232
+ state: "corrupt",
15233
+ reason: `config.json is not valid JSON: ${err.message}`
15234
+ };
15235
+ }
15236
+ if (!isRecord(parsed)) {
15237
+ return { state: "corrupt", reason: "config.json is not a JSON object" };
15238
+ }
15239
+ const violation = findShapeViolation(parsed);
15240
+ if (violation) {
15241
+ return { state: "corrupt", reason: `config.json is shape-invalid: ${violation}` };
15242
+ }
15243
+ const config2 = normalizeConfig(parsed);
15244
+ if (!config2) {
15245
+ return { state: "corrupt", reason: "config.json could not be normalized" };
15246
+ }
15247
+ return { state: "parsed", config: config2 };
15248
+ }
15249
+ loadOrDefault(log = NOOP_LOGGER) {
15250
+ const result = this.load();
15251
+ if (result.state === "parsed")
15252
+ return result.config;
15253
+ if (result.state === "corrupt") {
15254
+ 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.");
15255
+ }
15256
+ return structuredClone(DEFAULT_CONFIG);
15065
15257
  }
15066
- loadOrDefault() {
15067
- return this.load() ?? structuredClone(DEFAULT_CONFIG);
15258
+ describeConfig() {
15259
+ const result = this.load();
15260
+ if (result.state === "absent") {
15261
+ return { state: "absent", path: this.configPath, customValues: false };
15262
+ }
15263
+ if (result.state === "corrupt") {
15264
+ return { state: "corrupt", path: this.configPath, reason: result.reason, customValues: false };
15265
+ }
15266
+ return {
15267
+ state: "parsed",
15268
+ path: this.configPath,
15269
+ customValues: hasCustomDecisionValues(result.config)
15270
+ };
15068
15271
  }
15069
15272
  save(config2) {
15070
15273
  this.ensureConfigDir();
@@ -15297,8 +15500,10 @@ function nonEmpty(value) {
15297
15500
  }
15298
15501
 
15299
15502
  // src/trace-log.ts
15300
- import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
15503
+ import { appendFileSync as appendFileSync2, existsSync as existsSync6, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync as statSync4, unlinkSync as unlinkSync4 } from "fs";
15301
15504
  import { join as join4 } from "path";
15505
+ var TRACE_RETENTION_DAYS = 7;
15506
+ var TRACE_FILE_RE = /^trace-\d{4}-\d{2}-\d{2}\.jsonl$/;
15302
15507
  var SECRET_KEY_RE = /(token|secret|password|passwd|api[_-]?key|auth|cookie|session)/i;
15303
15508
  var SECRET_ARG_RE = /^--?(?:token|secret|password|passwd|apikey|api-key|api_key|auth|cookie|session)(?:=.*)?$/i;
15304
15509
  var RELEVANT_ENV_RE = /^(AGENTBRIDGE_|CODEX_)/;
@@ -15350,11 +15555,39 @@ function appendTraceEvent(input) {
15350
15555
  ...input.env ? { env: pickRelevantEnv(input.env) } : {},
15351
15556
  ...input.data ? { data: redactData(input.data) } : {}
15352
15557
  };
15353
- mkdirSync4(join4(input.cwd, ".agentbridge", "logs"), { recursive: true });
15558
+ const logsDir = join4(input.cwd, ".agentbridge", "logs");
15559
+ const isNewDayFile = !existsSync6(path);
15560
+ mkdirSync4(logsDir, { recursive: true });
15561
+ if (isNewDayFile) {
15562
+ pruneOldTraceLogs(logsDir, path, Date.parse(timestamp));
15563
+ }
15354
15564
  appendFileSync2(path, JSON.stringify(event) + `
15355
15565
  `, "utf-8");
15356
15566
  return path;
15357
15567
  }
15568
+ function pruneOldTraceLogs(logsDir, keepPath, nowMs) {
15569
+ if (!Number.isFinite(nowMs))
15570
+ return;
15571
+ const cutoff = nowMs - TRACE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
15572
+ let entries;
15573
+ try {
15574
+ entries = readdirSync2(logsDir);
15575
+ } catch {
15576
+ return;
15577
+ }
15578
+ for (const name of entries) {
15579
+ if (!TRACE_FILE_RE.test(name))
15580
+ continue;
15581
+ const filePath = join4(logsDir, name);
15582
+ if (filePath === keepPath)
15583
+ continue;
15584
+ try {
15585
+ if (statSync4(filePath).mtimeMs < cutoff) {
15586
+ unlinkSync4(filePath);
15587
+ }
15588
+ } catch {}
15589
+ }
15590
+ }
15358
15591
  function isEnvSnapshot(key, value) {
15359
15592
  return /env$/i.test(key) && !!value && typeof value === "object" && !Array.isArray(value);
15360
15593
  }
@@ -15393,10 +15626,10 @@ var envGuardResult = guardAgentBridgeEnv({
15393
15626
  });
15394
15627
  var stateDir = new StateDirResolver;
15395
15628
  stateDir.ensure();
15629
+ var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
15396
15630
  var configService = new ConfigService;
15397
- var config2 = configService.loadOrDefault();
15631
+ var config2 = configService.loadOrDefault(processLogger.log);
15398
15632
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
15399
- var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
15400
15633
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
15401
15634
  var CONTROL_WS_URL = daemonLifecycle.controlWsUrl;
15402
15635
  var claude = new ClaudeAdapter(stateDir.logFile);
@@ -15436,7 +15669,7 @@ if (process.env.AGENTBRIDGE_TRACE === "1") {
15436
15669
  });
15437
15670
  } catch {}
15438
15671
  }
15439
- claude.setReplySender(async (msg, requireReply, onBusy) => {
15672
+ claude.setReplySender(async (msg, requireReply, onBusy, idempotencyKey) => {
15440
15673
  if (msg.source !== "claude") {
15441
15674
  return { success: false, error: "Invalid message source" };
15442
15675
  }
@@ -15446,7 +15679,10 @@ claude.setReplySender(async (msg, requireReply, onBusy) => {
15446
15679
  error: disabledReplyError(daemonDisabledReason ?? "killed")
15447
15680
  };
15448
15681
  }
15449
- return daemonClient.sendReply(msg, requireReply, onBusy);
15682
+ return daemonClient.sendReply(msg, requireReply, onBusy, idempotencyKey);
15683
+ });
15684
+ daemonClient.on("turnStarted", ({ requestId, idempotencyKey, threadId, turnId }) => {
15685
+ log(`Codex turn started for reply ${requestId} (turn=${turnId}, thread=${threadId}` + `${idempotencyKey ? `, idempotencyKey=${idempotencyKey}` : ""})`);
15450
15686
  });
15451
15687
  daemonClient.on("codexMessage", (message) => {
15452
15688
  log(`Forwarding daemon \u2192 Claude (${message.content.length} chars)`);
@@ -15589,7 +15825,7 @@ async function notifyIfDaemonKilled(logMessage) {
15589
15825
  return true;
15590
15826
  }
15591
15827
  async function notifyIfPairRemoved(logMessage) {
15592
- if (existsSync6(stateDir.dir))
15828
+ if (existsSync7(stateDir.dir))
15593
15829
  return false;
15594
15830
  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`);
15595
15831
  return true;