@raysonmeng/agentbridge 0.1.11 → 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.11", "0.0.0-source"),
14231
- commit: defineString("48eb0ed", "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;
@@ -14545,6 +14611,48 @@ var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES",
14545
14611
  var REUSE_READY_DELAY_MS = 250;
14546
14612
  var HEALTH_FETCH_TIMEOUT_MS = 500;
14547
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
+ }
14548
14656
 
14549
14657
  class DaemonLifecycle {
14550
14658
  stateDir;
@@ -14577,52 +14685,37 @@ class DaemonLifecycle {
14577
14685
  return null;
14578
14686
  }
14579
14687
  }
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);
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;
14601
14694
  }
14602
- canReuseDespiteDrift(status) {
14603
- if (!compatibleContractVersion(status?.build, BUILD_INFO))
14604
- return false;
14605
- 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.`);
14606
14697
  }
14607
14698
  async ensureRunning() {
14608
14699
  if (await this.isHealthy()) {
14609
14700
  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 {
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":
14622
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`);
14623
14712
  await this.replaceUnhealthyDaemon(status?.pid);
14624
14713
  return;
14625
- }
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;
14626
14719
  }
14627
14720
  try {
14628
14721
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
@@ -14652,14 +14745,17 @@ class DaemonLifecycle {
14652
14745
  }
14653
14746
  await this.withStartupLockStrict(async (locked) => {
14654
14747
  if (!locked) {
14655
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
14656
- await this.waitForReadyAndOurs();
14748
+ await this.waitForContendedStartupLock();
14657
14749
  return;
14658
14750
  }
14659
14751
  if (await this.isHealthy()) {
14660
14752
  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`);
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`);
14663
14759
  await this.kill(3000, status?.pid);
14664
14760
  } else {
14665
14761
  try {
@@ -14711,7 +14807,11 @@ class DaemonLifecycle {
14711
14807
  for (let attempt = 0;attempt < maxRetries; attempt++) {
14712
14808
  if (await this.isReady()) {
14713
14809
  const status = await this.fetchStatus();
14714
- 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)) {
14715
14815
  return;
14716
14816
  }
14717
14817
  }
@@ -14793,13 +14893,16 @@ class DaemonLifecycle {
14793
14893
  async replaceUnhealthyDaemon(statusPid) {
14794
14894
  await this.withStartupLockStrict(async (locked) => {
14795
14895
  if (!locked) {
14796
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
14797
- await this.waitForReadyAndOurs();
14896
+ await this.waitForContendedStartupLock();
14798
14897
  return;
14799
14898
  }
14800
14899
  if (await this.isHealthy()) {
14801
14900
  const status = await this.fetchStatus();
14802
- 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)) {
14803
14906
  try {
14804
14907
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
14805
14908
  return;
@@ -14812,6 +14915,10 @@ class DaemonLifecycle {
14812
14915
  await this.waitForReady();
14813
14916
  });
14814
14917
  }
14918
+ async waitForContendedStartupLock() {
14919
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
14920
+ await this.waitForReadyAndOurs();
14921
+ }
14815
14922
  async withStartupLockStrict(fn) {
14816
14923
  const locked = this.acquireLockStrict();
14817
14924
  try {
@@ -14933,7 +15040,7 @@ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSy
14933
15040
  import { join as join2 } from "path";
14934
15041
  var DEFAULT_BUDGET_CONFIG = {
14935
15042
  enabled: true,
14936
- pollSeconds: 60,
15043
+ pollSeconds: 300,
14937
15044
  pauseAt: 90,
14938
15045
  resumeBelow: 30,
14939
15046
  syncDriftPct: 10,
@@ -14962,9 +15069,52 @@ var DEFAULT_CONFIG = {
14962
15069
  };
14963
15070
  var CONFIG_DIR = ".agentbridge";
14964
15071
  var CONFIG_FILE = "config.json";
15072
+ var NOOP_LOGGER = () => {};
14965
15073
  function isRecord(value) {
14966
15074
  return typeof value === "object" && value !== null && !Array.isArray(value);
14967
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
+ }
14968
15118
  function normalizeInteger(value, fallback) {
14969
15119
  if (typeof value === "number" && Number.isFinite(value))
14970
15120
  return value;
@@ -15000,35 +15150,35 @@ function normalizeCodexOverride(raw) {
15000
15150
  override.effort = raw.effort.trim();
15001
15151
  return Object.keys(override).length > 0 ? override : null;
15002
15152
  }
15003
- function normalizeCodexTiers(raw) {
15153
+ function normalizeCodexTiers(raw, fallback = DEFAULT_BUDGET_CONFIG.codexTiers) {
15004
15154
  const tiers = isRecord(raw) ? raw : {};
15005
15155
  return {
15006
15156
  full: normalizeCodexOverride(tiers.full),
15007
- balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
15008
- eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
15157
+ balanced: normalizeCodexOverride(tiers.balanced) ?? fallback.balanced,
15158
+ eco: normalizeCodexOverride(tiers.eco) ?? fallback.eco
15009
15159
  };
15010
15160
  }
15011
- function normalizeBudgetConfig(raw) {
15161
+ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
15012
15162
  const budget = isRecord(raw) ? raw : {};
15013
15163
  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);
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);
15017
15167
  if (pauseAt <= resumeBelow) {
15018
15168
  pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
15019
15169
  resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
15020
15170
  }
15021
15171
  return {
15022
- enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
15023
- 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),
15024
15174
  pauseAt,
15025
15175
  resumeBelow,
15026
- syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
15176
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
15027
15177
  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)
15178
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, fallback.parallel.minRemainingPct, 1, 100),
15179
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
15030
15180
  },
15031
- codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
15181
+ codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
15032
15182
  codexTiers
15033
15183
  };
15034
15184
  }
@@ -15065,15 +15215,59 @@ class ConfigService {
15065
15215
  return existsSync4(this.configPath);
15066
15216
  }
15067
15217
  load() {
15218
+ let raw;
15068
15219
  try {
15069
- const raw = readFileSync2(this.configPath, "utf-8");
15070
- return normalizeConfig(JSON.parse(raw));
15071
- } catch {
15072
- 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}` };
15073
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 };
15074
15248
  }
15075
- loadOrDefault() {
15076
- return this.load() ?? structuredClone(DEFAULT_CONFIG);
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);
15257
+ }
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
+ };
15077
15271
  }
15078
15272
  save(config2) {
15079
15273
  this.ensureConfigDir();
@@ -15306,8 +15500,10 @@ function nonEmpty(value) {
15306
15500
  }
15307
15501
 
15308
15502
  // src/trace-log.ts
15309
- 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";
15310
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$/;
15311
15507
  var SECRET_KEY_RE = /(token|secret|password|passwd|api[_-]?key|auth|cookie|session)/i;
15312
15508
  var SECRET_ARG_RE = /^--?(?:token|secret|password|passwd|apikey|api-key|api_key|auth|cookie|session)(?:=.*)?$/i;
15313
15509
  var RELEVANT_ENV_RE = /^(AGENTBRIDGE_|CODEX_)/;
@@ -15359,11 +15555,39 @@ function appendTraceEvent(input) {
15359
15555
  ...input.env ? { env: pickRelevantEnv(input.env) } : {},
15360
15556
  ...input.data ? { data: redactData(input.data) } : {}
15361
15557
  };
15362
- 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
+ }
15363
15564
  appendFileSync2(path, JSON.stringify(event) + `
15364
15565
  `, "utf-8");
15365
15566
  return path;
15366
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
+ }
15367
15591
  function isEnvSnapshot(key, value) {
15368
15592
  return /env$/i.test(key) && !!value && typeof value === "object" && !Array.isArray(value);
15369
15593
  }
@@ -15402,10 +15626,10 @@ var envGuardResult = guardAgentBridgeEnv({
15402
15626
  });
15403
15627
  var stateDir = new StateDirResolver;
15404
15628
  stateDir.ensure();
15629
+ var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
15405
15630
  var configService = new ConfigService;
15406
- var config2 = configService.loadOrDefault();
15631
+ var config2 = configService.loadOrDefault(processLogger.log);
15407
15632
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
15408
- var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
15409
15633
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
15410
15634
  var CONTROL_WS_URL = daemonLifecycle.controlWsUrl;
15411
15635
  var claude = new ClaudeAdapter(stateDir.logFile);
@@ -15445,7 +15669,7 @@ if (process.env.AGENTBRIDGE_TRACE === "1") {
15445
15669
  });
15446
15670
  } catch {}
15447
15671
  }
15448
- claude.setReplySender(async (msg, requireReply, onBusy) => {
15672
+ claude.setReplySender(async (msg, requireReply, onBusy, idempotencyKey) => {
15449
15673
  if (msg.source !== "claude") {
15450
15674
  return { success: false, error: "Invalid message source" };
15451
15675
  }
@@ -15455,7 +15679,10 @@ claude.setReplySender(async (msg, requireReply, onBusy) => {
15455
15679
  error: disabledReplyError(daemonDisabledReason ?? "killed")
15456
15680
  };
15457
15681
  }
15458
- 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}` : ""})`);
15459
15686
  });
15460
15687
  daemonClient.on("codexMessage", (message) => {
15461
15688
  log(`Forwarding daemon \u2192 Claude (${message.content.length} chars)`);
@@ -15598,7 +15825,7 @@ async function notifyIfDaemonKilled(logMessage) {
15598
15825
  return true;
15599
15826
  }
15600
15827
  async function notifyIfPairRemoved(logMessage) {
15601
- if (existsSync6(stateDir.dir))
15828
+ if (existsSync7(stateDir.dir))
15602
15829
  return false;
15603
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`);
15604
15831
  return true;