@raysonmeng/agentbridge 0.1.21 → 0.1.23

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.
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "name": "agentbridge",
14
14
  "description": "Bridge Claude Code and Codex through a shared daemon, push channel delivery, and reply/get_messages tools.",
15
- "version": "0.1.21",
15
+ "version": "0.1.23",
16
16
  "author": {
17
17
  "name": "AgentBridge Contributors",
18
18
  "email": "raysonmeng@qq.com"
package/README.md CHANGED
@@ -217,10 +217,11 @@ agent_bridge/
217
217
  │ ├── pull_request_template.md
218
218
  │ └── workflows/ci.yml # GitHub Actions CI
219
219
  ├── assets/ # Static assets (images, etc.)
220
- ├── docs/
221
- │ ├── phase3-spec.md # Phase 3 design spec (CLI + Plugin)
222
- │ ├── v1-roadmap.md # v1 feature roadmap
223
- └── v2-architecture.md # v2 multi-agent architecture design
220
+ ├── docs/ # Numbered docs; index in docs/README.md
221
+ │ ├── 01-协作系统规格-v3.md # Collaboration system spec v3 (main)
222
+ │ ├── 02-v2架构设计.md # v2 multi-agent architecture (foundation)
223
+ ├── 03-08 … # multi-pair / budget / protocol / release
224
+ │ └── archive/历史归档.md # Archived design / post-mortems / test plans
224
225
  ├── plugins/agentbridge/ # Claude Code plugin bundle
225
226
  │ ├── .claude-plugin/plugin.json
226
227
  │ ├── commands/init.md
@@ -346,8 +347,8 @@ Codex runs in a sandboxed environment that **blocks all writes to the `.git` dir
346
347
 
347
348
  ## Roadmap
348
349
 
349
- - **v1.x (current)**: Improve the single-bridge experience without architectural refactoring -- less noise, better turn discipline, and clearer collaboration modes. See [docs/v1-roadmap.md](docs/v1-roadmap.md).
350
- - **v2 (planned)**: Introduce the multi-agent foundation -- room-scoped collaboration, stable identity, a formal control protocol, and stronger recovery semantics. See [docs/v2-architecture.md](docs/v2-architecture.md).
350
+ - **v1.x (current)**: Improve the single-bridge experience without architectural refactoring -- less noise, better turn discipline, and clearer collaboration modes. See [docs/archive/历史归档.md](docs/archive/历史归档.md).
351
+ - **v2 (planned)**: Introduce the multi-agent foundation -- room-scoped collaboration, stable identity, a formal control protocol, and stronger recovery semantics. See [docs/02-v2架构设计.md](docs/02-v2架构设计.md).
351
352
  - **v3+ (longer term)**: Explore smarter collaboration, richer policies, and more advanced orchestration across runtimes.
352
353
 
353
354
  ## How This Project Was Built
package/README.zh-CN.md CHANGED
@@ -215,10 +215,11 @@ agent_bridge/
215
215
  │ ├── pull_request_template.md
216
216
  │ └── workflows/ci.yml # GitHub Actions CI
217
217
  ├── assets/ # 图片资源
218
- ├── docs/
219
- │ ├── phase3-spec.md # Phase 3 设计文档(CLI + Plugin)
220
- │ ├── v1-roadmap.md # v1 功能路线图
221
- └── v2-architecture.md # v2 Agent 架构设计
218
+ ├── docs/ # 文档(带序号,索引见 docs/README.md)
219
+ │ ├── 01-协作系统规格-v3.md # 协作系统实现规格 v3(主规格)
220
+ │ ├── 02-v2架构设计.md # v2 多 Agent 架构(基座,中英双语)
221
+ ├── 03-08 … # 单机多对 / 额度 / 协作协议 / 发布
222
+ │ └── archive/历史归档.md # 已归档的历史设计 / 复盘 / 测试计划
222
223
  ├── plugins/agentbridge/ # Claude Code 插件包
223
224
  │ ├── .claude-plugin/plugin.json
224
225
  │ ├── commands/init.md
@@ -340,8 +341,8 @@ Codex 运行在沙箱环境中,**禁止对 `.git` 目录进行任何写操作*
340
341
 
341
342
  ## Roadmap
342
343
 
343
- - **v1.x(当前)**:在不改变架构的前提下优化单桥体验 -- 降噪、控回合、定角色。详见 [docs/v1-roadmap.md](docs/v1-roadmap.md)。
344
- - **v2(规划中)**:引入多 Agent 基础设施 -- Room 作用域协作、稳定身份、正式控制协议、更强的恢复语义。详见 [docs/v2-architecture.md](docs/v2-architecture.md)。
344
+ - **v1.x(当前)**:在不改变架构的前提下优化单桥体验 -- 降噪、控回合、定角色。详见 [docs/archive/历史归档.md](docs/archive/历史归档.md)。
345
+ - **v2(规划中)**:引入多 Agent 基础设施 -- Room 作用域协作、稳定身份、正式控制协议、更强的恢复语义。详见 [docs/02-v2架构设计.md](docs/02-v2架构设计.md)。
345
346
  - **v3+(远期)**:更智能的协作、更丰富的策略、跨 runtime 的高级编排。
346
347
 
347
348
  ## 这个项目是怎么建成的
package/dist/cli.js CHANGED
@@ -179,7 +179,7 @@ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env)
179
179
  var require_package = __commonJS((exports, module) => {
180
180
  module.exports = {
181
181
  name: "@raysonmeng/agentbridge",
182
- version: "0.1.21",
182
+ version: "0.1.23",
183
183
  description: "Bridge between Claude Code and Codex \u2014 bidirectional agent communication via MCP Channel + JSON-RPC",
184
184
  type: "module",
185
185
  packageManager: "bun@1.3.11",
@@ -487,7 +487,7 @@ function findShapeViolation(raw) {
487
487
  if (!isRecord(budget)) {
488
488
  return "budget is present but not an object";
489
489
  }
490
- const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
490
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct", "budgetFreshTtlSec", "idleAdviceActivityWindowSec"];
491
491
  for (const key of numericKeys) {
492
492
  if (key in budget && !isCoercibleNumber(budget[key])) {
493
493
  return `budget.${key} is present but not a number`;
@@ -541,7 +541,7 @@ function hasCustomDecisionValues(config) {
541
541
  const d = DEFAULT_CONFIG;
542
542
  const b = config.budget;
543
543
  const db = d.budget;
544
- return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
544
+ return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || 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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
545
545
  }
546
546
  function normalizeInteger(value, fallback) {
547
547
  if (typeof value === "number" && Number.isFinite(value))
@@ -637,6 +637,8 @@ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
637
637
  return {
638
638
  enabled: normalizeBoolean(budget.enabled, fallback.enabled),
639
639
  pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
640
+ budgetFreshTtlSec: normalizeBoundedInteger(budget.budgetFreshTtlSec, fallback.budgetFreshTtlSec, 1, 300),
641
+ idleAdviceActivityWindowSec: normalizeBoundedInteger(budget.idleAdviceActivityWindowSec, fallback.idleAdviceActivityWindowSec, 0, 86400),
640
642
  pauseAt,
641
643
  resumeBelow,
642
644
  syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
@@ -654,6 +656,8 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
654
656
  const overlay = {
655
657
  enabled: env.AGENTBRIDGE_BUDGET_ENABLED ?? budget.enabled,
656
658
  pollSeconds: env.AGENTBRIDGE_BUDGET_POLL_SECONDS ?? budget.pollSeconds,
659
+ budgetFreshTtlSec: env.AGENTBRIDGE_BUDGET_FRESH_TTL_SEC ?? budget.budgetFreshTtlSec,
660
+ idleAdviceActivityWindowSec: env.AGENTBRIDGE_BUDGET_IDLE_ADVICE_ACTIVITY_WINDOW_SEC ?? budget.idleAdviceActivityWindowSec,
657
661
  pauseAt: env.AGENTBRIDGE_BUDGET_PAUSE_AT ?? budget.pauseAt,
658
662
  resumeBelow: env.AGENTBRIDGE_BUDGET_RESUME_BELOW ?? budget.resumeBelow,
659
663
  syncDriftPct: env.AGENTBRIDGE_BUDGET_SYNC_DRIFT_PCT ?? budget.syncDriftPct,
@@ -793,6 +797,8 @@ var init_config_service = __esm(() => {
793
797
  DEFAULT_BUDGET_CONFIG = {
794
798
  enabled: true,
795
799
  pollSeconds: 300,
800
+ budgetFreshTtlSec: 25,
801
+ idleAdviceActivityWindowSec: 600,
796
802
  pauseAt: 90,
797
803
  resumeBelow: 30,
798
804
  syncDriftPct: 10,
@@ -1501,9 +1507,26 @@ var init_daemon_client = __esm(() => {
1501
1507
  send: () => this.send({ type: "probe_incumbent" })
1502
1508
  });
1503
1509
  }
1510
+ async requestBudgetRefresh(timeoutMs = 3000) {
1511
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1512
+ return null;
1513
+ }
1514
+ const requestId = `budget_${Date.now()}_${this.nextRequestId++}`;
1515
+ return this.awaitTypedResponse({
1516
+ key: "budget_refresh",
1517
+ successEvent: "budgetRefresh",
1518
+ match: (payload) => payload.requestId === requestId,
1519
+ successValue: (payload) => payload.snapshot,
1520
+ failValue: null,
1521
+ timeoutMs,
1522
+ send: () => this.send({ type: "request_budget_refresh", requestId })
1523
+ });
1524
+ }
1504
1525
  awaitTypedResponse(opts) {
1505
- const { key, successEvent, successValue, failValue, timeoutMs, send } = opts;
1526
+ const { key, successEvent, successValue, failValue, timeoutMs, send, match } = opts;
1506
1527
  const onSuccess = (payload) => {
1528
+ if (match && !match(payload))
1529
+ return;
1507
1530
  this.pendingEventWaiters.settle(key, successValue(payload));
1508
1531
  };
1509
1532
  const onRejected = () => {
@@ -1604,6 +1627,9 @@ var init_daemon_client = __esm(() => {
1604
1627
  case "incumbent_status":
1605
1628
  this.emit("incumbentStatus", { connected: message.connected, alive: message.alive });
1606
1629
  return;
1630
+ case "budget_refresh":
1631
+ this.emit("budgetRefresh", { requestId: message.requestId, snapshot: message.snapshot });
1632
+ return;
1607
1633
  }
1608
1634
  };
1609
1635
  ws.onclose = (event) => {
@@ -1682,11 +1708,11 @@ function formatBuildInfo(build) {
1682
1708
  var CODE_HASH_SENTINEL = "source", BUILD_INFO;
1683
1709
  var init_build_info = __esm(() => {
1684
1710
  BUILD_INFO = Object.freeze({
1685
- version: defineString("0.1.21", "0.0.0-source"),
1686
- commit: defineString("ddee9b2", "source"),
1711
+ version: defineString("0.1.23", "0.0.0-source"),
1712
+ commit: defineString("487e717", "source"),
1687
1713
  bundle: defineBundle("dist"),
1688
1714
  contractVersion: defineNumber(1, CONTRACT_VERSION),
1689
- codeHash: defineString("8354efa9fcad", "source")
1715
+ codeHash: defineString("e5377c294644", "source")
1690
1716
  });
1691
1717
  });
1692
1718
 
package/dist/daemon.js CHANGED
@@ -29,11 +29,11 @@ function defineNumber(value, fallback) {
29
29
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
30
30
  }
31
31
  var BUILD_INFO = Object.freeze({
32
- version: defineString("0.1.21", "0.0.0-source"),
33
- commit: defineString("ddee9b2", "source"),
32
+ version: defineString("0.1.23", "0.0.0-source"),
33
+ commit: defineString("487e717", "source"),
34
34
  bundle: defineBundle("dist"),
35
35
  contractVersion: defineNumber(1, CONTRACT_VERSION),
36
- codeHash: defineString("8354efa9fcad", "source")
36
+ codeHash: defineString("e5377c294644", "source")
37
37
  });
38
38
  function daemonStatusBuildInfo() {
39
39
  return { ...BUILD_INFO };
@@ -3478,6 +3478,8 @@ import { join as join4 } from "path";
3478
3478
  var DEFAULT_BUDGET_CONFIG = {
3479
3479
  enabled: true,
3480
3480
  pollSeconds: 300,
3481
+ budgetFreshTtlSec: 25,
3482
+ idleAdviceActivityWindowSec: 600,
3481
3483
  pauseAt: 90,
3482
3484
  resumeBelow: 30,
3483
3485
  syncDriftPct: 10,
@@ -3539,7 +3541,7 @@ function findShapeViolation(raw) {
3539
3541
  if (!isRecord(budget)) {
3540
3542
  return "budget is present but not an object";
3541
3543
  }
3542
- const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
3544
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct", "budgetFreshTtlSec", "idleAdviceActivityWindowSec"];
3543
3545
  for (const key of numericKeys) {
3544
3546
  if (key in budget && !isCoercibleNumber(budget[key])) {
3545
3547
  return `budget.${key} is present but not a number`;
@@ -3593,7 +3595,7 @@ function hasCustomDecisionValues(config) {
3593
3595
  const d = DEFAULT_CONFIG;
3594
3596
  const b = config.budget;
3595
3597
  const db = d.budget;
3596
- return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
3598
+ return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || 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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
3597
3599
  }
3598
3600
  function normalizeInteger(value, fallback) {
3599
3601
  if (typeof value === "number" && Number.isFinite(value))
@@ -3689,6 +3691,8 @@ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
3689
3691
  return {
3690
3692
  enabled: normalizeBoolean(budget.enabled, fallback.enabled),
3691
3693
  pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
3694
+ budgetFreshTtlSec: normalizeBoundedInteger(budget.budgetFreshTtlSec, fallback.budgetFreshTtlSec, 1, 300),
3695
+ idleAdviceActivityWindowSec: normalizeBoundedInteger(budget.idleAdviceActivityWindowSec, fallback.idleAdviceActivityWindowSec, 0, 86400),
3692
3696
  pauseAt,
3693
3697
  resumeBelow,
3694
3698
  syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
@@ -3706,6 +3710,8 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
3706
3710
  const overlay = {
3707
3711
  enabled: env.AGENTBRIDGE_BUDGET_ENABLED ?? budget.enabled,
3708
3712
  pollSeconds: env.AGENTBRIDGE_BUDGET_POLL_SECONDS ?? budget.pollSeconds,
3713
+ budgetFreshTtlSec: env.AGENTBRIDGE_BUDGET_FRESH_TTL_SEC ?? budget.budgetFreshTtlSec,
3714
+ idleAdviceActivityWindowSec: env.AGENTBRIDGE_BUDGET_IDLE_ADVICE_ACTIVITY_WINDOW_SEC ?? budget.idleAdviceActivityWindowSec,
3709
3715
  pauseAt: env.AGENTBRIDGE_BUDGET_PAUSE_AT ?? budget.pauseAt,
3710
3716
  resumeBelow: env.AGENTBRIDGE_BUDGET_RESUME_BELOW ?? budget.resumeBelow,
3711
3717
  syncDriftPct: env.AGENTBRIDGE_BUDGET_SYNC_DRIFT_PCT ?? budget.syncDriftPct,
@@ -4846,6 +4852,7 @@ class BudgetCoordinator {
4846
4852
  resumeSignals;
4847
4853
  adviceCooldown;
4848
4854
  isCodexTurnActive;
4855
+ hasRecentActivity;
4849
4856
  timer = null;
4850
4857
  running = false;
4851
4858
  fpState = INITIAL_FINGERPRINT_STATE;
@@ -4876,6 +4883,7 @@ class BudgetCoordinator {
4876
4883
  log: this.log
4877
4884
  });
4878
4885
  this.isCodexTurnActive = options.isCodexTurnActive ?? (() => false);
4886
+ this.hasRecentActivity = options.hasRecentActivity ?? (() => true);
4879
4887
  }
4880
4888
  async start() {
4881
4889
  if (this.running || !this.config.enabled)
@@ -4908,6 +4916,24 @@ class BudgetCoordinator {
4908
4916
  getSnapshot() {
4909
4917
  return this.latestSnapshot;
4910
4918
  }
4919
+ async refreshSnapshotReadonly() {
4920
+ let usage;
4921
+ try {
4922
+ usage = await this.source.fetchBoth();
4923
+ } catch (error) {
4924
+ this.log(`budget readonly refresh failed: ${error instanceof Error ? error.message : String(error)}`);
4925
+ return null;
4926
+ }
4927
+ if (!usage)
4928
+ return null;
4929
+ const now = this.now();
4930
+ const runway = {
4931
+ claude: agentRunway(usage.claude, now),
4932
+ codex: agentRunway(usage.codex, now)
4933
+ };
4934
+ const state = computeBudgetState(usage.claude, usage.codex, this.config, now, runway);
4935
+ return this.toSnapshot(state, runway);
4936
+ }
4911
4937
  getResumeCandidate() {
4912
4938
  const { detail, ...rest } = this.resumeCandidate;
4913
4939
  return detail ? {
@@ -5025,6 +5051,12 @@ class BudgetCoordinator {
5025
5051
  this.fpState = { ...this.fpState, fingerprint: null };
5026
5052
  return;
5027
5053
  }
5054
+ const activityWindowSec = this.config.idleAdviceActivityWindowSec;
5055
+ if (activityWindowSec > 0 && !this.hasRecentActivity(activityWindowSec)) {
5056
+ this.log(`budget advise suppressed: no agent activity in last ${activityWindowSec}s`);
5057
+ this.fpState = { ...this.fpState, fingerprint: null };
5058
+ return;
5059
+ }
5028
5060
  if (effect.phase === "underutilized") {
5029
5061
  if (!this.adviceCooldown.tryAcquire("underutilization", state.now))
5030
5062
  return;
@@ -6795,7 +6827,8 @@ function ensureBudgetCoordinatorStarted() {
6795
6827
  });
6796
6828
  },
6797
6829
  resumeSignals: readResumeSignals,
6798
- isCodexTurnActive: () => codex.turnInProgress
6830
+ isCodexTurnActive: () => codex.turnInProgress,
6831
+ hasRecentActivity: (windowSec) => codex.turnInProgress || Date.now() - lastActivityEpochMs <= windowSec * 1000
6799
6832
  });
6800
6833
  }
6801
6834
  budgetCoordinator.start();
@@ -6924,6 +6957,7 @@ codex.on("steerFailed", ({ requestId, reason }) => {
6924
6957
  });
6925
6958
  codex.on("steerAccepted", ({ requestId }) => {
6926
6959
  log("Steer accepted by app-server");
6960
+ recordAgentActivity();
6927
6961
  const dispatch = pendingSteerDispatches.get(requestId);
6928
6962
  pendingSteerDispatches.delete(requestId);
6929
6963
  if (dispatch?.requireReply) {
@@ -6993,13 +7027,19 @@ codex.on("turnTrackingReset", (reason) => {
6993
7027
  pendingSteerDispatches.clear();
6994
7028
  resumeInjectionQueue.onTurnTrackingReset();
6995
7029
  });
7030
+ var lastActivityEpochMs = 0;
7031
+ function recordAgentActivity() {
7032
+ lastActivityEpochMs = Date.now();
7033
+ }
6996
7034
  codex.on("turnStarted", () => {
6997
7035
  log("Codex turn started");
7036
+ recordAgentActivity();
6998
7037
  emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
6999
7038
  });
7000
7039
  codex.on("agentMessage", (msg) => {
7001
7040
  if (msg.source !== "codex")
7002
7041
  return;
7042
+ recordAgentActivity();
7003
7043
  const route = routeCodexMessage(msg.content, {
7004
7044
  mode: FILTER_MODE,
7005
7045
  replyArmed: replyTracker.isArmed,
@@ -7217,6 +7257,11 @@ function handleControlMessage(ws, raw) {
7217
7257
  log(`handleProbeIncumbent threw for #${ws.data.clientId}: ${err?.message ?? err}`);
7218
7258
  });
7219
7259
  return;
7260
+ case "request_budget_refresh":
7261
+ handleRequestBudgetRefresh(ws, message.requestId).catch((err) => {
7262
+ log(`handleRequestBudgetRefresh threw for #${ws.data.clientId}: ${err?.message ?? err}`);
7263
+ });
7264
+ return;
7220
7265
  case "claude_to_codex": {
7221
7266
  handleClaudeToCodex(ws, message).catch((err) => {
7222
7267
  log(`handleClaudeToCodex threw for request ${message.requestId}: ${err?.message ?? err}`);
@@ -7575,6 +7620,11 @@ async function handleProbeIncumbent(ws) {
7575
7620
  alive: stillConnected && alive
7576
7621
  });
7577
7622
  }
7623
+ async function handleRequestBudgetRefresh(ws, requestId) {
7624
+ const snapshot = budgetCoordinator ? await budgetCoordinator.refreshSnapshotReadonly() : null;
7625
+ log(`request_budget_refresh from #${ws.data.clientId}: ${snapshot ? "fresh" : "unavailable"}`);
7626
+ sendProtocolMessage(ws, { type: "budget_refresh", requestId, snapshot });
7627
+ }
7578
7628
  async function probeLiveness2(ws, timeoutMs) {
7579
7629
  return probeLiveness({
7580
7630
  get readyState() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raysonmeng/agentbridge",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Bridge between Claude Code and Codex — bidirectional agent communication via MCP Channel + JSON-RPC",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.11",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentbridge",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Bridge Claude Code and Codex with a shared daemon, push channel delivery, and bidirectional reply tooling.",
5
5
  "author": {
6
6
  "name": "AgentBridge Contributors",
@@ -14161,6 +14161,7 @@ var DEFAULT_MAX_BUFFERED_MESSAGES = 100;
14161
14161
  var DEFAULT_MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
14162
14162
  var DEFAULT_DEDUPE_CAPACITY = 2048;
14163
14163
  var DEFAULT_DEDUPE_TTL_MS = 20 * 60 * 1000;
14164
+ var DEFAULT_BUDGET_FRESH_TTL_MS = 25 * 1000;
14164
14165
  var CLAUDE_INSTRUCTIONS = [
14165
14166
  "Codex is an AI coding agent (OpenAI) running in a separate session on the same machine.",
14166
14167
  "",
@@ -14224,6 +14225,10 @@ class ClaudeAdapter extends EventEmitter {
14224
14225
  monotonicNow;
14225
14226
  deliveredMessageIds = new Map;
14226
14227
  budgetSnapshot = null;
14228
+ budgetFreshTtlMs;
14229
+ wallNow;
14230
+ requestFreshSnapshot = null;
14231
+ pendingBudgetRefresh = null;
14227
14232
  constructor(logFile = new StateDirResolver().logFile, options = {}) {
14228
14233
  super();
14229
14234
  this.logFile = logFile;
@@ -14240,6 +14245,8 @@ class ClaudeAdapter extends EventEmitter {
14240
14245
  this.dedupeCapacity = positiveIntegerOr(options.dedupeCapacity, DEFAULT_DEDUPE_CAPACITY);
14241
14246
  this.dedupeTtlMs = positiveIntegerOr(options.dedupeTtlMs, DEFAULT_DEDUPE_TTL_MS);
14242
14247
  this.monotonicNow = options.now ?? (() => performance.now());
14248
+ this.budgetFreshTtlMs = positiveIntegerOr(options.budgetFreshTtlMs, parsePositiveIntegerEnv("AGENTBRIDGE_BUDGET_FRESH_TTL_SEC", DEFAULT_BUDGET_FRESH_TTL_MS / 1000) * 1000);
14249
+ this.wallNow = options.wallNow ?? (() => Date.now());
14243
14250
  this.server = new Server({ name: "agentbridge", version: "0.1.0" }, {
14244
14251
  capabilities: {
14245
14252
  experimental: { "claude/channel": {} },
@@ -14267,6 +14274,9 @@ class ClaudeAdapter extends EventEmitter {
14267
14274
  setBudgetSnapshot(snapshot) {
14268
14275
  this.budgetSnapshot = snapshot;
14269
14276
  }
14277
+ setRequestFreshSnapshot(fetcher) {
14278
+ this.requestFreshSnapshot = fetcher;
14279
+ }
14270
14280
  async pushNotification(message) {
14271
14281
  this.log(`pushNotification (instance=${this.instanceId}, msgId=${message.id}, len=${message.content.length})`);
14272
14282
  if (!this.rememberDelivery(message))
@@ -14543,13 +14553,42 @@ chat_id: ${this.sessionId}`);
14543
14553
  content: [{ type: "text", text: `Resume acknowledged (resume_id=${resumeIdRaw}, status=${status}).` }]
14544
14554
  };
14545
14555
  }
14546
- handleGetBudget() {
14547
- this.log(`get_budget called (instance=${this.instanceId}, hasSnapshot=${this.budgetSnapshot !== null})`);
14548
- const text = this.budgetSnapshot ? renderBudgetSnapshot(this.budgetSnapshot) : BUDGET_UNAVAILABLE_TEXT;
14556
+ async handleGetBudget() {
14557
+ let snapshot = this.budgetSnapshot;
14558
+ const fresh = snapshot !== null && this.isBudgetSnapshotFresh(snapshot);
14559
+ this.log(`get_budget called (instance=${this.instanceId}, hasSnapshot=${snapshot !== null}, fresh=${fresh})`);
14560
+ if (!fresh && this.requestFreshSnapshot) {
14561
+ const refreshed = await this.refreshBudgetSnapshot();
14562
+ snapshot = refreshed ?? this.budgetSnapshot;
14563
+ }
14564
+ const text = snapshot ? renderBudgetSnapshot(snapshot) : BUDGET_UNAVAILABLE_TEXT;
14549
14565
  return {
14550
14566
  content: [{ type: "text", text }]
14551
14567
  };
14552
14568
  }
14569
+ isBudgetSnapshotFresh(snapshot) {
14570
+ if (!snapshot.updatedAt || snapshot.updatedAt <= 0)
14571
+ return false;
14572
+ const ageMs = this.wallNow() - snapshot.updatedAt * 1000;
14573
+ return ageMs < this.budgetFreshTtlMs;
14574
+ }
14575
+ refreshBudgetSnapshot() {
14576
+ if (!this.requestFreshSnapshot)
14577
+ return Promise.resolve(null);
14578
+ if (!this.pendingBudgetRefresh) {
14579
+ this.pendingBudgetRefresh = this.requestFreshSnapshot().then((snapshot) => {
14580
+ if (snapshot)
14581
+ this.budgetSnapshot = snapshot;
14582
+ return snapshot;
14583
+ }).catch((error2) => {
14584
+ this.log(`get_budget refresh failed: ${error2 instanceof Error ? error2.message : String(error2)}`);
14585
+ return null;
14586
+ }).finally(() => {
14587
+ this.pendingBudgetRefresh = null;
14588
+ });
14589
+ }
14590
+ return this.pendingBudgetRefresh;
14591
+ }
14553
14592
  async handleReply(args) {
14554
14593
  const text = args?.text;
14555
14594
  if (!text) {
@@ -14667,11 +14706,11 @@ function defineNumber(value, fallback) {
14667
14706
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
14668
14707
  }
14669
14708
  var BUILD_INFO = Object.freeze({
14670
- version: defineString("0.1.21", "0.0.0-source"),
14671
- commit: defineString("ddee9b2", "source"),
14709
+ version: defineString("0.1.23", "0.0.0-source"),
14710
+ commit: defineString("487e717", "source"),
14672
14711
  bundle: defineBundle("plugin"),
14673
14712
  contractVersion: defineNumber(1, CONTRACT_VERSION),
14674
- codeHash: defineString("8354efa9fcad", "source")
14713
+ codeHash: defineString("e5377c294644", "source")
14675
14714
  });
14676
14715
  function sameRuntimeContract(a, b) {
14677
14716
  if (!a || !b)
@@ -14875,9 +14914,26 @@ class DaemonClient extends EventEmitter2 {
14875
14914
  send: () => this.send({ type: "probe_incumbent" })
14876
14915
  });
14877
14916
  }
14917
+ async requestBudgetRefresh(timeoutMs = 3000) {
14918
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14919
+ return null;
14920
+ }
14921
+ const requestId = `budget_${Date.now()}_${this.nextRequestId++}`;
14922
+ return this.awaitTypedResponse({
14923
+ key: "budget_refresh",
14924
+ successEvent: "budgetRefresh",
14925
+ match: (payload) => payload.requestId === requestId,
14926
+ successValue: (payload) => payload.snapshot,
14927
+ failValue: null,
14928
+ timeoutMs,
14929
+ send: () => this.send({ type: "request_budget_refresh", requestId })
14930
+ });
14931
+ }
14878
14932
  awaitTypedResponse(opts) {
14879
- const { key, successEvent, successValue, failValue, timeoutMs, send } = opts;
14933
+ const { key, successEvent, successValue, failValue, timeoutMs, send, match } = opts;
14880
14934
  const onSuccess = (payload) => {
14935
+ if (match && !match(payload))
14936
+ return;
14881
14937
  this.pendingEventWaiters.settle(key, successValue(payload));
14882
14938
  };
14883
14939
  const onRejected = () => {
@@ -14978,6 +15034,9 @@ class DaemonClient extends EventEmitter2 {
14978
15034
  case "incumbent_status":
14979
15035
  this.emit("incumbentStatus", { connected: message.connected, alive: message.alive });
14980
15036
  return;
15037
+ case "budget_refresh":
15038
+ this.emit("budgetRefresh", { requestId: message.requestId, snapshot: message.snapshot });
15039
+ return;
14981
15040
  }
14982
15041
  };
14983
15042
  ws.onclose = (event) => {
@@ -15694,6 +15753,8 @@ import { join as join2 } from "path";
15694
15753
  var DEFAULT_BUDGET_CONFIG = {
15695
15754
  enabled: true,
15696
15755
  pollSeconds: 300,
15756
+ budgetFreshTtlSec: 25,
15757
+ idleAdviceActivityWindowSec: 600,
15697
15758
  pauseAt: 90,
15698
15759
  resumeBelow: 30,
15699
15760
  syncDriftPct: 10,
@@ -15755,7 +15816,7 @@ function findShapeViolation(raw) {
15755
15816
  if (!isRecord(budget)) {
15756
15817
  return "budget is present but not an object";
15757
15818
  }
15758
- const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
15819
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct", "budgetFreshTtlSec", "idleAdviceActivityWindowSec"];
15759
15820
  for (const key of numericKeys) {
15760
15821
  if (key in budget && !isCoercibleNumber(budget[key])) {
15761
15822
  return `budget.${key} is present but not a number`;
@@ -15809,7 +15870,7 @@ function hasCustomDecisionValues(config2) {
15809
15870
  const d = DEFAULT_CONFIG;
15810
15871
  const b = config2.budget;
15811
15872
  const db = d.budget;
15812
- 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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
15873
+ 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.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || 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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
15813
15874
  }
15814
15875
  function normalizeInteger(value, fallback) {
15815
15876
  if (typeof value === "number" && Number.isFinite(value))
@@ -15905,6 +15966,8 @@ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
15905
15966
  return {
15906
15967
  enabled: normalizeBoolean(budget.enabled, fallback.enabled),
15907
15968
  pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
15969
+ budgetFreshTtlSec: normalizeBoundedInteger(budget.budgetFreshTtlSec, fallback.budgetFreshTtlSec, 1, 300),
15970
+ idleAdviceActivityWindowSec: normalizeBoundedInteger(budget.idleAdviceActivityWindowSec, fallback.idleAdviceActivityWindowSec, 0, 86400),
15908
15971
  pauseAt,
15909
15972
  resumeBelow,
15910
15973
  syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
@@ -15918,6 +15981,37 @@ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
15918
15981
  allocation: normalizeAllocationConfig(budget.allocation, fallback.allocation)
15919
15982
  };
15920
15983
  }
15984
+ function applyBudgetEnvOverrides(budget, env = process.env) {
15985
+ const overlay = {
15986
+ enabled: env.AGENTBRIDGE_BUDGET_ENABLED ?? budget.enabled,
15987
+ pollSeconds: env.AGENTBRIDGE_BUDGET_POLL_SECONDS ?? budget.pollSeconds,
15988
+ budgetFreshTtlSec: env.AGENTBRIDGE_BUDGET_FRESH_TTL_SEC ?? budget.budgetFreshTtlSec,
15989
+ idleAdviceActivityWindowSec: env.AGENTBRIDGE_BUDGET_IDLE_ADVICE_ACTIVITY_WINDOW_SEC ?? budget.idleAdviceActivityWindowSec,
15990
+ pauseAt: env.AGENTBRIDGE_BUDGET_PAUSE_AT ?? budget.pauseAt,
15991
+ resumeBelow: env.AGENTBRIDGE_BUDGET_RESUME_BELOW ?? budget.resumeBelow,
15992
+ syncDriftPct: env.AGENTBRIDGE_BUDGET_SYNC_DRIFT_PCT ?? budget.syncDriftPct,
15993
+ parallel: {
15994
+ minRemainingPct: env.AGENTBRIDGE_BUDGET_PARALLEL_MIN_REMAINING_PCT ?? budget.parallel.minRemainingPct,
15995
+ timeWindowSec: env.AGENTBRIDGE_BUDGET_PARALLEL_TIME_WINDOW_SEC ?? budget.parallel.timeWindowSec
15996
+ },
15997
+ codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
15998
+ codexTiers: budget.codexTiers,
15999
+ maximize: {
16000
+ targetUtil: env.AGENTBRIDGE_BUDGET_TARGET_UTIL ?? budget.maximize.targetUtil,
16001
+ reserveSlopePctPerHour: env.AGENTBRIDGE_BUDGET_RESERVE_SLOPE_PCT_PER_HOUR ?? budget.maximize.reserveSlopePctPerHour,
16002
+ reserveMaxPct: env.AGENTBRIDGE_BUDGET_RESERVE_MAX_PCT ?? budget.maximize.reserveMaxPct,
16003
+ finishingHorizonMinutes: env.AGENTBRIDGE_BUDGET_FINISHING_HORIZON_MINUTES ?? budget.maximize.finishingHorizonMinutes,
16004
+ resumeHysteresisPct: env.AGENTBRIDGE_BUDGET_RESUME_HYSTERESIS_PCT ?? budget.maximize.resumeHysteresisPct,
16005
+ admissionAt: env.AGENTBRIDGE_BUDGET_ADMISSION_AT ?? budget.maximize.admissionAt,
16006
+ wrapUpQuota: env.AGENTBRIDGE_BUDGET_WRAP_UP_QUOTA ?? budget.maximize.wrapUpQuota
16007
+ },
16008
+ allocation: {
16009
+ minRunwayRatio: env.AGENTBRIDGE_BUDGET_MIN_RUNWAY_RATIO ?? budget.allocation.minRunwayRatio,
16010
+ minRunwayGapHours: env.AGENTBRIDGE_BUDGET_MIN_RUNWAY_GAP_HOURS ?? budget.allocation.minRunwayGapHours
16011
+ }
16012
+ };
16013
+ return normalizeBudgetConfig(overlay, budget);
16014
+ }
15921
16015
  function normalizeConfig(raw) {
15922
16016
  if (!isRecord(raw))
15923
16017
  return null;
@@ -16397,8 +16491,12 @@ var config2 = configService.loadOrDefault(processLogger.log);
16397
16491
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
16398
16492
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
16399
16493
  var CONTROL_WS_URL = daemonLifecycle.controlWsUrl;
16400
- var claude = new ClaudeAdapter(stateDir.logFile);
16494
+ var effectiveBudget = applyBudgetEnvOverrides(config2.budget);
16495
+ var claude = new ClaudeAdapter(stateDir.logFile, {
16496
+ budgetFreshTtlMs: effectiveBudget.budgetFreshTtlSec * 1000
16497
+ });
16401
16498
  var daemonClient = new DaemonClient(CONTROL_WS_URL, { identity: currentClientIdentity });
16499
+ claude.setRequestFreshSnapshot(() => daemonClient.requestBudgetRefresh());
16402
16500
  var shuttingDown = false;
16403
16501
  var daemonDisabled = false;
16404
16502
  var daemonDisabledReason = null;
@@ -29,11 +29,11 @@ function defineNumber(value, fallback) {
29
29
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
30
30
  }
31
31
  var BUILD_INFO = Object.freeze({
32
- version: defineString("0.1.21", "0.0.0-source"),
33
- commit: defineString("ddee9b2", "source"),
32
+ version: defineString("0.1.23", "0.0.0-source"),
33
+ commit: defineString("487e717", "source"),
34
34
  bundle: defineBundle("plugin"),
35
35
  contractVersion: defineNumber(1, CONTRACT_VERSION),
36
- codeHash: defineString("8354efa9fcad", "source")
36
+ codeHash: defineString("e5377c294644", "source")
37
37
  });
38
38
  function daemonStatusBuildInfo() {
39
39
  return { ...BUILD_INFO };
@@ -3478,6 +3478,8 @@ import { join as join4 } from "path";
3478
3478
  var DEFAULT_BUDGET_CONFIG = {
3479
3479
  enabled: true,
3480
3480
  pollSeconds: 300,
3481
+ budgetFreshTtlSec: 25,
3482
+ idleAdviceActivityWindowSec: 600,
3481
3483
  pauseAt: 90,
3482
3484
  resumeBelow: 30,
3483
3485
  syncDriftPct: 10,
@@ -3539,7 +3541,7 @@ function findShapeViolation(raw) {
3539
3541
  if (!isRecord(budget)) {
3540
3542
  return "budget is present but not an object";
3541
3543
  }
3542
- const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
3544
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct", "budgetFreshTtlSec", "idleAdviceActivityWindowSec"];
3543
3545
  for (const key of numericKeys) {
3544
3546
  if (key in budget && !isCoercibleNumber(budget[key])) {
3545
3547
  return `budget.${key} is present but not a number`;
@@ -3593,7 +3595,7 @@ function hasCustomDecisionValues(config) {
3593
3595
  const d = DEFAULT_CONFIG;
3594
3596
  const b = config.budget;
3595
3597
  const db = d.budget;
3596
- return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
3598
+ return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.budgetFreshTtlSec !== db.budgetFreshTtlSec || b.idleAdviceActivityWindowSec !== db.idleAdviceActivityWindowSec || 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 || b.maximize.targetUtil !== db.maximize.targetUtil || b.maximize.reserveSlopePctPerHour !== db.maximize.reserveSlopePctPerHour || b.maximize.reserveMaxPct !== db.maximize.reserveMaxPct || b.maximize.finishingHorizonMinutes !== db.maximize.finishingHorizonMinutes || b.maximize.resumeHysteresisPct !== db.maximize.resumeHysteresisPct || b.maximize.admissionAt !== db.maximize.admissionAt || b.maximize.wrapUpQuota !== db.maximize.wrapUpQuota || b.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
3597
3599
  }
3598
3600
  function normalizeInteger(value, fallback) {
3599
3601
  if (typeof value === "number" && Number.isFinite(value))
@@ -3689,6 +3691,8 @@ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
3689
3691
  return {
3690
3692
  enabled: normalizeBoolean(budget.enabled, fallback.enabled),
3691
3693
  pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
3694
+ budgetFreshTtlSec: normalizeBoundedInteger(budget.budgetFreshTtlSec, fallback.budgetFreshTtlSec, 1, 300),
3695
+ idleAdviceActivityWindowSec: normalizeBoundedInteger(budget.idleAdviceActivityWindowSec, fallback.idleAdviceActivityWindowSec, 0, 86400),
3692
3696
  pauseAt,
3693
3697
  resumeBelow,
3694
3698
  syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
@@ -3706,6 +3710,8 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
3706
3710
  const overlay = {
3707
3711
  enabled: env.AGENTBRIDGE_BUDGET_ENABLED ?? budget.enabled,
3708
3712
  pollSeconds: env.AGENTBRIDGE_BUDGET_POLL_SECONDS ?? budget.pollSeconds,
3713
+ budgetFreshTtlSec: env.AGENTBRIDGE_BUDGET_FRESH_TTL_SEC ?? budget.budgetFreshTtlSec,
3714
+ idleAdviceActivityWindowSec: env.AGENTBRIDGE_BUDGET_IDLE_ADVICE_ACTIVITY_WINDOW_SEC ?? budget.idleAdviceActivityWindowSec,
3709
3715
  pauseAt: env.AGENTBRIDGE_BUDGET_PAUSE_AT ?? budget.pauseAt,
3710
3716
  resumeBelow: env.AGENTBRIDGE_BUDGET_RESUME_BELOW ?? budget.resumeBelow,
3711
3717
  syncDriftPct: env.AGENTBRIDGE_BUDGET_SYNC_DRIFT_PCT ?? budget.syncDriftPct,
@@ -4846,6 +4852,7 @@ class BudgetCoordinator {
4846
4852
  resumeSignals;
4847
4853
  adviceCooldown;
4848
4854
  isCodexTurnActive;
4855
+ hasRecentActivity;
4849
4856
  timer = null;
4850
4857
  running = false;
4851
4858
  fpState = INITIAL_FINGERPRINT_STATE;
@@ -4876,6 +4883,7 @@ class BudgetCoordinator {
4876
4883
  log: this.log
4877
4884
  });
4878
4885
  this.isCodexTurnActive = options.isCodexTurnActive ?? (() => false);
4886
+ this.hasRecentActivity = options.hasRecentActivity ?? (() => true);
4879
4887
  }
4880
4888
  async start() {
4881
4889
  if (this.running || !this.config.enabled)
@@ -4908,6 +4916,24 @@ class BudgetCoordinator {
4908
4916
  getSnapshot() {
4909
4917
  return this.latestSnapshot;
4910
4918
  }
4919
+ async refreshSnapshotReadonly() {
4920
+ let usage;
4921
+ try {
4922
+ usage = await this.source.fetchBoth();
4923
+ } catch (error) {
4924
+ this.log(`budget readonly refresh failed: ${error instanceof Error ? error.message : String(error)}`);
4925
+ return null;
4926
+ }
4927
+ if (!usage)
4928
+ return null;
4929
+ const now = this.now();
4930
+ const runway = {
4931
+ claude: agentRunway(usage.claude, now),
4932
+ codex: agentRunway(usage.codex, now)
4933
+ };
4934
+ const state = computeBudgetState(usage.claude, usage.codex, this.config, now, runway);
4935
+ return this.toSnapshot(state, runway);
4936
+ }
4911
4937
  getResumeCandidate() {
4912
4938
  const { detail, ...rest } = this.resumeCandidate;
4913
4939
  return detail ? {
@@ -5025,6 +5051,12 @@ class BudgetCoordinator {
5025
5051
  this.fpState = { ...this.fpState, fingerprint: null };
5026
5052
  return;
5027
5053
  }
5054
+ const activityWindowSec = this.config.idleAdviceActivityWindowSec;
5055
+ if (activityWindowSec > 0 && !this.hasRecentActivity(activityWindowSec)) {
5056
+ this.log(`budget advise suppressed: no agent activity in last ${activityWindowSec}s`);
5057
+ this.fpState = { ...this.fpState, fingerprint: null };
5058
+ return;
5059
+ }
5028
5060
  if (effect.phase === "underutilized") {
5029
5061
  if (!this.adviceCooldown.tryAcquire("underutilization", state.now))
5030
5062
  return;
@@ -6795,7 +6827,8 @@ function ensureBudgetCoordinatorStarted() {
6795
6827
  });
6796
6828
  },
6797
6829
  resumeSignals: readResumeSignals,
6798
- isCodexTurnActive: () => codex.turnInProgress
6830
+ isCodexTurnActive: () => codex.turnInProgress,
6831
+ hasRecentActivity: (windowSec) => codex.turnInProgress || Date.now() - lastActivityEpochMs <= windowSec * 1000
6799
6832
  });
6800
6833
  }
6801
6834
  budgetCoordinator.start();
@@ -6924,6 +6957,7 @@ codex.on("steerFailed", ({ requestId, reason }) => {
6924
6957
  });
6925
6958
  codex.on("steerAccepted", ({ requestId }) => {
6926
6959
  log("Steer accepted by app-server");
6960
+ recordAgentActivity();
6927
6961
  const dispatch = pendingSteerDispatches.get(requestId);
6928
6962
  pendingSteerDispatches.delete(requestId);
6929
6963
  if (dispatch?.requireReply) {
@@ -6993,13 +7027,19 @@ codex.on("turnTrackingReset", (reason) => {
6993
7027
  pendingSteerDispatches.clear();
6994
7028
  resumeInjectionQueue.onTurnTrackingReset();
6995
7029
  });
7030
+ var lastActivityEpochMs = 0;
7031
+ function recordAgentActivity() {
7032
+ lastActivityEpochMs = Date.now();
7033
+ }
6996
7034
  codex.on("turnStarted", () => {
6997
7035
  log("Codex turn started");
7036
+ recordAgentActivity();
6998
7037
  emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
6999
7038
  });
7000
7039
  codex.on("agentMessage", (msg) => {
7001
7040
  if (msg.source !== "codex")
7002
7041
  return;
7042
+ recordAgentActivity();
7003
7043
  const route = routeCodexMessage(msg.content, {
7004
7044
  mode: FILTER_MODE,
7005
7045
  replyArmed: replyTracker.isArmed,
@@ -7217,6 +7257,11 @@ function handleControlMessage(ws, raw) {
7217
7257
  log(`handleProbeIncumbent threw for #${ws.data.clientId}: ${err?.message ?? err}`);
7218
7258
  });
7219
7259
  return;
7260
+ case "request_budget_refresh":
7261
+ handleRequestBudgetRefresh(ws, message.requestId).catch((err) => {
7262
+ log(`handleRequestBudgetRefresh threw for #${ws.data.clientId}: ${err?.message ?? err}`);
7263
+ });
7264
+ return;
7220
7265
  case "claude_to_codex": {
7221
7266
  handleClaudeToCodex(ws, message).catch((err) => {
7222
7267
  log(`handleClaudeToCodex threw for request ${message.requestId}: ${err?.message ?? err}`);
@@ -7575,6 +7620,11 @@ async function handleProbeIncumbent(ws) {
7575
7620
  alive: stillConnected && alive
7576
7621
  });
7577
7622
  }
7623
+ async function handleRequestBudgetRefresh(ws, requestId) {
7624
+ const snapshot = budgetCoordinator ? await budgetCoordinator.refreshSnapshotReadonly() : null;
7625
+ log(`request_budget_refresh from #${ws.data.clientId}: ${snapshot ? "fresh" : "unavailable"}`);
7626
+ sendProtocolMessage(ws, { type: "budget_refresh", requestId, snapshot });
7627
+ }
7578
7628
  async function probeLiveness2(ws, timeoutMs) {
7579
7629
  return probeLiveness({
7580
7630
  get readyState() {