@raysonmeng/agentbridge 0.1.16 → 0.1.17

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.
package/dist/daemon.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
+ var __require = import.meta.require;
3
4
 
4
5
  // src/daemon.ts
5
- import { rmSync as rmSync2 } from "fs";
6
+ import { existsSync as existsSync8, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs";
7
+ import { homedir as homedir5 } from "os";
8
+ import { join as join11 } from "path";
6
9
  import { randomUUID as randomUUID4 } from "crypto";
7
10
 
8
11
  // src/contract-version.ts
@@ -26,11 +29,11 @@ function defineNumber(value, fallback) {
26
29
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
27
30
  }
28
31
  var BUILD_INFO = Object.freeze({
29
- version: defineString("0.1.16", "0.0.0-source"),
30
- commit: defineString("1dc5e4f", "source"),
32
+ version: defineString("0.1.17", "0.0.0-source"),
33
+ commit: defineString("0d1e1bd", "source"),
31
34
  bundle: defineBundle("dist"),
32
35
  contractVersion: defineNumber(1, CONTRACT_VERSION),
33
- codeHash: defineString("1fc975838e46", "source")
36
+ codeHash: defineString("c22387f3269f", "source")
34
37
  });
35
38
  function daemonStatusBuildInfo() {
36
39
  return { ...BUILD_INFO };
@@ -3402,7 +3405,17 @@ var DEFAULT_BUDGET_CONFIG = {
3402
3405
  balanced: { effort: "medium" },
3403
3406
  eco: { effort: "low" }
3404
3407
  },
3405
- strategy: "conserve"
3408
+ maximize: {
3409
+ targetUtil: 98,
3410
+ reserveSlopePctPerHour: 0.4,
3411
+ reserveMaxPct: 7,
3412
+ finishingHorizonMinutes: 30,
3413
+ resumeHysteresisPct: 5
3414
+ },
3415
+ allocation: {
3416
+ minRunwayRatio: 50,
3417
+ minRunwayGapHours: 2
3418
+ }
3406
3419
  };
3407
3420
  var DEFAULT_CONFIG = {
3408
3421
  version: "1.0",
@@ -3455,6 +3468,34 @@ function findShapeViolation(raw) {
3455
3468
  }
3456
3469
  }
3457
3470
  }
3471
+ if ("maximize" in budget) {
3472
+ const maximize = budget.maximize;
3473
+ if (!isRecord(maximize)) {
3474
+ return "budget.maximize is present but not an object";
3475
+ }
3476
+ for (const key of [
3477
+ "targetUtil",
3478
+ "reserveSlopePctPerHour",
3479
+ "reserveMaxPct",
3480
+ "finishingHorizonMinutes",
3481
+ "resumeHysteresisPct"
3482
+ ]) {
3483
+ if (key in maximize && !isCoercibleNumber(maximize[key])) {
3484
+ return `budget.maximize.${key} is present but not a number`;
3485
+ }
3486
+ }
3487
+ }
3488
+ if ("allocation" in budget) {
3489
+ const allocation = budget.allocation;
3490
+ if (!isRecord(allocation)) {
3491
+ return "budget.allocation is present but not an object";
3492
+ }
3493
+ for (const key of ["minRunwayRatio", "minRunwayGapHours"]) {
3494
+ if (key in allocation && !isCoercibleNumber(allocation[key])) {
3495
+ return `budget.allocation.${key} is present but not a number`;
3496
+ }
3497
+ }
3498
+ }
3458
3499
  }
3459
3500
  return null;
3460
3501
  }
@@ -3462,7 +3503,7 @@ function hasCustomDecisionValues(config) {
3462
3503
  const d = DEFAULT_CONFIG;
3463
3504
  const b = config.budget;
3464
3505
  const db = d.budget;
3465
- 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;
3506
+ 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.allocation.minRunwayRatio !== db.allocation.minRunwayRatio || b.allocation.minRunwayGapHours !== db.allocation.minRunwayGapHours;
3466
3507
  }
3467
3508
  function normalizeInteger(value, fallback) {
3468
3509
  if (typeof value === "number" && Number.isFinite(value))
@@ -3480,8 +3521,41 @@ function normalizeBoundedInteger(value, fallback, min, max) {
3480
3521
  return fallback;
3481
3522
  return parsed;
3482
3523
  }
3483
- function normalizeStrategy(value, fallback) {
3484
- return value === "conserve" || value === "maximize" ? value : fallback;
3524
+ function normalizeBoundedNumber(value, fallback, min, max) {
3525
+ let parsed;
3526
+ if (typeof value === "number") {
3527
+ parsed = value;
3528
+ } else if (typeof value === "string" && value.trim() !== "") {
3529
+ parsed = Number(value);
3530
+ } else {
3531
+ return fallback;
3532
+ }
3533
+ if (!Number.isFinite(parsed))
3534
+ return fallback;
3535
+ if (parsed < min || parsed > max)
3536
+ return fallback;
3537
+ return parsed;
3538
+ }
3539
+ function normalizeMaximizeConfig(raw, pauseAt, fallback = DEFAULT_BUDGET_CONFIG.maximize) {
3540
+ const m = isRecord(raw) ? raw : {};
3541
+ const normalized = {
3542
+ targetUtil: normalizeBoundedInteger(m.targetUtil, fallback.targetUtil, 90, 99),
3543
+ reserveSlopePctPerHour: normalizeBoundedNumber(m.reserveSlopePctPerHour, fallback.reserveSlopePctPerHour, 0, 5),
3544
+ reserveMaxPct: normalizeBoundedInteger(m.reserveMaxPct, fallback.reserveMaxPct, 0, 30),
3545
+ finishingHorizonMinutes: normalizeBoundedInteger(m.finishingHorizonMinutes, fallback.finishingHorizonMinutes, 5, 180),
3546
+ resumeHysteresisPct: normalizeBoundedInteger(m.resumeHysteresisPct, fallback.resumeHysteresisPct, 1, 30)
3547
+ };
3548
+ if (normalized.targetUtil <= pauseAt) {
3549
+ return { ...DEFAULT_BUDGET_CONFIG.maximize };
3550
+ }
3551
+ return normalized;
3552
+ }
3553
+ function normalizeAllocationConfig(raw, fallback = DEFAULT_BUDGET_CONFIG.allocation) {
3554
+ const a = isRecord(raw) ? raw : {};
3555
+ return {
3556
+ minRunwayRatio: normalizeBoundedInteger(a.minRunwayRatio, fallback.minRunwayRatio, 10, 100),
3557
+ minRunwayGapHours: normalizeBoundedInteger(a.minRunwayGapHours, fallback.minRunwayGapHours, 1, 168)
3558
+ };
3485
3559
  }
3486
3560
  function normalizeBoolean(value, fallback) {
3487
3561
  if (typeof value === "boolean")
@@ -3532,7 +3606,8 @@ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
3532
3606
  },
3533
3607
  codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
3534
3608
  codexTiers,
3535
- strategy: normalizeStrategy(budget.strategy, fallback.strategy)
3609
+ maximize: normalizeMaximizeConfig(budget.maximize, pauseAt, fallback.maximize),
3610
+ allocation: normalizeAllocationConfig(budget.allocation, fallback.allocation)
3536
3611
  };
3537
3612
  }
3538
3613
  function applyBudgetEnvOverrides(budget, env = process.env) {
@@ -3548,7 +3623,17 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
3548
3623
  },
3549
3624
  codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
3550
3625
  codexTiers: budget.codexTiers,
3551
- strategy: env.AGENTBRIDGE_BUDGET_STRATEGY ?? budget.strategy
3626
+ maximize: {
3627
+ targetUtil: env.AGENTBRIDGE_BUDGET_TARGET_UTIL ?? budget.maximize.targetUtil,
3628
+ reserveSlopePctPerHour: env.AGENTBRIDGE_BUDGET_RESERVE_SLOPE_PCT_PER_HOUR ?? budget.maximize.reserveSlopePctPerHour,
3629
+ reserveMaxPct: env.AGENTBRIDGE_BUDGET_RESERVE_MAX_PCT ?? budget.maximize.reserveMaxPct,
3630
+ finishingHorizonMinutes: env.AGENTBRIDGE_BUDGET_FINISHING_HORIZON_MINUTES ?? budget.maximize.finishingHorizonMinutes,
3631
+ resumeHysteresisPct: env.AGENTBRIDGE_BUDGET_RESUME_HYSTERESIS_PCT ?? budget.maximize.resumeHysteresisPct
3632
+ },
3633
+ allocation: {
3634
+ minRunwayRatio: env.AGENTBRIDGE_BUDGET_MIN_RUNWAY_RATIO ?? budget.allocation.minRunwayRatio,
3635
+ minRunwayGapHours: env.AGENTBRIDGE_BUDGET_MIN_RUNWAY_GAP_HOURS ?? budget.allocation.minRunwayGapHours
3636
+ }
3552
3637
  };
3553
3638
  return normalizeBudgetConfig(overlay, budget);
3554
3639
  }
@@ -3661,6 +3746,9 @@ class ConfigService {
3661
3746
  }
3662
3747
  }
3663
3748
 
3749
+ // src/budget/budget-coordinator.ts
3750
+ import { homedir as homedir2 } from "os";
3751
+
3664
3752
  // src/budget/budget-gate.ts
3665
3753
  function matchingGateReset(usage) {
3666
3754
  if (!usage)
@@ -3672,28 +3760,225 @@ function matchingGateReset(usage) {
3672
3760
  return 0;
3673
3761
  return Math.min(...candidates.map((window) => window.resetEpoch));
3674
3762
  }
3675
- function resumeBlockingEpoch(usage, cfg, now) {
3763
+ function retryAfterMsForResume(resumeAfterEpoch, nowMs) {
3764
+ if (resumeAfterEpoch === null)
3765
+ return;
3766
+ const remainingMs = resumeAfterEpoch * 1000 - nowMs;
3767
+ return remainingMs > 0 ? remainingMs : undefined;
3768
+ }
3769
+
3770
+ // src/budget/types.ts
3771
+ var STALE_MAX_AGE_SEC = 600;
3772
+
3773
+ // src/budget/budget-decision.ts
3774
+ var AGENT_LABEL = {
3775
+ claude: "Claude",
3776
+ codex: "Codex"
3777
+ };
3778
+ var WINDOW_LABEL = {
3779
+ fiveHour: "5h",
3780
+ weekly: "\u5468"
3781
+ };
3782
+ var WINDOW_KEYS = ["fiveHour", "weekly"];
3783
+ var MAX_TIME_TO_RESET_HOURS = 7 * 24;
3784
+ var FINISHING_MARGIN_MIN_PCT = 1;
3785
+ var FINISHING_MARGIN_MAX_PCT = 10;
3786
+ var DYNAMIC_LINE_CEILING_PCT = 99;
3787
+ function pct(value) {
3788
+ return `${Math.round(value * 10) / 10}%`;
3789
+ }
3790
+ function clamp(value, min, max) {
3791
+ return Math.min(Math.max(value, min), max);
3792
+ }
3793
+ function clampedTimeToResetHours(window, now) {
3794
+ return Math.min((window.resetEpoch - now) / 3600, MAX_TIME_TO_RESET_HOURS);
3795
+ }
3796
+ function isDecisionGrade(usage, now) {
3797
+ if (!usage)
3798
+ return false;
3799
+ const freshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
3800
+ if (!freshWindow)
3801
+ return false;
3802
+ if (usage.fetchedAt > 0 && now - usage.fetchedAt > STALE_MAX_AGE_SEC)
3803
+ return false;
3804
+ return true;
3805
+ }
3806
+ function dynamicPauseAt(window, burnRatePctPerHour, cfg, now) {
3807
+ const m = cfg.maximize;
3808
+ const rawTimeToResetHours = (window.resetEpoch - now) / 3600;
3809
+ if (rawTimeToResetHours <= 0)
3810
+ return 100;
3811
+ const tH = clampedTimeToResetHours(window, now);
3812
+ const finishingMarginPct = clamp(burnRatePctPerHour * (m.finishingHorizonMinutes / 60), FINISHING_MARGIN_MIN_PCT, FINISHING_MARGIN_MAX_PCT);
3813
+ const projectedAtReset = window.util + burnRatePctPerHour * tH;
3814
+ if (projectedAtReset <= m.targetUtil) {
3815
+ if (window.util >= m.targetUtil)
3816
+ return "admission-closed";
3817
+ if (tH < m.finishingHorizonMinutes / 60 && window.util >= m.targetUtil - finishingMarginPct) {
3818
+ return "admission-closed";
3819
+ }
3820
+ return 100;
3821
+ }
3822
+ const reservePct = Math.min(m.reserveMaxPct, m.reserveSlopePctPerHour * tH);
3823
+ const line = m.targetUtil - finishingMarginPct - reservePct;
3824
+ return clamp(line, cfg.pauseAt, Math.max(cfg.pauseAt, DYNAMIC_LINE_CEILING_PCT));
3825
+ }
3826
+ function dynamicWindowVerdict(window, cfg, now) {
3827
+ const rate = confidentRate(window);
3828
+ if (rate === null)
3829
+ return { kind: "degraded" };
3830
+ if (window.resetEpoch <= now)
3831
+ return { kind: "degraded" };
3832
+ const line = dynamicPauseAt(window, rate, cfg, now);
3833
+ if (line === "admission-closed")
3834
+ return { kind: "admission-closed" };
3835
+ const projectedAtReset = window.util + rate * clampedTimeToResetHours(window, now);
3836
+ if (projectedAtReset <= cfg.maximize.targetUtil) {
3837
+ return { kind: "will-not-fill", projectedAtReset };
3838
+ }
3839
+ return { kind: "will-fill", line };
3840
+ }
3841
+ function confidentRate(window) {
3842
+ if (window.burnConfident !== true)
3843
+ return null;
3844
+ if (typeof window.burnRate !== "number" || !Number.isFinite(window.burnRate) || window.burnRate < 0) {
3845
+ return null;
3846
+ }
3847
+ return window.burnRate;
3848
+ }
3849
+ function maximizeWindowEntry(window, cfg, now) {
3850
+ const rate = confidentRate(window);
3851
+ if (rate === null) {
3852
+ return { blocks: window.util >= cfg.pauseAt, line: null, admission: false };
3853
+ }
3854
+ const line = dynamicPauseAt(window, rate, cfg, now);
3855
+ if (line === "admission-closed") {
3856
+ return { blocks: window.util >= cfg.pauseAt, line: null, admission: true };
3857
+ }
3858
+ return { blocks: window.util >= line, line, admission: false };
3859
+ }
3860
+ function maximizeWindowBlocksResume(window, cfg, now) {
3861
+ const rate = confidentRate(window);
3862
+ if (rate === null) {
3863
+ return window.util >= cfg.resumeBelow;
3864
+ }
3865
+ const line = dynamicPauseAt(window, rate, cfg, now);
3866
+ const hyst = cfg.maximize.resumeHysteresisPct;
3867
+ if (line === "admission-closed") {
3868
+ return window.util >= cfg.pauseAt - hyst;
3869
+ }
3870
+ if (line === 100)
3871
+ return false;
3872
+ return window.util >= line - hyst;
3873
+ }
3874
+ function freshWindows(usage, now) {
3875
+ const out = [];
3876
+ for (const key of WINDOW_KEYS) {
3877
+ const window = usage[key];
3878
+ if (window && window.resetEpoch > now)
3879
+ out.push({ key, window });
3880
+ }
3881
+ return out;
3882
+ }
3883
+ var NO_PAUSE = { pause: false, window: null, line: null, reason: "" };
3884
+ function fallbackPauseReason(agent, usage, cfg) {
3885
+ return `${AGENT_LABEL[agent]} gateUtil ${pct(usage.gateUtil)} \u2265 pauseAt ${pct(cfg.pauseAt)}\uFF08\u515C\u5E95\u5224\u636E\uFF09`;
3886
+ }
3887
+ function agentShouldPause(agent, usage, cfg, now) {
3888
+ if (!usage)
3889
+ return NO_PAUSE;
3890
+ if (!isDecisionGrade(usage, now))
3891
+ return NO_PAUSE;
3892
+ const windows = freshWindows(usage, now);
3893
+ if (windows.length === 0) {
3894
+ if (usage.gateUtil >= cfg.pauseAt) {
3895
+ return { pause: true, window: null, line: null, reason: fallbackPauseReason(agent, usage, cfg) };
3896
+ }
3897
+ return NO_PAUSE;
3898
+ }
3899
+ for (const { key, window } of windows) {
3900
+ const verdict = maximizeWindowEntry(window, cfg, now);
3901
+ if (verdict.blocks) {
3902
+ return {
3903
+ pause: true,
3904
+ window: key,
3905
+ line: verdict.line,
3906
+ reason: buildMaximizeReason(agent, key, window, verdict, cfg)
3907
+ };
3908
+ }
3909
+ }
3910
+ return NO_PAUSE;
3911
+ }
3912
+ function buildMaximizeReason(agent, key, window, verdict, cfg) {
3913
+ const head = `${AGENT_LABEL[agent]} ${WINDOW_LABEL[key]}\u7A97\u53E3 util ${pct(window.util)}`;
3914
+ if (verdict.line !== null) {
3915
+ const rate = window.burnRate;
3916
+ const rateText = typeof rate === "number" ? `\uFF0C\u71C3\u5C3D\u7387\u2248${pct(rate)}/h` : "";
3917
+ return `${head} \u2265 \u52A8\u6001\u6682\u505C\u7EBF ${pct(verdict.line)}${rateText}`;
3918
+ }
3919
+ if (verdict.admission) {
3920
+ return `${head} \u89E6\u53D1\u6536\u5C3E\u4FDD\u62A4\u786C\u7EBF\uFF08\u2265 pauseAt ${pct(cfg.pauseAt)}\uFF09`;
3921
+ }
3922
+ return `${head} \u2265 pauseAt ${pct(cfg.pauseAt)}\uFF08\u71C3\u5C3D\u7387\u91C7\u6837\u4E2D\uFF0C\u9000\u515C\u5E95\u5224\u636E\uFF09`;
3923
+ }
3924
+ function agentCanResume(usage, cfg, now) {
3925
+ if (!isDecisionGrade(usage, now))
3926
+ return false;
3927
+ if (usage.rateLimitedUntil > now)
3928
+ return false;
3929
+ const windows = freshWindows(usage, now);
3930
+ for (const { window } of windows) {
3931
+ if (maximizeWindowBlocksResume(window, cfg, now))
3932
+ return false;
3933
+ }
3934
+ return true;
3935
+ }
3936
+ function effectiveDynamicLine(usage, cfg, now) {
3937
+ if (!usage || !isDecisionGrade(usage, now))
3938
+ return null;
3939
+ let bestLine = null;
3940
+ let bestHeadroom = Number.POSITIVE_INFINITY;
3941
+ for (const { window } of freshWindows(usage, now)) {
3942
+ const rate = confidentRate(window);
3943
+ if (rate === null)
3944
+ continue;
3945
+ const line = dynamicPauseAt(window, rate, cfg, now);
3946
+ if (line === "admission-closed" || line >= 100)
3947
+ continue;
3948
+ const headroom = line - window.util;
3949
+ if (headroom < bestHeadroom) {
3950
+ bestHeadroom = headroom;
3951
+ bestLine = line;
3952
+ }
3953
+ }
3954
+ return bestLine;
3955
+ }
3956
+ function resumeBlockingEpochFor(usage, cfg, now) {
3676
3957
  if (!usage)
3677
3958
  return 0;
3678
3959
  if (usage.rateLimitedUntil > now)
3679
3960
  return usage.rateLimitedUntil;
3680
- if (usage.gateUtil >= cfg.resumeBelow)
3681
- return matchingGateReset(usage);
3682
- return 0;
3961
+ if (!isDecisionGrade(usage, now)) {
3962
+ const reset = matchingGateReset(usage);
3963
+ return reset > now ? reset : 0;
3964
+ }
3965
+ const blockingResets = freshWindows(usage, now).filter(({ window }) => maximizeWindowBlocksResume(window, cfg, now)).map(({ window }) => window.resetEpoch).filter((epoch) => epoch > 0);
3966
+ if (blockingResets.length === 0)
3967
+ return 0;
3968
+ return Math.min(...blockingResets);
3683
3969
  }
3684
3970
 
3685
- // src/budget/types.ts
3686
- var STALE_MAX_AGE_SEC = 600;
3687
-
3688
3971
  // src/budget/budget-state.ts
3689
- var AGENT_LABEL = {
3972
+ var NO_RUNWAY = { claude: null, codex: null };
3973
+ var UNDERUTILIZATION_MIN_WASTE_PCT = 10;
3974
+ var AGENT_LABEL2 = {
3690
3975
  claude: "Claude",
3691
3976
  codex: "Codex"
3692
3977
  };
3693
3978
  var CODEX_BALANCED_WARN_UTIL = 60;
3694
3979
  var CODEX_ECO_WARN_UTIL = 80;
3695
3980
  var CLAUDE_ADVICE_WARN_UTIL = 80;
3696
- function pct(value) {
3981
+ function pct2(value) {
3697
3982
  return `${Math.round(value * 10) / 10}%`;
3698
3983
  }
3699
3984
  function formatEpoch(epoch) {
@@ -3703,40 +3988,23 @@ function formatEpoch(epoch) {
3703
3988
  }
3704
3989
  function usageSummary(name, usage) {
3705
3990
  if (!usage)
3706
- return `${AGENT_LABEL[name]} \u672A\u77E5`;
3707
- return `${AGENT_LABEL[name]} gate=${pct(usage.gateUtil)} warn=${pct(usage.warnUtil)} 5h\u91CD\u7F6E=${formatEpoch(usage.fiveHour?.resetEpoch ?? 0)}`;
3991
+ return `${AGENT_LABEL2[name]} \u672A\u77E5`;
3992
+ return `${AGENT_LABEL2[name]} gate=${pct2(usage.gateUtil)} warn=${pct2(usage.warnUtil)} 5h\u91CD\u7F6E=${formatEpoch(usage.fiveHour?.resetEpoch ?? 0)}`;
3708
3993
  }
3709
3994
  function resumeAfterEpoch(claude, codex, cfg, now) {
3710
3995
  const epochs = [
3711
- resumeBlockingEpoch(claude, cfg, now),
3712
- resumeBlockingEpoch(codex, cfg, now)
3996
+ resumeBlockingEpochFor(claude, cfg, now),
3997
+ resumeBlockingEpochFor(codex, cfg, now)
3713
3998
  ].filter((epoch) => epoch > 0);
3714
3999
  if (epochs.length === 0)
3715
4000
  return null;
3716
4001
  return Math.max(...epochs);
3717
4002
  }
3718
- function isDecisionGrade(usage, now) {
3719
- if (!usage)
3720
- return false;
3721
- const freshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
3722
- if (!freshWindow)
3723
- return false;
3724
- if (usage.fetchedAt > 0 && now - usage.fetchedAt > STALE_MAX_AGE_SEC)
3725
- return false;
3726
- return true;
3727
- }
3728
4003
  function pauseTrigger(agent, usage, cfg, now) {
3729
- if (!usage)
3730
- return null;
3731
- if (!isDecisionGrade(usage, now))
4004
+ const decision = agentShouldPause(agent, usage, cfg, now);
4005
+ if (!decision.pause)
3732
4006
  return null;
3733
- if (usage.gateUtil >= cfg.pauseAt) {
3734
- return {
3735
- agent,
3736
- reason: `${AGENT_LABEL[agent]} gateUtil ${pct(usage.gateUtil)} \u2265 pauseAt ${pct(cfg.pauseAt)}`
3737
- };
3738
- }
3739
- return null;
4007
+ return { agent, reason: decision.reason };
3740
4008
  }
3741
4009
  function driftFor(claude, codex, cfg) {
3742
4010
  if (!claude || !codex)
@@ -3751,33 +4019,75 @@ function driftFor(claude, codex, cfg) {
3751
4019
  lighter: drift > 0 ? "codex" : "claude"
3752
4020
  };
3753
4021
  }
3754
- function parallelState(claude, codex, cfg, now) {
3755
- if (!claude || !codex)
3756
- return { recommended: false, reason: null };
3757
- if (claude.remaining <= cfg.parallel.minRemainingPct || codex.remaining <= cfg.parallel.minRemainingPct) {
3758
- return { recommended: false, reason: null };
4022
+ function runwayBalance(claudeRunway, codexRunway, cfg) {
4023
+ const ch = claudeRunway.seconds / 3600;
4024
+ const xh = codexRunway.seconds / 3600;
4025
+ const lo = Math.min(ch, xh);
4026
+ const hi = Math.max(ch, xh);
4027
+ const ratioPct = hi <= 0 ? 100 : Math.round(100 * lo / hi);
4028
+ const gapHours = Math.abs(ch - xh);
4029
+ if (ratioPct < cfg.allocation.minRunwayRatio && gapHours >= cfg.allocation.minRunwayGapHours) {
4030
+ const shorter = ch < xh ? "claude" : "codex";
4031
+ return { heavier: shorter, lighter: shorter === "claude" ? "codex" : "claude" };
3759
4032
  }
3760
- const claudeReset = claude.fiveHour?.resetEpoch ?? 0;
3761
- const codexReset = codex.fiveHour?.resetEpoch ?? 0;
3762
- if (claudeReset <= now || codexReset <= now)
3763
- return { recommended: false, reason: null };
3764
- const nearestResetSec = Math.min(claudeReset - now, codexReset - now);
3765
- if (nearestResetSec >= cfg.parallel.timeWindowSec)
3766
- return { recommended: false, reason: null };
3767
- const minutes = Math.ceil(nearestResetSec / 60);
4033
+ return null;
4034
+ }
4035
+ function allocationDrift(claude, codex, runway, cfg) {
4036
+ const warnDrift = driftFor(claude, codex, cfg);
4037
+ if (!claude || !codex || !runway.claude || !runway.codex) {
4038
+ return { drift: warnDrift, basis: "warn" };
4039
+ }
4040
+ const balance = runwayBalance(runway.claude, runway.codex, cfg);
3768
4041
  return {
3769
- recommended: true,
3770
- reason: `\u53CC\u65B9\u5269\u4F59\u989D\u5EA6\u5747\u9AD8\u4E8E ${pct(cfg.parallel.minRemainingPct)}\uFF0C\u6700\u8FD1 5h \u6876\u7EA6 ${minutes} \u5206\u949F\u540E\u91CD\u7F6E`
4042
+ drift: {
4043
+ pct: warnDrift.pct,
4044
+ heavier: balance?.heavier ?? null,
4045
+ lighter: balance?.lighter ?? null
4046
+ },
4047
+ basis: "runway"
3771
4048
  };
3772
4049
  }
4050
+ function runwayHoursText(runway) {
4051
+ if (!runway)
4052
+ return "\u672A\u77E5";
4053
+ return `~${(runway.seconds / 3600).toFixed(1)}h`;
4054
+ }
4055
+ function underutilizationState(claude, codex, cfg, now) {
4056
+ let top = null;
4057
+ for (const [agent, usage] of [["claude", claude], ["codex", codex]]) {
4058
+ const weekly = usage?.weekly;
4059
+ if (!weekly)
4060
+ continue;
4061
+ const verdict = dynamicWindowVerdict(weekly, cfg, now);
4062
+ if (verdict.kind !== "will-not-fill")
4063
+ continue;
4064
+ const waste = Math.round((cfg.maximize.targetUtil - verdict.projectedAtReset) * 10) / 10;
4065
+ if (waste < UNDERUTILIZATION_MIN_WASTE_PCT)
4066
+ continue;
4067
+ if (top === null || waste > top.waste) {
4068
+ top = { agent, projected: verdict.projectedAtReset, waste, resetEpoch: weekly.resetEpoch };
4069
+ }
4070
+ }
4071
+ if (top === null)
4072
+ return { recommended: false, reason: null };
4073
+ const hoursToReset = Math.max(0, (top.resetEpoch - now) / 3600);
4074
+ const reason = [
4075
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u989D\u5EA6\u5C06\u6B20\u8F7D\uFF0C\u5EFA\u8BAE\u63D0\u9AD8\u5E76\u884C/\u59D4\u6D3E\u5BC6\u5EA6\u3002",
4076
+ `${AGENT_LABEL2[top.agent]} \u6309\u5F53\u524D\u71C3\u5C3D\u7387\u5468\u7A97\u53E3\u5237\u65B0\u65F6\u53EA\u4F1A\u7528\u5230 ~${pct2(top.projected)}\uFF0C` + `\u8DDD\u5237\u65B0\u8FD8\u6709 ~${hoursToReset.toFixed(1)}h \u2014\u2014 \u5EFA\u8BAE\u62C6\u66F4\u591A\u5E76\u884C\u5B50\u4EFB\u52A1/\u63D0\u9AD8\u59D4\u6D3E\u5BC6\u5EA6\uFF0C` + `\u5426\u5219\u7EA6 ${pct2(top.waste)} \u5468\u989D\u5EA6\u5C06\u4F5C\u5E9F\u3002`
4077
+ ].join(`
4078
+ `);
4079
+ return { recommended: true, reason };
4080
+ }
3773
4081
  function renderBudgetInterventionDirective(claude, codex, side, reason, resumeEpoch, cfg) {
3774
4082
  const resumeText = `\u9884\u8BA1\u6062\u590D\u65F6\u95F4\uFF08\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09\uFF1A${formatEpoch(resumeEpoch)}\u3002`;
4083
+ const resumeCondSingle = `\u5404\u7A97\u53E3 util \u56DE\u843D\u81F3\u52A8\u6001\u6682\u505C\u7EBF \u2212 ${pct2(cfg.maximize.resumeHysteresisPct)} \u4EE5\u4E0B\u6216\u5BF9\u5E94\u7A97\u53E3\u5237\u65B0`;
4084
+ const resumeCondBoth = `\u5404\u7A97\u53E3 util \u90FD\u56DE\u843D\u81F3\u52A8\u6001\u6682\u505C\u7EBF \u2212 ${pct2(cfg.maximize.resumeHysteresisPct)} \u4EE5\u4E0B\u6216\u5BF9\u5E94\u7A97\u53E3\u5237\u65B0`;
3775
4085
  if (side === "claude") {
3776
4086
  return [
3777
4087
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u8FDB\u5165\u63A5\u529B\u6A21\u5F0F\u3002",
3778
4088
  `\u89E6\u53D1\u65B9\uFF1AClaude\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
3779
4089
  `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
3780
- `\u6062\u590D\u53C2\u8003\uFF1AClaude gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
4090
+ `\u6062\u590D\u53C2\u8003\uFF1AClaude ${resumeCondSingle} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
3781
4091
  "\u8BF7\u7ACB\u5373\u4EA4\u63A5\uFF1A\u628A\u5269\u4F59\u4EFB\u52A1\u6E05\u5355\u3001\u5173\u952E\u4E0A\u4E0B\u6587\u3001\u4EA7\u51FA\u4F4D\u7F6E\u3001\u9A8C\u6536\u6807\u51C6\u6253\u5305\u6210\u4E00\u6761 reply \u53D1\u7ED9 Codex\u3002",
3782
4092
  "\u4EA4\u63A5\u540E Claude \u505C\u624B\uFF1B\u8981\u6C42 Codex \u5728\u5355 turn \u5185\u5C3D\u91CF\u5B8C\u6210\uFF0C\u5C3E\u5DF4\u5199 checkpoint\uFF0C\u6682\u505C\u671F\u4E0D\u8981\u671F\u5F85 Claude \u56DE\u590D\u3002"
3783
4093
  ].join(`
@@ -3788,7 +4098,7 @@ function renderBudgetInterventionDirective(claude, codex, side, reason, resumeEp
3788
4098
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u6682\u505C\u59D4\u6D3E\u3002",
3789
4099
  `\u89E6\u53D1\u65B9\uFF1ACodex\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
3790
4100
  `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
3791
- `\u6062\u590D\u53C2\u8003\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
4101
+ `\u6062\u590D\u53C2\u8003\uFF1ACodex ${resumeCondSingle} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
3792
4102
  "\u8BF7 Claude \u5199 checkpoint\uFF0C\u5E76\u53EF solo \u63A8\u8FDB\u4E0D\u4F9D\u8D56 Codex \u7684\u72EC\u7ACB\u90E8\u5206\uFF1B\u4E0D\u8981\u7EE7\u7EED\u5411 Codex \u59D4\u6D3E\uFF0C\u6807\u6CE8\u6E05\u695A\u5206\u5DE5\u65AD\u70B9\u3002"
3793
4103
  ].join(`
3794
4104
  `);
@@ -3797,30 +4107,26 @@ function renderBudgetInterventionDirective(claude, codex, side, reason, resumeEp
3797
4107
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8FDB\u5165\u8054\u5408\u6682\u505C\u3002",
3798
4108
  `\u89E6\u53D1\u65B9\uFF1A\u53CC\u65B9\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
3799
4109
  `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
3800
- `\u6062\u590D\u6761\u4EF6\uFF1AClaude \u4E0E Codex \u7684 gateUtil \u90FD\u4F4E\u4E8E ${pct(cfg.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
4110
+ `\u6062\u590D\u6761\u4EF6\uFF1AClaude \u4E0E Codex \u7684 ${resumeCondBoth}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
3801
4111
  "\u8BF7\u6536\u5C3E\u5F53\u524D\u6B65\u3001\u5199 checkpoint\u3001\u505C\u6B62\u7EE7\u7EED\u59D4\u6D3E\uFF1Bpause \u671F\u95F4\u4E0D\u8981\u91CD\u8BD5\u5411 Codex \u53D1\u9001 reply\u3002"
3802
4112
  ].join(`
3803
4113
  `);
3804
4114
  }
3805
- function balanceDirective(claude, codex, drift, parallel) {
3806
- const heavier = drift.heavier ? AGENT_LABEL[drift.heavier] : "\u672A\u77E5";
3807
- const lighter = drift.lighter ? AGENT_LABEL[drift.lighter] : "\u672A\u77E5";
3808
- const lines = [
3809
- "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u68C0\u6D4B\u5230\u53CC\u65B9\u7528\u91CF\u6BD4\u4F8B\u6F02\u79FB\u3002",
3810
- `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
3811
- `${heavier} \u6BD4 ${lighter} \u9AD8 ${pct(Math.abs(drift.pct))}\uFF0C\u8BF7\u4F18\u5148\u628A\u540E\u7EED\u53EF\u62C6\u5206\u4EFB\u52A1\u5206\u7ED9 ${lighter}\uFF0C\u76F4\u5230 warnUtil \u63A5\u8FD1\u3002`
3812
- ];
3813
- if (parallel.recommended && parallel.reason) {
3814
- lines.push(`${parallel.reason}\uFF1B\u53EF\u8BA9 ${lighter} \u627F\u62C5\u66F4\u591A\u5E76\u884C\u5B50\u4EFB\u52A1\uFF0C\u517C\u987E\u5747\u8861\u4E0E\u63D0\u901F\u3002`);
3815
- }
3816
- return lines.join(`
4115
+ function balanceDirective(claude, codex, drift, basis, runway) {
4116
+ const heavier = drift.heavier ? AGENT_LABEL2[drift.heavier] : "\u672A\u77E5";
4117
+ const lighter = drift.lighter ? AGENT_LABEL2[drift.lighter] : "\u672A\u77E5";
4118
+ if (basis === "runway") {
4119
+ return [
4120
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u6309\u5269\u4F59\u53EF\u5DE5\u4F5C\u65F6\u95F4\u9700\u8981\u5747\u8861\u3002",
4121
+ `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
4122
+ `Claude \u6309\u5F53\u524D\u71C3\u5C3D\u7387\u7EA6\u53EF\u518D\u5DE5\u4F5C ${runwayHoursText(runway.claude)}\u3001` + `Codex ${runwayHoursText(runway.codex)}\uFF08\u7A97\u53E3\u4E3A\u7EA6\u675F\uFF09\uFF1B` + `runway \u8F83\u77ED\u7684\u4E00\u4FA7\u662F ${heavier}\uFF0C\u8BF7\u628A\u540E\u7EED\u53EF\u62C6\u5206\u4EFB\u52A1\u4F18\u5148\u6D3E\u7ED9 ${lighter}\u3002`
4123
+ ].join(`
3817
4124
  `);
3818
- }
3819
- function parallelDirective(claude, codex, parallel) {
4125
+ }
3820
4126
  return [
3821
- "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u5F53\u524D\u989D\u5EA6\u5BCC\u4F59\u4E14\u4E34\u8FD1 5h \u7ED3\u7B97\uFF0C\u5EFA\u8BAE\u52A8\u6001\u5E76\u884C\u3002",
4127
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u68C0\u6D4B\u5230\u53CC\u65B9\u7528\u91CF\u6BD4\u4F8B\u6F02\u79FB\u3002",
3822
4128
  `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
3823
- `${parallel.reason}\uFF1B\u53EF\u4EE5\u62C6\u66F4\u591A\u72EC\u7ACB\u5B50\u4EFB\u52A1\u5E76\u884C\u63A8\u8FDB\u3002`
4129
+ `${heavier} \u6BD4 ${lighter} \u9AD8 ${pct2(Math.abs(drift.pct))}\uFF0C\u8BF7\u4F18\u5148\u628A\u540E\u7EED\u53EF\u62C6\u5206\u4EFB\u52A1\u5206\u7ED9 ${lighter}\uFF0C\u76F4\u5230 warnUtil \u63A5\u8FD1\u3002`
3824
4130
  ].join(`
3825
4131
  `);
3826
4132
  }
@@ -3838,16 +4144,19 @@ function claudeAdviceFor(claude, now) {
3838
4144
  return null;
3839
4145
  if (claude.warnUtil < CLAUDE_ADVICE_WARN_UTIL)
3840
4146
  return null;
3841
- return `Claude warnUtil ${pct(claude.warnUtil)} \u5DF2\u504F\u9AD8\uFF1B\u540E\u7EED\u53EF\u62C6\u5206 subagent \u5EFA\u8BAE\u964D\u6863\u5230 haiku/sonnet\uFF0C\u5E76\u4FDD\u7559\u9AD8\u96BE\u5EA6\u4E3B\u7EBF\u7ED9\u5F53\u524D\u4F1A\u8BDD\u3002`;
4147
+ return `Claude warnUtil ${pct2(claude.warnUtil)} \u5DF2\u504F\u9AD8\uFF1B\u540E\u7EED\u53EF\u62C6\u5206 subagent \u5EFA\u8BAE\u964D\u6863\u5230 haiku/sonnet\uFF0C\u5E76\u4FDD\u7559\u9AD8\u96BE\u5EA6\u4E3B\u7EBF\u7ED9\u5F53\u524D\u4F1A\u8BDD\u3002`;
3842
4148
  }
3843
- function computeBudgetState(claude, codex, cfg, now) {
4149
+ function computeBudgetState(claude, codex, cfg, now, runway = NO_RUNWAY) {
3844
4150
  const triggers = [
3845
4151
  pauseTrigger("claude", claude, cfg, now),
3846
4152
  pauseTrigger("codex", codex, cfg, now)
3847
4153
  ].filter((trigger) => trigger !== null);
3848
4154
  const paused = triggers.length > 0;
3849
- const drift = driftFor(claude, codex, cfg);
3850
- const parallel = paused ? { recommended: false, reason: null } : parallelState(claude, codex, cfg, now);
4155
+ const { drift, basis } = allocationDrift(claude, codex, runway, cfg);
4156
+ const parallel = { recommended: false, reason: null };
4157
+ const adviceEligible = !paused && claude !== null && codex !== null && claude.rateLimitedUntil <= now && codex.rateLimitedUntil <= now && isDecisionGrade(claude, now) && isDecisionGrade(codex, now);
4158
+ const balanceActive = adviceEligible && drift.heavier !== null && drift.lighter !== null;
4159
+ const underutilization = adviceEligible && !balanceActive ? underutilizationState(claude, codex, cfg, now) : { recommended: false, reason: null };
3851
4160
  const resetEpochs = {
3852
4161
  claude: matchingGateReset(claude),
3853
4162
  codex: matchingGateReset(codex)
@@ -3856,18 +4165,18 @@ function computeBudgetState(claude, codex, cfg, now) {
3856
4165
  let phase = "normal";
3857
4166
  if (paused)
3858
4167
  phase = "paused";
3859
- else if (drift.heavier && drift.lighter)
4168
+ else if (balanceActive)
3860
4169
  phase = "balance";
3861
- else if (parallel.recommended)
3862
- phase = "parallel";
4170
+ else if (underutilization.recommended)
4171
+ phase = "underutilized";
3863
4172
  const pauseSide = !paused ? null : triggers.length > 1 ? "both" : triggers[0].agent;
3864
4173
  let directiveToClaude = null;
3865
4174
  if (phase === "paused") {
3866
4175
  directiveToClaude = renderBudgetInterventionDirective(claude, codex, pauseSide ?? "both", triggers.map((trigger) => trigger.reason).join("\uFF1B"), filteredResumeAfterEpoch, cfg);
3867
4176
  } else if (phase === "balance" && claude && codex) {
3868
- directiveToClaude = balanceDirective(claude, codex, drift, parallel);
3869
- } else if (phase === "parallel" && claude && codex) {
3870
- directiveToClaude = parallelDirective(claude, codex, parallel);
4177
+ directiveToClaude = balanceDirective(claude, codex, drift, basis, runway);
4178
+ } else if (phase === "underutilized") {
4179
+ directiveToClaude = underutilization.reason;
3871
4180
  }
3872
4181
  return {
3873
4182
  phase,
@@ -3883,18 +4192,84 @@ function computeBudgetState(claude, codex, cfg, now) {
3883
4192
  resetEpochs
3884
4193
  },
3885
4194
  parallel,
4195
+ underutilization,
3886
4196
  effort: { claudeAdvice: claudeAdviceFor(claude, now), codexTier: codexTierFor(codex, now) },
3887
4197
  directiveToClaude
3888
4198
  };
3889
4199
  }
3890
4200
 
4201
+ // src/budget/advice-cooldown.ts
4202
+ import { readFileSync as readFileSync5 } from "fs";
4203
+ import { join as join5 } from "path";
4204
+ var DEFAULT_ADVICE_COOLDOWN_SEC = 1800;
4205
+ var COOLDOWN_FILENAME = "advice-cooldown.json";
4206
+ function resolveAdviceCooldownSec(env = process.env) {
4207
+ const raw = env.AGENTBRIDGE_BUDGET_ADVICE_COOLDOWN_SEC;
4208
+ if (raw === undefined || raw.trim() === "")
4209
+ return DEFAULT_ADVICE_COOLDOWN_SEC;
4210
+ const parsed = Number(raw);
4211
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 86400)
4212
+ return DEFAULT_ADVICE_COOLDOWN_SEC;
4213
+ return parsed;
4214
+ }
4215
+ function resolveStateDir(homeDir) {
4216
+ const override = process.env.BUDGET_STATE_DIR;
4217
+ if (override && override.trim() !== "")
4218
+ return override.trim();
4219
+ return join5(homeDir, ".budget-guard");
4220
+ }
4221
+
4222
+ class AdviceCooldown {
4223
+ path;
4224
+ cooldownSec;
4225
+ log;
4226
+ constructor(options) {
4227
+ this.path = join5(resolveStateDir(options.homeDir), COOLDOWN_FILENAME);
4228
+ this.cooldownSec = options.cooldownSec ?? DEFAULT_ADVICE_COOLDOWN_SEC;
4229
+ this.log = options.log ?? (() => {});
4230
+ }
4231
+ tryAcquire(direction, now) {
4232
+ const file = this.read();
4233
+ const last = file[direction]?.lastEmittedEpoch;
4234
+ if (this.cooldownSec > 0 && typeof last === "number" && Number.isFinite(last) && now - last < this.cooldownSec && last <= now) {
4235
+ return false;
4236
+ }
4237
+ this.write({ ...file, [direction]: { lastEmittedEpoch: now } });
4238
+ return true;
4239
+ }
4240
+ read() {
4241
+ let raw;
4242
+ try {
4243
+ raw = readFileSync5(this.path, "utf-8");
4244
+ } catch {
4245
+ return {};
4246
+ }
4247
+ try {
4248
+ const parsed = JSON.parse(raw);
4249
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
4250
+ return {};
4251
+ return parsed;
4252
+ } catch {
4253
+ this.log(`advice-cooldown: ignoring malformed ${this.path}`);
4254
+ return {};
4255
+ }
4256
+ }
4257
+ write(file) {
4258
+ try {
4259
+ atomicWriteJson(this.path, file);
4260
+ } catch (error) {
4261
+ this.log(`advice-cooldown: write failed: ${error instanceof Error ? error.message : String(error)}`);
4262
+ }
4263
+ }
4264
+ }
4265
+
3891
4266
  // src/budget/budget-fingerprint.ts
3892
4267
  var RESET_FINGERPRINT_BUCKET_SEC = 600;
3893
- var AGENT_LABEL2 = {
4268
+ var AGENT_LABEL3 = {
3894
4269
  claude: "Claude",
3895
4270
  codex: "Codex"
3896
4271
  };
3897
- function pct2(value) {
4272
+ function pct3(value) {
3898
4273
  return `${Math.round(value * 10) / 10}%`;
3899
4274
  }
3900
4275
  function formatEpoch2(epoch) {
@@ -3926,46 +4301,38 @@ function agentsToSide(agents) {
3926
4301
  return "codex";
3927
4302
  return null;
3928
4303
  }
3929
- function shouldEnter(usage, cfg, now) {
3930
- if (!isDecisionGrade(usage, now))
3931
- return false;
3932
- return usage.gateUtil >= cfg.pauseAt;
3933
- }
3934
- function canAgentResume(usage, cfg, now) {
3935
- if (!isDecisionGrade(usage, now))
3936
- return false;
3937
- if (usage.rateLimitedUntil > now)
3938
- return false;
3939
- return usage.gateUtil < cfg.resumeBelow;
3940
- }
3941
4304
  function nextActiveSide(prevSide, state, cfg) {
3942
4305
  const active = new Set(sideToAgents(prevSide));
3943
4306
  for (const agent of ["claude", "codex"]) {
3944
4307
  const usage = state.perAgent[agent];
3945
- if (shouldEnter(usage, cfg, state.now)) {
4308
+ if (agentShouldPause(agent, usage, cfg, state.now).pause) {
3946
4309
  active.add(agent);
3947
- } else if (active.has(agent) && canAgentResume(usage, cfg, state.now)) {
4310
+ } else if (active.has(agent) && agentCanResume(usage, cfg, state.now)) {
3948
4311
  active.delete(agent);
3949
4312
  }
3950
4313
  }
3951
4314
  return agentsToSide(active);
3952
4315
  }
4316
+ function removedAgents(prevSide, currentSide) {
4317
+ const current = new Set(sideToAgents(currentSide));
4318
+ return sideToAgents(prevSide).filter((agent) => !current.has(agent));
4319
+ }
3953
4320
  function activeSideReason(agent, usage, cfg, now) {
3954
4321
  if (!usage)
3955
- return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
4322
+ return `${AGENT_LABEL3[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
3956
4323
  if (usage.rateLimitedUntil > now) {
3957
- return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${formatEpoch2(usage.rateLimitedUntil)}`;
4324
+ return `${AGENT_LABEL3[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${formatEpoch2(usage.rateLimitedUntil)}`;
3958
4325
  }
3959
- if (usage.gateUtil >= cfg.pauseAt) {
3960
- return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(cfg.pauseAt)}`;
3961
- }
3962
- return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(cfg.resumeBelow)}`;
4326
+ const decision = agentShouldPause(agent, usage, cfg, now);
4327
+ if (decision.pause)
4328
+ return decision.reason;
4329
+ return `${AGENT_LABEL3[agent]} gateUtil ${pct3(usage.gateUtil)} \u5C1A\u672A\u6EE1\u8DB3\u51FA\u95F8\u6761\u4EF6`;
3963
4330
  }
3964
4331
  function interventionReason(side, state, cfg) {
3965
4332
  return sideToAgents(side).map((agent) => activeSideReason(agent, state.perAgent[agent], cfg, state.now)).join("\uFF1B");
3966
4333
  }
3967
4334
  function resumeAfterEpoch2(side, state, cfg) {
3968
- const epochs = sideToAgents(side).map((agent) => resumeBlockingEpoch(state.perAgent[agent], cfg, state.now)).filter((epoch) => epoch > 0);
4335
+ const epochs = sideToAgents(side).map((agent) => resumeBlockingEpochFor(state.perAgent[agent], cfg, state.now)).filter((epoch) => epoch > 0);
3969
4336
  if (epochs.length === 0)
3970
4337
  return null;
3971
4338
  return Math.max(...epochs);
@@ -3999,6 +4366,7 @@ function directiveFingerprint(state, activeSide) {
3999
4366
  function classifyPoll(prev, state, cfg) {
4000
4367
  const previousSide = prev.side;
4001
4368
  const currentSide = nextActiveSide(previousSide, state, cfg);
4369
+ const recoveredSides = removedAgents(previousSide, currentSide);
4002
4370
  if (currentSide) {
4003
4371
  const reason = interventionReason(currentSide, state, cfg);
4004
4372
  const nextResumeRaw = resumeAfterEpoch2(currentSide, state, cfg);
@@ -4015,33 +4383,67 @@ function classifyPoll(prev, state, cfg) {
4015
4383
  reason,
4016
4384
  resumeEpoch,
4017
4385
  emit,
4018
- pauseChanged
4386
+ pauseChanged,
4387
+ recoveredSides
4019
4388
  }
4020
4389
  };
4021
4390
  }
4022
4391
  if (previousSide) {
4023
4392
  return {
4024
4393
  next: { side: null, fingerprint: null, resumeEpoch: null, reason: null },
4025
- effect: { kind: "exit", previousSide }
4394
+ effect: { kind: "exit", previousSide, recoveredSides }
4026
4395
  };
4027
4396
  }
4028
4397
  if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
4029
- return { next: prev, effect: { kind: "none" } };
4398
+ return { next: prev, effect: { kind: "none", recoveredSides: [] } };
4030
4399
  }
4031
4400
  if (!state.directiveToClaude) {
4032
4401
  return {
4033
4402
  next: { side: null, fingerprint: null, resumeEpoch: null, reason: null },
4034
- effect: { kind: "none" }
4403
+ effect: { kind: "none", recoveredSides: [] }
4035
4404
  };
4036
4405
  }
4037
4406
  const fingerprint = directiveFingerprint(state);
4038
4407
  if (fingerprint !== prev.fingerprint) {
4039
4408
  return {
4040
4409
  next: { side: null, fingerprint, resumeEpoch: null, reason: null },
4041
- effect: { kind: "advise", phase: state.phase }
4410
+ effect: { kind: "advise", phase: state.phase, recoveredSides: [] }
4411
+ };
4412
+ }
4413
+ return { next: prev, effect: { kind: "none", recoveredSides: [] } };
4414
+ }
4415
+ function resumeCandidateSides(effect) {
4416
+ if (effect.recoveredSides.length > 0) {
4417
+ return effect.recoveredSides;
4418
+ }
4419
+ switch (effect.kind) {
4420
+ case "exit":
4421
+ return sideToAgents(effect.previousSide);
4422
+ case "enter":
4423
+ case "hold-uncertain":
4424
+ return sideToAgents(effect.side);
4425
+ case "advise":
4426
+ case "none":
4427
+ return [];
4428
+ }
4429
+ }
4430
+ function computeResumeCandidate(sides, state, cfg, signals) {
4431
+ const candidate = {};
4432
+ const detail = {};
4433
+ for (const agent of sides) {
4434
+ const windowRefreshed = agentCanResume(state.perAgent[agent], cfg, state.now);
4435
+ const ready = windowRefreshed && signals.pendingExists[agent] && signals.tuiReady[agent] && signals.checkpointExists;
4436
+ candidate[agent] = ready;
4437
+ const pending = signals.pending?.[agent];
4438
+ detail[agent] = {
4439
+ ready,
4440
+ ...pending ? { pending } : {},
4441
+ ...signals.checkpointPath ? { checkpointPath: signals.checkpointPath } : {}
4042
4442
  };
4043
4443
  }
4044
- return { next: prev, effect: { kind: "none" } };
4444
+ if (sides.length > 0)
4445
+ candidate.detail = detail;
4446
+ return candidate;
4045
4447
  }
4046
4448
 
4047
4449
  // src/budget/burn-view.ts
@@ -4110,17 +4512,17 @@ var REAL_BUDGET_POLL_SCHEDULER = {
4110
4512
  clearTimeout(timer);
4111
4513
  }
4112
4514
  };
4113
- var AGENT_LABEL3 = {
4515
+ var AGENT_LABEL4 = {
4114
4516
  claude: "Claude",
4115
4517
  codex: "Codex"
4116
4518
  };
4117
- function pct3(value) {
4519
+ function pct4(value) {
4118
4520
  return `${Math.round(value * 10) / 10}%`;
4119
4521
  }
4120
4522
  function usageLine(agent, usage) {
4121
4523
  if (!usage)
4122
- return `${AGENT_LABEL3[agent]} \u672A\u77E5`;
4123
- return `${AGENT_LABEL3[agent]} gate=${pct3(usage.gateUtil)} warn=${pct3(usage.warnUtil)}`;
4524
+ return `${AGENT_LABEL4[agent]} \u672A\u77E5`;
4525
+ return `${AGENT_LABEL4[agent]} gate=${pct4(usage.gateUtil)} warn=${pct4(usage.warnUtil)}`;
4124
4526
  }
4125
4527
  function maxPollDelayMs(config) {
4126
4528
  return Math.max(0, config.pollSeconds * 1000);
@@ -4181,9 +4583,13 @@ class BudgetCoordinator {
4181
4583
  now;
4182
4584
  scheduler;
4183
4585
  log;
4586
+ onResume;
4587
+ resumeSignals;
4588
+ adviceCooldown;
4184
4589
  timer = null;
4185
4590
  running = false;
4186
4591
  fpState = INITIAL_FINGERPRINT_STATE;
4592
+ resumeCandidate = {};
4187
4593
  latestSnapshot = null;
4188
4594
  pendingOverrideTier = null;
4189
4595
  pendingOverrides = null;
@@ -4199,6 +4605,13 @@ class BudgetCoordinator {
4199
4605
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
4200
4606
  this.scheduler = options.scheduler ?? REAL_BUDGET_POLL_SCHEDULER;
4201
4607
  this.log = options.log ?? (() => {});
4608
+ this.onResume = options.onResume ?? (() => {});
4609
+ this.resumeSignals = options.resumeSignals ?? null;
4610
+ this.adviceCooldown = options.adviceCooldown ?? new AdviceCooldown({
4611
+ homeDir: homedir2(),
4612
+ cooldownSec: resolveAdviceCooldownSec(),
4613
+ log: this.log
4614
+ });
4202
4615
  }
4203
4616
  async start() {
4204
4617
  if (this.running || !this.config.enabled)
@@ -4224,6 +4637,19 @@ class BudgetCoordinator {
4224
4637
  getSnapshot() {
4225
4638
  return this.latestSnapshot;
4226
4639
  }
4640
+ getResumeCandidate() {
4641
+ const { detail, ...rest } = this.resumeCandidate;
4642
+ return detail ? {
4643
+ ...rest,
4644
+ detail: Object.fromEntries(Object.entries(detail).map(([side, value]) => [
4645
+ side,
4646
+ {
4647
+ ...value,
4648
+ ...value.pending ? { pending: { ...value.pending } } : {}
4649
+ }
4650
+ ]))
4651
+ } : { ...rest };
4652
+ }
4227
4653
  getCodexTurnOverrides() {
4228
4654
  if (!this.tierControlEnabled())
4229
4655
  return null;
@@ -4279,10 +4705,15 @@ class BudgetCoordinator {
4279
4705
  if (!this.running) {
4280
4706
  return;
4281
4707
  }
4282
- const state = computeBudgetState(usage.claude, usage.codex, this.config, this.now());
4708
+ const now = this.now();
4709
+ const runway = {
4710
+ claude: agentRunway(usage.claude, now),
4711
+ codex: agentRunway(usage.codex, now)
4712
+ };
4713
+ const state = computeBudgetState(usage.claude, usage.codex, this.config, now, runway);
4283
4714
  this.updatePendingOverrides(state.effort.codexTier);
4284
4715
  this.applyState(state);
4285
- this.setSnapshot(this.toSnapshot(state));
4716
+ this.setSnapshot(this.toSnapshot(state, runway));
4286
4717
  }
4287
4718
  setSnapshot(snapshot) {
4288
4719
  this.latestSnapshot = snapshot;
@@ -4291,6 +4722,11 @@ class BudgetCoordinator {
4291
4722
  applyState(state) {
4292
4723
  const { next, effect } = classifyPoll(this.fpState, state, this.config);
4293
4724
  this.fpState = next;
4725
+ this.resumeCandidate = this.resumeSignals ? computeResumeCandidate(resumeCandidateSides(effect), state, this.config, this.resumeSignals()) : {};
4726
+ for (const side of effect.recoveredSides) {
4727
+ const { id, directive } = this.emitRecovery(side, state);
4728
+ this.onResume(side, directive, id);
4729
+ }
4294
4730
  switch (effect.kind) {
4295
4731
  case "enter":
4296
4732
  case "hold-uncertain": {
@@ -4303,12 +4739,16 @@ class BudgetCoordinator {
4303
4739
  }
4304
4740
  case "exit": {
4305
4741
  this.onPauseChange(false);
4306
- this.emitDirective(this.recoveryPrefix(effect.previousSide), this.recoveryDirective(state, effect.previousSide));
4307
4742
  return;
4308
4743
  }
4309
4744
  case "advise": {
4310
- const prefix = effect.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
4311
- this.emitDirective(prefix, state.directiveToClaude);
4745
+ if (effect.phase === "underutilized") {
4746
+ if (!this.adviceCooldown.tryAcquire("underutilization", state.now))
4747
+ return;
4748
+ this.emitDirective("system_budget_underutilized", state.directiveToClaude);
4749
+ return;
4750
+ }
4751
+ this.emitDirective("system_budget_balance", state.directiveToClaude);
4312
4752
  return;
4313
4753
  }
4314
4754
  case "none":
@@ -4349,45 +4789,44 @@ class BudgetCoordinator {
4349
4789
  this.pendingOverrides = { ...overrides };
4350
4790
  }
4351
4791
  emitDirective(prefix, content) {
4352
- this.emit(`${prefix}_${this.sequence++}`, content);
4792
+ const id = `${prefix}_${this.sequence++}`;
4793
+ this.emit(id, content);
4794
+ return id;
4353
4795
  }
4354
4796
  interventionPrefix(side) {
4355
4797
  return side === "claude" ? "system_budget_handoff" : "system_budget_pause";
4356
4798
  }
4357
- recoveryPrefix(previousSide) {
4358
- return previousSide === "claude" ? "system_budget_claude_recovered" : "system_budget_resume";
4799
+ recoveryPrefix(side) {
4800
+ return side === "claude" ? "system_budget_claude_recovered" : "system_budget_resume";
4801
+ }
4802
+ emitRecovery(side, state) {
4803
+ const directive = this.recoveryDirective(state, side);
4804
+ const id = this.emitDirective(this.recoveryPrefix(side), directive);
4805
+ return { id, directive };
4359
4806
  }
4360
4807
  interventionDirective(state, side, reason, resumeEpoch) {
4361
4808
  return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, reason || "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", resumeEpoch, this.config);
4362
4809
  }
4363
- recoveryDirective(state, previousSide) {
4364
- if (previousSide === "claude") {
4810
+ recoveryDirective(state, side) {
4811
+ const recoveredText = `\u5404\u7A97\u53E3 util \u5DF2\u56DE\u843D\u81F3\u52A8\u6001\u6682\u505C\u7EBF \u2212 ${pct4(this.config.maximize.resumeHysteresisPct)} \u4EE5\u4E0B\u6216\u5BF9\u5E94\u7A97\u53E3\u5DF2\u5237\u65B0`;
4812
+ if (side === "claude") {
4365
4813
  return [
4366
4814
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u9884\u7B97\u5DF2\u6062\u590D\u3002",
4367
4815
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
4368
- `Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4816
+ `Claude ${recoveredText}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4369
4817
  "Claude \u53EF\u6062\u590D orchestrator \u89D2\u8272\uFF1B\u540E\u7EED\u5206\u914D\u524D\u8BF7\u91CD\u65B0\u67E5\u8BE2\u5B9E\u65F6\u989D\u5EA6\uFF0C\u4E0D\u8981\u4F9D\u8D56\u65E7\u6570\u5B57\u3002"
4370
4818
  ].join(`
4371
- `);
4372
- }
4373
- if (previousSide === "codex") {
4374
- return [
4375
- "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u9884\u7B97\u95F8\u95E8\u89E3\u9664\u3002",
4376
- `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
4377
- `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4378
- "\u5EFA\u8BAE Claude \u7528 reply \u5E26\u4E0A\u5F53\u524D\u76EE\u6807\u3001checkpoint \u548C\u4E0B\u4E00\u6B65\uFF0C\u5524\u9192 Codex \u63A5\u7EED\u6267\u884C\u3002"
4379
- ].join(`
4380
4819
  `);
4381
4820
  }
4382
4821
  return [
4383
- "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8054\u5408\u6682\u505C\u89E3\u9664\u3002",
4822
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u9884\u7B97\u95F8\u95E8\u89E3\u9664\u3002",
4384
4823
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
4385
- `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1A\u53CC\u65B9 gateUtil \u5747\u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4824
+ `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex ${recoveredText}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4386
4825
  "\u5EFA\u8BAE Claude \u7528 reply \u5E26\u4E0A\u5F53\u524D\u76EE\u6807\u3001checkpoint \u548C\u4E0B\u4E00\u6B65\uFF0C\u5524\u9192 Codex \u63A5\u7EED\u6267\u884C\u3002"
4387
4826
  ].join(`
4388
4827
  `);
4389
4828
  }
4390
- toSnapshot(state) {
4829
+ toSnapshot(state, runway) {
4391
4830
  const paused = this.isPaused();
4392
4831
  return {
4393
4832
  phase: paused ? "paused" : state.phase,
@@ -4403,18 +4842,23 @@ class BudgetCoordinator {
4403
4842
  parallelRecommended: paused ? false : state.parallel.recommended,
4404
4843
  codexTier: state.effort.codexTier,
4405
4844
  claudeAdvice: state.effort.claudeAdvice,
4406
- ...this.burnRateSnapshotFields(state)
4845
+ ...this.burnRateSnapshotFields(state, runway),
4846
+ ...this.dynamicLineSnapshotFields(state)
4847
+ };
4848
+ }
4849
+ dynamicLineSnapshotFields(state) {
4850
+ return {
4851
+ dynamicPauseLine: {
4852
+ claude: effectiveDynamicLine(state.perAgent.claude, this.config, state.now),
4853
+ codex: effectiveDynamicLine(state.perAgent.codex, this.config, state.now)
4854
+ }
4407
4855
  };
4408
4856
  }
4409
- burnRateSnapshotFields(state) {
4857
+ burnRateSnapshotFields(state, runway) {
4410
4858
  const rates = {
4411
4859
  claude: agentBurnRates(state.perAgent.claude),
4412
4860
  codex: agentBurnRates(state.perAgent.codex)
4413
4861
  };
4414
- const runway = {
4415
- claude: agentRunway(state.perAgent.claude, state.now),
4416
- codex: agentRunway(state.perAgent.codex, state.now)
4417
- };
4418
4862
  if (!hasAnyBurnSignal(rates, runway))
4419
4863
  return {};
4420
4864
  return { burnRate: rates, runway };
@@ -4424,8 +4868,8 @@ class BudgetCoordinator {
4424
4868
  // src/budget/quota-source.ts
4425
4869
  import { execFile } from "child_process";
4426
4870
  import { existsSync as existsSync5 } from "fs";
4427
- import { homedir as homedir2 } from "os";
4428
- import { basename, join as join5 } from "path";
4871
+ import { homedir as homedir3 } from "os";
4872
+ import { basename, join as join6 } from "path";
4429
4873
  function parseBurnFields(record) {
4430
4874
  const group = {};
4431
4875
  let any = false;
@@ -4515,7 +4959,7 @@ function asFiniteNumber(value) {
4515
4959
  function numberOr(value, fallback) {
4516
4960
  return asFiniteNumber(value) ?? fallback;
4517
4961
  }
4518
- function clamp(value, min, max) {
4962
+ function clamp2(value, min, max) {
4519
4963
  return Math.min(max, Math.max(min, value));
4520
4964
  }
4521
4965
  function asRecord(value) {
@@ -4536,7 +4980,7 @@ function normalizeBucket(value, fetchedAt) {
4536
4980
  }
4537
4981
  return {
4538
4982
  id,
4539
- util: clamp(util, 0, 100),
4983
+ util: clamp2(util, 0, 100),
4540
4984
  resetEpoch: Math.max(0, resetEpoch),
4541
4985
  resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter),
4542
4986
  burn: parseBurnFields(bucket)
@@ -4550,7 +4994,7 @@ function normalizeTopLevelBucket(record, util, fetchedAt) {
4550
4994
  }
4551
4995
  return {
4552
4996
  id: "top_level",
4553
- util: clamp(util, 0, 100),
4997
+ util: clamp2(util, 0, 100),
4554
4998
  resetEpoch: Math.max(0, resetEpoch),
4555
4999
  resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter),
4556
5000
  burn: parseBurnFields(record)
@@ -4617,8 +5061,8 @@ function identifyWindows(buckets) {
4617
5061
  function normalizeTolerantProbeRecord(record) {
4618
5062
  const fetchedAt = numberOr(record.fetched_at ?? record.fetchedAt ?? record.now_epoch ?? record.nowEpoch, 0);
4619
5063
  const hasFiniteUtil = asFiniteNumber(record.util ?? record.hard_util ?? record.hardUtil) !== null || asFiniteNumber(record.warn_util ?? record.warnUtil) !== null;
4620
- const gateUtil = clamp(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
4621
- const warnUtil = clamp(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
5064
+ const gateUtil = clamp2(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
5065
+ const warnUtil = clamp2(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
4622
5066
  const rawBuckets = Array.isArray(record.buckets) ? record.buckets : [];
4623
5067
  const buckets = rawBuckets.map((bucket) => normalizeBucket(bucket, fetchedAt)).filter((bucket) => bucket !== null);
4624
5068
  let parsedVia = "id-match";
@@ -4645,7 +5089,7 @@ function normalizeTolerantProbeRecord(record) {
4645
5089
  warnUtil,
4646
5090
  fiveHour,
4647
5091
  weekly,
4648
- remaining: clamp(100 - gateUtil, 0, 100),
5092
+ remaining: clamp2(100 - gateUtil, 0, 100),
4649
5093
  rateLimitedUntil,
4650
5094
  fetchedAt,
4651
5095
  parsedVia
@@ -4693,7 +5137,11 @@ function isDegradedUsage(usage, now = Math.floor(Date.now() / 1000)) {
4693
5137
  if (usage.stale || !usage.ok)
4694
5138
  return true;
4695
5139
  const hasFreshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
4696
- return !hasFreshWindow;
5140
+ if (!hasFreshWindow)
5141
+ return true;
5142
+ if (usage.fetchedAt > 0 && now - usage.fetchedAt > STALE_MAX_AGE_SEC)
5143
+ return true;
5144
+ return false;
4697
5145
  }
4698
5146
 
4699
5147
  class QuotaSource {
@@ -4708,7 +5156,7 @@ class QuotaSource {
4708
5156
  unknownSchemaVersionsLogged = new Set;
4709
5157
  constructor(options = {}) {
4710
5158
  this.env = options.env ?? process.env;
4711
- this.homeDir = options.homeDir ?? homedir2();
5159
+ this.homeDir = options.homeDir ?? homedir3();
4712
5160
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
4713
5161
  this.runner = options.runner ?? defaultRunner;
4714
5162
  this.log = options.log ?? (() => {});
@@ -4740,11 +5188,11 @@ class QuotaSource {
4740
5188
  add(command, commandKind(command));
4741
5189
  return candidates;
4742
5190
  }
4743
- const binDir = join5(this.homeDir, ".budget-guard/bin");
4744
- const installedProbeMjs = join5(binDir, "probe.mjs");
5191
+ const binDir = join6(this.homeDir, ".budget-guard/bin");
5192
+ const installedProbeMjs = join6(binDir, "probe.mjs");
4745
5193
  if (existsSync5(installedProbeMjs))
4746
5194
  add(installedProbeMjs, "probe-mjs");
4747
- const installedBudgetProbe = join5(binDir, "budget-probe");
5195
+ const installedBudgetProbe = join6(binDir, "budget-probe");
4748
5196
  if (existsSync5(installedBudgetProbe))
4749
5197
  add(installedBudgetProbe, "budget-probe");
4750
5198
  return candidates;
@@ -4807,9 +5255,603 @@ function createQuotaSource(options) {
4807
5255
  return new QuotaSource(options);
4808
5256
  }
4809
5257
 
5258
+ // src/budget/pending-reader.ts
5259
+ import { createHash } from "crypto";
5260
+ import { join as join7 } from "path";
5261
+ function nodeFs() {
5262
+ return __require("fs");
5263
+ }
5264
+ function cwdMatches(entryCwd, optsCwd) {
5265
+ if (entryCwd === optsCwd)
5266
+ return true;
5267
+ try {
5268
+ const fs2 = nodeFs();
5269
+ return fs2.realpathSync(entryCwd) === fs2.realpathSync(optsCwd);
5270
+ } catch {
5271
+ return false;
5272
+ }
5273
+ }
5274
+ function parsePendingPayload(value) {
5275
+ const record = asRecord(value);
5276
+ if (!record)
5277
+ return null;
5278
+ const sessionId = record.session_id;
5279
+ if (typeof sessionId !== "string" || sessionId === "")
5280
+ return null;
5281
+ const util = asFiniteNumber(record.util);
5282
+ if (util === null)
5283
+ return null;
5284
+ const warnUtil = numberOr(record.warn_util, util);
5285
+ const resetEpoch = numberOr(record.reset_epoch ?? record.reset, 0);
5286
+ const at = numberOr(record.at, 0);
5287
+ const cwd = typeof record.cwd === "string" ? record.cwd : "";
5288
+ const status = typeof record.status === "string" ? record.status : "";
5289
+ const agent = record.agent === "claude" || record.agent === "codex" ? record.agent : null;
5290
+ if (agent === null)
5291
+ return null;
5292
+ return { status, agent, sessionId, cwd, resetEpoch, util, warnUtil, at, sourcePath: "", contentHash: "" };
5293
+ }
5294
+ function sha256(value) {
5295
+ return createHash("sha256").update(value).digest("hex");
5296
+ }
5297
+ function resolveStateDir2(homeDir) {
5298
+ const override = process.env.BUDGET_STATE_DIR;
5299
+ if (override && override.trim() !== "")
5300
+ return override.trim();
5301
+ return join7(homeDir, ".budget-guard");
5302
+ }
5303
+ function readPendingFile(path, log) {
5304
+ let raw;
5305
+ try {
5306
+ raw = nodeFs().readFileSync(path, "utf-8");
5307
+ } catch (error) {
5308
+ log(`pending reader: skip unreadable ${path}: ${error instanceof Error ? error.message : String(error)}`);
5309
+ return null;
5310
+ }
5311
+ const text = String(raw).trim();
5312
+ if (text === "")
5313
+ return null;
5314
+ let parsed;
5315
+ try {
5316
+ parsed = JSON.parse(text);
5317
+ } catch {
5318
+ log(`pending reader: skip malformed JSON ${path}`);
5319
+ return null;
5320
+ }
5321
+ const entry = parsePendingPayload(parsed);
5322
+ if (!entry)
5323
+ return null;
5324
+ return { ...entry, sourcePath: path, contentHash: sha256(text) };
5325
+ }
5326
+ function listScopeFiles(stateDir, agent, log) {
5327
+ const pendingDir = join7(stateDir, "pending");
5328
+ let names;
5329
+ try {
5330
+ names = nodeFs().readdirSync(pendingDir);
5331
+ } catch {
5332
+ return [];
5333
+ }
5334
+ const prefix = `${agent}_`;
5335
+ return names.filter((name) => name.startsWith(prefix) && name.endsWith(".json")).map((name) => join7(pendingDir, name));
5336
+ }
5337
+ function readGuardPending(opts) {
5338
+ const log = opts.log ?? (() => {});
5339
+ const stateDir = resolveStateDir2(opts.homeDir);
5340
+ const paths = [
5341
+ ...listScopeFiles(stateDir, opts.agent, log),
5342
+ join7(stateDir, `pending_${opts.agent}.json`)
5343
+ ];
5344
+ const bySession = new Map;
5345
+ for (const path of paths) {
5346
+ const entry = readPendingFile(path, log);
5347
+ if (!entry)
5348
+ continue;
5349
+ if (entry.agent !== opts.agent)
5350
+ continue;
5351
+ if (entry.status !== "paused")
5352
+ continue;
5353
+ if (opts.cwd !== undefined && !cwdMatches(entry.cwd, opts.cwd))
5354
+ continue;
5355
+ if (!bySession.has(entry.sessionId)) {
5356
+ bySession.set(entry.sessionId, entry);
5357
+ }
5358
+ }
5359
+ return [...bySession.values()];
5360
+ }
5361
+
5362
+ // src/budget/resume-injection-queue.ts
5363
+ import { createHash as createHash2 } from "crypto";
5364
+ import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync5, openSync as openSync3, readdirSync, readFileSync as readFileSync6, realpathSync, unlinkSync as unlinkSync4, writeFileSync as writeFileSync3 } from "fs";
5365
+ import { join as join8 } from "path";
5366
+
5367
+ // src/budget/resume-prompt.ts
5368
+ var RESUME_PROMPT = "\u989D\u5EA6\u7A97\u53E3\u5DF2\u5237\u65B0\uFF0C\u7EE7\u7EED\u4E0A\u6B21\u672A\u5B8C\u6210\u7684\u4EFB\u52A1\uFF1A\u4ECE .agent/checkpoint.md \u7684\u300C\u4E0B\u4E00\u6B65\u300D\u63A5\u7740\u505A\uFF1B\u5B8C\u6210\u540E\u505C\u4E0B\u5E76\u6807 DONE\u3002";
5369
+ function claudeResumePrompt(resumeId) {
5370
+ return "\u989D\u5EA6\u7A97\u53E3\u5DF2\u5237\u65B0\u3002" + `\u8BF7\u5148\u8C03\u7528 ack_resume(resume_id="${resumeId}", status="resumed") \u786E\u8BA4\u5DF2\u6536\u5230\u672C\u901A\u77E5\uFF08ACK = \u5DF2\u63A5\u6536\uFF0C\u4E0D\u662F\u5B8C\u6210\uFF0C\u8BF7\u7ACB\u5373\u8C03\u7528\uFF0C\u4E0D\u8981\u7B49\u4EFB\u52A1\u505A\u5B8C\uFF09\uFF0C` + "\u518D\u4ECE .agent/checkpoint.md \u7684\u300C\u4E0B\u4E00\u6B65\u300D\u63A5\u7740\u505A\uFF1B\u5B8C\u6210\u540E\u505C\u4E0B\u5E76\u6807 DONE\u3002";
5371
+ }
5372
+
5373
+ // src/budget/resume-injection-queue.ts
5374
+ var DEFAULT_RETRY_MS = 5000;
5375
+ var DEFAULT_CONFIRM_TIMEOUT_MS = 60000;
5376
+ var DEFAULT_MAX_ATTEMPTS = 5;
5377
+ var DEFAULT_STALE_CLAIM_TTL_SEC = 300;
5378
+ var DEFAULT_CONSUMED_TTL_SEC = 7 * 24 * 3600;
5379
+ function finitePositive(value, fallback) {
5380
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
5381
+ }
5382
+
5383
+ class ResumeInjectionQueue {
5384
+ inject;
5385
+ scheduler;
5386
+ retryMs;
5387
+ confirmTimeoutMs;
5388
+ maxAttempts;
5389
+ log;
5390
+ onInjectionAccepted;
5391
+ onInjectionSuperseded;
5392
+ onConfirmed;
5393
+ onAbandoned;
5394
+ entries = new Map;
5395
+ resetSweepDepth = 0;
5396
+ constructor(options) {
5397
+ this.inject = options.inject;
5398
+ this.scheduler = options.scheduler ?? globalThis;
5399
+ this.retryMs = finitePositive(options.retryMs, DEFAULT_RETRY_MS);
5400
+ this.confirmTimeoutMs = finitePositive(options.confirmTimeoutMs, DEFAULT_CONFIRM_TIMEOUT_MS);
5401
+ this.maxAttempts = finitePositive(options.maxAttempts, DEFAULT_MAX_ATTEMPTS);
5402
+ this.log = options.log ?? (() => {});
5403
+ this.onInjectionAccepted = options.onInjectionAccepted ?? (() => {});
5404
+ this.onInjectionSuperseded = options.onInjectionSuperseded ?? (() => {});
5405
+ this.onConfirmed = options.onConfirmed ?? (() => {});
5406
+ this.onAbandoned = options.onAbandoned ?? (() => {});
5407
+ }
5408
+ get size() {
5409
+ return this.entries.size;
5410
+ }
5411
+ get(resumeId) {
5412
+ const entry = this.entries.get(resumeId);
5413
+ if (!entry)
5414
+ return;
5415
+ const { retryTimer: _retryTimer, confirmTimer: _confirmTimer, claim: _claim, ...publicEntry } = entry;
5416
+ return { ...publicEntry };
5417
+ }
5418
+ enqueue(input) {
5419
+ if (this.entries.has(input.resumeId)) {
5420
+ this.log(`resume injection deduped: ${input.resumeId}`);
5421
+ try {
5422
+ input.claim?.release();
5423
+ } catch (error) {
5424
+ this.log(`resume claim release failed (${input.resumeId} dedup): ${error instanceof Error ? error.message : String(error)}`);
5425
+ }
5426
+ return;
5427
+ }
5428
+ if (input.claim) {
5429
+ const identity = input.claim.identity;
5430
+ for (const existing of this.entries.values()) {
5431
+ if (existing.claim && existing.claim.identity === identity) {
5432
+ this.log(`resume injection identity-deduped: ${input.resumeId} ~ existing ${existing.resumeId} (identity ${identity})`);
5433
+ existing.claim = input.claim;
5434
+ return;
5435
+ }
5436
+ }
5437
+ }
5438
+ this.entries.set(input.resumeId, {
5439
+ resumeId: input.resumeId,
5440
+ prompt: input.prompt ?? RESUME_PROMPT,
5441
+ state: "pending",
5442
+ attempts: 0,
5443
+ ...input.claim ? { claim: input.claim } : {}
5444
+ });
5445
+ this.tryInjectNext();
5446
+ }
5447
+ onTurnDrained() {
5448
+ this.tryInjectNext();
5449
+ }
5450
+ stop() {
5451
+ for (const entry of this.entries.values()) {
5452
+ const requestId = entry.injectionId;
5453
+ this.clearRetryTimer(entry);
5454
+ this.clearConfirmTimer(entry);
5455
+ if (requestId !== undefined) {
5456
+ this.onInjectionSuperseded({ resumeId: entry.resumeId, requestId, reason: "stop" });
5457
+ }
5458
+ try {
5459
+ entry.claim?.release();
5460
+ } catch (error) {
5461
+ this.log(`resume claim release failed (${entry.resumeId}): ${error instanceof Error ? error.message : String(error)}`);
5462
+ }
5463
+ }
5464
+ this.entries.clear();
5465
+ }
5466
+ onTurnTrackingReset() {
5467
+ this.resetSweepDepth++;
5468
+ try {
5469
+ for (const entry of [...this.entries.values()]) {
5470
+ if (entry.state === "awaiting_confirm") {
5471
+ this.supersedeAwaiting(entry, "turn_tracking_reset");
5472
+ this.countRealAttemptOrAbandon(entry, "turn tracking reset before turn/start confirmation");
5473
+ } else if (entry.state === "pending") {
5474
+ this.clearRetryTimer(entry);
5475
+ this.scheduleRetry(entry);
5476
+ }
5477
+ }
5478
+ } finally {
5479
+ this.resetSweepDepth--;
5480
+ }
5481
+ }
5482
+ onBridgeTurnStarted(event) {
5483
+ const entry = this.entries.get(event.resumeId);
5484
+ if (!entry || entry.state !== "awaiting_confirm" || entry.injectionId !== event.requestId)
5485
+ return;
5486
+ this.clearConfirmTimer(entry);
5487
+ try {
5488
+ entry.claim?.consume();
5489
+ } catch (error) {
5490
+ this.log(`resume claim consume failed (${event.resumeId}): ${error instanceof Error ? error.message : String(error)}`);
5491
+ }
5492
+ this.entries.delete(event.resumeId);
5493
+ this.onConfirmed({ resumeId: event.resumeId, requestId: event.requestId, turnId: event.turnId });
5494
+ }
5495
+ onBridgeTurnRejected(event) {
5496
+ const entry = this.entries.get(event.resumeId);
5497
+ if (!entry || entry.state !== "awaiting_confirm" || entry.injectionId !== event.requestId)
5498
+ return;
5499
+ this.supersedeAwaiting(entry, "bridge_rejected");
5500
+ this.countRealAttemptOrAbandon(entry, event.error);
5501
+ }
5502
+ tryInjectNext() {
5503
+ if (this.resetSweepDepth > 0)
5504
+ return;
5505
+ for (const entry of this.entries.values()) {
5506
+ if (entry.state === "awaiting_confirm")
5507
+ return;
5508
+ }
5509
+ for (const entry of this.entries.values()) {
5510
+ if (entry.state !== "pending")
5511
+ continue;
5512
+ this.clearRetryTimer(entry);
5513
+ let requestId;
5514
+ try {
5515
+ requestId = this.inject(entry.prompt);
5516
+ } catch (error) {
5517
+ this.countRealAttemptOrAbandon(entry, error instanceof Error ? error.message : String(error));
5518
+ return;
5519
+ }
5520
+ if (requestId === null) {
5521
+ this.scheduleRetry(entry);
5522
+ return;
5523
+ }
5524
+ entry.state = "awaiting_confirm";
5525
+ entry.injectionId = requestId;
5526
+ this.onInjectionAccepted({ resumeId: entry.resumeId, requestId });
5527
+ this.scheduleConfirmTimeout(entry);
5528
+ return;
5529
+ }
5530
+ }
5531
+ countRealAttemptOrAbandon(entry, reason) {
5532
+ entry.attempts += 1;
5533
+ if (entry.attempts >= this.maxAttempts) {
5534
+ this.abandon(entry, reason);
5535
+ return;
5536
+ }
5537
+ this.scheduleRetry(entry);
5538
+ }
5539
+ abandon(entry, reason) {
5540
+ this.clearRetryTimer(entry);
5541
+ this.clearConfirmTimer(entry);
5542
+ this.entries.delete(entry.resumeId);
5543
+ try {
5544
+ entry.claim?.release();
5545
+ } catch (error) {
5546
+ this.log(`resume claim release failed (${entry.resumeId}): ${error instanceof Error ? error.message : String(error)}`);
5547
+ }
5548
+ this.onAbandoned({ resumeId: entry.resumeId, reason });
5549
+ this.tryInjectNext();
5550
+ }
5551
+ supersedeAwaiting(entry, reason) {
5552
+ this.clearConfirmTimer(entry);
5553
+ const requestId = entry.injectionId;
5554
+ delete entry.injectionId;
5555
+ entry.state = "pending";
5556
+ if (requestId !== undefined) {
5557
+ this.onInjectionSuperseded({ resumeId: entry.resumeId, requestId, reason });
5558
+ }
5559
+ }
5560
+ scheduleRetry(entry) {
5561
+ if (!this.entries.has(entry.resumeId))
5562
+ return;
5563
+ this.clearRetryTimer(entry);
5564
+ entry.retryTimer = this.scheduler.setTimeout(() => {
5565
+ delete entry.retryTimer;
5566
+ this.tryInjectNext();
5567
+ }, this.retryMs);
5568
+ entry.retryTimer?.unref?.();
5569
+ }
5570
+ scheduleConfirmTimeout(entry) {
5571
+ this.clearConfirmTimer(entry);
5572
+ entry.confirmTimer = this.scheduler.setTimeout(() => {
5573
+ delete entry.confirmTimer;
5574
+ if (entry.state !== "awaiting_confirm")
5575
+ return;
5576
+ this.supersedeAwaiting(entry, "confirm_timeout");
5577
+ this.countRealAttemptOrAbandon(entry, "turn/start confirmation timed out");
5578
+ }, this.confirmTimeoutMs);
5579
+ entry.confirmTimer?.unref?.();
5580
+ }
5581
+ clearRetryTimer(entry) {
5582
+ if (entry.retryTimer === undefined)
5583
+ return;
5584
+ this.scheduler.clearTimeout(entry.retryTimer);
5585
+ delete entry.retryTimer;
5586
+ }
5587
+ clearConfirmTimer(entry) {
5588
+ if (entry.confirmTimer === undefined)
5589
+ return;
5590
+ this.scheduler.clearTimeout(entry.confirmTimer);
5591
+ delete entry.confirmTimer;
5592
+ }
5593
+ }
5594
+ function realpathOrRaw(path) {
5595
+ try {
5596
+ return realpathSync(path);
5597
+ } catch {
5598
+ return path;
5599
+ }
5600
+ }
5601
+ function sha2562(value) {
5602
+ return createHash2("sha256").update(value).digest("hex");
5603
+ }
5604
+ function writeJsonWx(path, value) {
5605
+ let fd;
5606
+ try {
5607
+ fd = openSync3(path, "wx", 384);
5608
+ } catch (error) {
5609
+ if (error?.code === "EEXIST")
5610
+ return false;
5611
+ throw error;
5612
+ }
5613
+ try {
5614
+ writeFileSync3(fd, JSON.stringify(value, null, 2));
5615
+ } finally {
5616
+ closeSync3(fd);
5617
+ }
5618
+ return true;
5619
+ }
5620
+ function unlinkIfExists(path) {
5621
+ try {
5622
+ unlinkSync4(path);
5623
+ } catch (error) {
5624
+ if (error?.code === "ENOENT")
5625
+ return;
5626
+ throw error;
5627
+ }
5628
+ }
5629
+ function readClaimedAt(path) {
5630
+ try {
5631
+ const parsed = JSON.parse(readFileSync6(path, "utf-8"));
5632
+ const claimedAt = parsed?.claimed_at;
5633
+ return typeof claimedAt === "number" && Number.isFinite(claimedAt) ? claimedAt : null;
5634
+ } catch {
5635
+ return null;
5636
+ }
5637
+ }
5638
+ function pruneStaleResumeArtifacts(dir, tsField, ttlSec, nowSec, log) {
5639
+ let names;
5640
+ try {
5641
+ names = readdirSync(dir);
5642
+ } catch {
5643
+ return;
5644
+ }
5645
+ for (const name of names) {
5646
+ if (!name.endsWith(".json"))
5647
+ continue;
5648
+ const p = join8(dir, name);
5649
+ try {
5650
+ const parsed = JSON.parse(readFileSync6(p, "utf-8"));
5651
+ const ts = parsed?.[tsField];
5652
+ if (typeof ts === "number" && Number.isFinite(ts) && nowSec - ts > ttlSec) {
5653
+ unlinkIfExists(p);
5654
+ }
5655
+ } catch (error) {
5656
+ log?.(`resume artifact prune skipped ${p}: ${error instanceof Error ? error.message : String(error)}`);
5657
+ }
5658
+ }
5659
+ }
5660
+ function tryClaimPendingResume(opts) {
5661
+ const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
5662
+ const claimTtlSec = finitePositive(opts.claimTtlSec, DEFAULT_STALE_CLAIM_TTL_SEC);
5663
+ const consumedTtlSec = finitePositive(opts.consumedTtlSec, DEFAULT_CONSUMED_TTL_SEC);
5664
+ const cwd = realpathOrRaw(opts.pending.cwd);
5665
+ const sourcePath = opts.pending.sourcePath ?? "";
5666
+ const contentHash = opts.pending.contentHash ?? "";
5667
+ const identity = sha2562([
5668
+ opts.agent,
5669
+ opts.pending.sessionId,
5670
+ cwd,
5671
+ contentHash
5672
+ ].join("\x00"));
5673
+ const claimsDir = join8(opts.stateDir, "claims");
5674
+ const consumedDir = join8(opts.stateDir, "consumed");
5675
+ const claimPath = join8(claimsDir, `${identity}.json`);
5676
+ const consumedPath = join8(consumedDir, `${identity}.json`);
5677
+ mkdirSync5(claimsDir, { recursive: true });
5678
+ mkdirSync5(consumedDir, { recursive: true });
5679
+ const nowSec = now();
5680
+ pruneStaleResumeArtifacts(consumedDir, "consumed_at", consumedTtlSec, nowSec, opts.log);
5681
+ pruneStaleResumeArtifacts(claimsDir, "claimed_at", claimTtlSec, nowSec, opts.log);
5682
+ if (existsSync6(consumedPath))
5683
+ return { ok: false, reason: "consumed" };
5684
+ if (existsSync6(claimPath)) {
5685
+ const claimedAt = readClaimedAt(claimPath);
5686
+ if (claimedAt !== null && nowSec - claimedAt > claimTtlSec) {
5687
+ try {
5688
+ unlinkIfExists(claimPath);
5689
+ } catch (error) {
5690
+ const message = error instanceof Error ? error.message : String(error);
5691
+ opts.log?.(`stale resume claim cleanup failed: ${message}`);
5692
+ return { ok: false, reason: "error", error: message };
5693
+ }
5694
+ } else {
5695
+ return { ok: false, reason: "claimed" };
5696
+ }
5697
+ }
5698
+ const payload = {
5699
+ identity,
5700
+ agent: opts.agent,
5701
+ session_id: opts.pending.sessionId,
5702
+ cwd,
5703
+ pending_path: sourcePath,
5704
+ pending_hash: contentHash,
5705
+ checkpoint_path: opts.checkpointPath,
5706
+ claimed_at: nowSec
5707
+ };
5708
+ try {
5709
+ if (!writeJsonWx(claimPath, payload))
5710
+ return { ok: false, reason: "claimed" };
5711
+ } catch (error) {
5712
+ const message = error instanceof Error ? error.message : String(error);
5713
+ opts.log?.(`resume claim failed: ${message}`);
5714
+ return { ok: false, reason: "error", error: message };
5715
+ }
5716
+ return {
5717
+ ok: true,
5718
+ claim: {
5719
+ identity,
5720
+ claimPath,
5721
+ consumedPath,
5722
+ consume: () => {
5723
+ mkdirSync5(consumedDir, { recursive: true });
5724
+ writeFileSync3(consumedPath, JSON.stringify({ ...payload, consumed_at: now() }, null, 2));
5725
+ unlinkIfExists(claimPath);
5726
+ },
5727
+ release: () => {
5728
+ unlinkIfExists(claimPath);
5729
+ }
5730
+ }
5731
+ };
5732
+ }
5733
+
5734
+ // src/budget/resume-ack-tracker.ts
5735
+ function finitePositive2(value, fallback) {
5736
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
5737
+ }
5738
+
5739
+ class ResumeAckTracker {
5740
+ push;
5741
+ scheduler;
5742
+ timeoutMs;
5743
+ retries;
5744
+ onDegraded;
5745
+ entries = new Map;
5746
+ deliverySeq = 0;
5747
+ constructor(options) {
5748
+ this.push = options.push;
5749
+ this.scheduler = options.scheduler;
5750
+ this.timeoutMs = finitePositive2(options.timeoutMs, 60000);
5751
+ this.retries = finitePositive2(options.retries, 3);
5752
+ this.onDegraded = options.onDegraded ?? (() => {});
5753
+ }
5754
+ get size() {
5755
+ return this.entries.size;
5756
+ }
5757
+ get(resumeId) {
5758
+ const entry = this.entries.get(resumeId);
5759
+ if (!entry)
5760
+ return;
5761
+ const { timer: _timer, ...publicEntry } = entry;
5762
+ return { ...publicEntry };
5763
+ }
5764
+ start(resumeId) {
5765
+ if (this.entries.has(resumeId))
5766
+ return;
5767
+ const entry = { resumeId, attempts: 0, state: "awaiting_ack" };
5768
+ this.entries.set(resumeId, entry);
5769
+ this.pushAttempt(entry);
5770
+ this.armTimer(entry);
5771
+ }
5772
+ ack(resumeId) {
5773
+ const entry = this.entries.get(resumeId);
5774
+ if (!entry)
5775
+ return;
5776
+ this.clearTimer(entry);
5777
+ entry.state = "resumed";
5778
+ this.entries.delete(resumeId);
5779
+ }
5780
+ stop() {
5781
+ for (const entry of this.entries.values()) {
5782
+ this.clearTimer(entry);
5783
+ }
5784
+ this.entries.clear();
5785
+ }
5786
+ pushAttempt(entry) {
5787
+ const deliveryId = `${entry.resumeId}_retry${entry.attempts}_${++this.deliverySeq}`;
5788
+ this.push({ resumeId: entry.resumeId, deliveryId, attempt: entry.attempts });
5789
+ }
5790
+ armTimer(entry) {
5791
+ this.clearTimer(entry);
5792
+ entry.timer = this.scheduler.setTimeout(() => {
5793
+ delete entry.timer;
5794
+ this.onTimeout(entry);
5795
+ }, this.timeoutMs);
5796
+ entry.timer?.unref?.();
5797
+ }
5798
+ onTimeout(entry) {
5799
+ if (entry.state !== "awaiting_ack" || !this.entries.has(entry.resumeId))
5800
+ return;
5801
+ entry.attempts += 1;
5802
+ if (entry.attempts >= this.retries) {
5803
+ entry.state = "degraded";
5804
+ this.entries.delete(entry.resumeId);
5805
+ this.onDegraded(entry.resumeId);
5806
+ return;
5807
+ }
5808
+ this.pushAttempt(entry);
5809
+ this.armTimer(entry);
5810
+ }
5811
+ clearTimer(entry) {
5812
+ if (entry.timer === undefined)
5813
+ return;
5814
+ this.scheduler.clearTimeout(entry.timer);
5815
+ delete entry.timer;
5816
+ }
5817
+ }
5818
+
5819
+ // src/budget/route-resume.ts
5820
+ function routeResume(side, resumeId, deps) {
5821
+ if (side === "codex") {
5822
+ deps.enqueueCodex(resumeId);
5823
+ return;
5824
+ }
5825
+ deps.claudeTracker.start(resumeId);
5826
+ }
5827
+
5828
+ // src/budget/resume-ack-sentinel.ts
5829
+ import { renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
5830
+ import { join as join9 } from "path";
5831
+ var RESUME_ACK_DEGRADED_SENTINEL = "resume-ack-degraded.json";
5832
+ function resumeAckSentinelPath(stateDir) {
5833
+ return join9(stateDir, RESUME_ACK_DEGRADED_SENTINEL);
5834
+ }
5835
+ function writeResumeAckDegradedSentinel(opts) {
5836
+ const now = opts.now ?? (() => Date.now());
5837
+ const payload = {
5838
+ resumeId: opts.resumeId,
5839
+ degradedAt: now()
5840
+ };
5841
+ const target = resumeAckSentinelPath(opts.stateDir);
5842
+ const tmp = `${target}.${process.pid}.tmp`;
5843
+ try {
5844
+ writeFileSync4(tmp, JSON.stringify(payload, null, 2), { mode: 384 });
5845
+ renameSync3(tmp, target);
5846
+ opts.log?.(`Resume-ack degraded sentinel written: ${opts.resumeId}`);
5847
+ } catch (err) {
5848
+ opts.log?.(`Resume-ack degraded sentinel write failed (${opts.resumeId}): ${err?.message ?? err}`);
5849
+ }
5850
+ }
5851
+
4810
5852
  // src/daemon-identity-ownership.ts
4811
- import { readFileSync as readFileSync5 } from "fs";
4812
- var defaultRead2 = (path) => readFileSync5(path, "utf-8");
5853
+ import { readFileSync as readFileSync7 } from "fs";
5854
+ var defaultRead2 = (path) => readFileSync7(path, "utf-8");
4813
5855
  function pidFileOwnedByUs(pidFilePath, ourPid, read = defaultRead2) {
4814
5856
  let raw;
4815
5857
  try {
@@ -4977,12 +6019,12 @@ class ReplyRequiredTracker {
4977
6019
 
4978
6020
  // src/thread-state.ts
4979
6021
  import {
4980
- existsSync as existsSync6,
4981
- readdirSync,
4982
- readFileSync as readFileSync6
6022
+ existsSync as existsSync7,
6023
+ readdirSync as readdirSync2,
6024
+ readFileSync as readFileSync8
4983
6025
  } from "fs";
4984
- import { homedir as homedir3 } from "os";
4985
- import { basename as basename2, join as join6 } from "path";
6026
+ import { homedir as homedir4 } from "os";
6027
+ import { basename as basename2, join as join10 } from "path";
4986
6028
  function nowIso() {
4987
6029
  return new Date().toISOString();
4988
6030
  }
@@ -4991,11 +6033,11 @@ function threadTag(identity) {
4991
6033
  return `abg:${name}:${identity.cwd}`;
4992
6034
  }
4993
6035
  function codexHome(env = process.env) {
4994
- return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join6(homedir3(), ".codex");
6036
+ return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join10(homedir4(), ".codex");
4995
6037
  }
4996
6038
  function readRawCurrentThread(stateDir) {
4997
6039
  try {
4998
- const parsed = JSON.parse(readFileSync6(stateDir.currentThreadFile, "utf-8"));
6040
+ const parsed = JSON.parse(readFileSync8(stateDir.currentThreadFile, "utf-8"));
4999
6041
  if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") {
5000
6042
  return parsed;
5001
6043
  }
@@ -5003,8 +6045,8 @@ function readRawCurrentThread(stateDir) {
5003
6045
  return null;
5004
6046
  }
5005
6047
  function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
5006
- const sessionsDir = join6(codexHome(env), "sessions");
5007
- if (!threadId || !existsSync6(sessionsDir))
6048
+ const sessionsDir = join10(codexHome(env), "sessions");
6049
+ if (!threadId || !existsSync7(sessionsDir))
5008
6050
  return null;
5009
6051
  const exactName = `rollout-${threadId}.jsonl`;
5010
6052
  const stack = [sessionsDir];
@@ -5013,13 +6055,13 @@ function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
5013
6055
  const dir = stack.pop();
5014
6056
  let entries;
5015
6057
  try {
5016
- entries = readdirSync(dir, { withFileTypes: true });
6058
+ entries = readdirSync2(dir, { withFileTypes: true });
5017
6059
  } catch {
5018
6060
  continue;
5019
6061
  }
5020
6062
  for (const entry of entries) {
5021
6063
  visited++;
5022
- const path = join6(dir, entry.name);
6064
+ const path = join10(dir, entry.name);
5023
6065
  if (entry.isDirectory()) {
5024
6066
  stack.push(path);
5025
6067
  continue;
@@ -5208,6 +6250,11 @@ var BOOTSTRAP_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_BOOTSTRAP_TIMEOUT_MS
5208
6250
  var CODEX_BOOT_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_CODEX_BOOT_RETRIES", 2);
5209
6251
  var ALLOW_IDENTITYLESS_CLIENT = process.env.AGENTBRIDGE_COMPAT_IDENTITYLESS === "1";
5210
6252
  var BUDGET_CONFIG = applyBudgetEnvOverrides(config.budget);
6253
+ var RESUME_INJECT_RETRY_MS = parsePositiveIntEnv("AGENTBRIDGE_RESUME_INJECT_RETRY_MS", 5000, log);
6254
+ var RESUME_CONFIRM_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_RESUME_CONFIRM_TIMEOUT_MS", 60000, log);
6255
+ var RESUME_INJECT_MAX_ATTEMPTS = parsePositiveIntEnv("AGENTBRIDGE_RESUME_INJECT_MAX_ATTEMPTS", 5, log);
6256
+ var RESUME_ACK_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_RESUME_ACK_TIMEOUT_MS", 60000, log);
6257
+ var RESUME_ACK_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_RESUME_ACK_RETRIES", 3, log);
5211
6258
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
5212
6259
  var DAEMON_NONCE = randomUUID4();
5213
6260
  var DAEMON_STARTED_AT = Date.now();
@@ -5225,6 +6272,52 @@ var inAttentionWindow = false;
5225
6272
  var replyTracker = new ReplyRequiredTracker;
5226
6273
  var idempotencyTracker = new IdempotencyTracker;
5227
6274
  var pendingTurnStarts = new Map;
6275
+ var pendingResumeTurnStarts = new Map;
6276
+ var resumeInjectionQueue = new ResumeInjectionQueue({
6277
+ inject: (prompt) => codex.injectMessage(prompt),
6278
+ retryMs: RESUME_INJECT_RETRY_MS,
6279
+ confirmTimeoutMs: RESUME_CONFIRM_TIMEOUT_MS,
6280
+ maxAttempts: RESUME_INJECT_MAX_ATTEMPTS,
6281
+ log,
6282
+ onInjectionAccepted: ({ resumeId, requestId }) => {
6283
+ pendingResumeTurnStarts.set(requestId, { resumeId });
6284
+ log(`Budget resume injection accepted: ${resumeId} \u2192 request ${requestId}`);
6285
+ },
6286
+ onInjectionSuperseded: ({ resumeId, requestId, reason }) => {
6287
+ pendingResumeTurnStarts.delete(requestId);
6288
+ log(`Budget resume injection superseded: ${resumeId} request ${requestId} (${reason})`);
6289
+ },
6290
+ onConfirmed: ({ resumeId, requestId, turnId }) => {
6291
+ log(`Budget resume injection confirmed: ${resumeId} request ${requestId} \u2192 turn ${turnId}`);
6292
+ },
6293
+ onAbandoned: ({ resumeId, reason }) => {
6294
+ log(`Budget resume injection abandoned: ${resumeId}: ${reason}`);
6295
+ }
6296
+ });
6297
+ var claudeResumeTracker = new ResumeAckTracker({
6298
+ push: ({ resumeId, deliveryId, attempt }) => {
6299
+ const message = {
6300
+ id: `system_budget_resume_${SYSTEM_MSG_SALT}_${deliveryId}`,
6301
+ source: "codex",
6302
+ content: claudeResumePrompt(resumeId),
6303
+ timestamp: Date.now(),
6304
+ resumeId
6305
+ };
6306
+ log(`Budget resume push to Claude: ${resumeId} (attempt ${attempt}, delivery ${deliveryId})`);
6307
+ emitToClaude(message);
6308
+ },
6309
+ scheduler: globalThis,
6310
+ timeoutMs: RESUME_ACK_TIMEOUT_MS,
6311
+ retries: RESUME_ACK_RETRIES,
6312
+ onDegraded: (resumeId) => {
6313
+ log(`Budget resume ${resumeId} degraded: no ack from Claude after ${RESUME_ACK_RETRIES} attempts`);
6314
+ try {
6315
+ writeResumeAckDegradedSentinel({ stateDir: stateDir.dir, resumeId, log });
6316
+ } catch (err) {
6317
+ log(`Resume degraded sentinel write failed (${resumeId}): ${err?.message ?? err}`);
6318
+ }
6319
+ }
6320
+ });
5228
6321
  var pendingSteerDispatches = new Map;
5229
6322
  var BUSY_RETRY_ADVISORY_MS = 15000;
5230
6323
  var shuttingDown = false;
@@ -5250,11 +6343,105 @@ function createPendingBackpressureBuffer() {
5250
6343
  });
5251
6344
  }
5252
6345
  var budgetCoordinator = null;
6346
+ function pairCwd() {
6347
+ const raw = process.cwd();
6348
+ try {
6349
+ return realpathSync2(raw);
6350
+ } catch {
6351
+ return raw;
6352
+ }
6353
+ }
6354
+ function budgetGuardStateDir() {
6355
+ const override = process.env.BUDGET_STATE_DIR;
6356
+ if (override && override.trim() !== "")
6357
+ return override.trim();
6358
+ return join11(homedir5(), ".budget-guard");
6359
+ }
6360
+ function resumeClaimTtlSec() {
6361
+ const totalMs = RESUME_CONFIRM_TIMEOUT_MS * RESUME_INJECT_MAX_ATTEMPTS + RESUME_INJECT_RETRY_MS * Math.max(0, RESUME_INJECT_MAX_ATTEMPTS - 1);
6362
+ return Math.max(1, Math.ceil(totalMs / 1000));
6363
+ }
6364
+ function readResumeSignals() {
6365
+ let tuiReadyCodex = false;
6366
+ let tuiReadyClaude = false;
6367
+ try {
6368
+ tuiReadyCodex = tuiConnectionState.canReply();
6369
+ } catch (error) {
6370
+ log(`resume signal: codex tuiReady failed: ${error instanceof Error ? error.message : String(error)}`);
6371
+ }
6372
+ try {
6373
+ tuiReadyClaude = attachedClaude !== null;
6374
+ } catch (error) {
6375
+ log(`resume signal: claude tuiReady failed: ${error instanceof Error ? error.message : String(error)}`);
6376
+ }
6377
+ let pendingCodex = false;
6378
+ let pendingClaude = false;
6379
+ let pendingCodexEntry;
6380
+ let pendingClaudeEntry;
6381
+ try {
6382
+ const home = homedir5();
6383
+ const cwd = pairCwd();
6384
+ pendingCodexEntry = readGuardPending({ homeDir: home, agent: "codex", cwd, log })[0];
6385
+ pendingClaudeEntry = readGuardPending({ homeDir: home, agent: "claude", cwd, log })[0];
6386
+ pendingCodex = pendingCodexEntry !== undefined;
6387
+ pendingClaude = pendingClaudeEntry !== undefined;
6388
+ } catch (error) {
6389
+ log(`resume signal: pending read failed: ${error instanceof Error ? error.message : String(error)}`);
6390
+ }
6391
+ let checkpointExists = false;
6392
+ let checkpointPath;
6393
+ try {
6394
+ checkpointPath = join11(pairCwd(), ".agent", "checkpoint.md");
6395
+ checkpointExists = existsSync8(checkpointPath);
6396
+ } catch (error) {
6397
+ log(`resume signal: checkpoint stat failed: ${error instanceof Error ? error.message : String(error)}`);
6398
+ checkpointPath = undefined;
6399
+ }
6400
+ return {
6401
+ tuiReady: { codex: tuiReadyCodex, claude: tuiReadyClaude },
6402
+ pendingExists: { codex: pendingCodex, claude: pendingClaude },
6403
+ pending: {
6404
+ ...pendingCodexEntry ? { codex: pendingCodexEntry } : {},
6405
+ ...pendingClaudeEntry ? { claude: pendingClaudeEntry } : {}
6406
+ },
6407
+ checkpointExists,
6408
+ ...checkpointPath ? { checkpointPath } : {}
6409
+ };
6410
+ }
6411
+ function enqueueCodexBudgetResume(resumeId) {
6412
+ const candidate = budgetCoordinator?.getResumeCandidate();
6413
+ const detail = candidate?.detail?.codex;
6414
+ if (candidate?.codex !== true || detail?.ready !== true) {
6415
+ log(`Budget resume ${resumeId} ignored: Codex resume candidate is not ready`);
6416
+ return;
6417
+ }
6418
+ if (!detail.pending) {
6419
+ log(`Budget resume ${resumeId} ignored: missing Codex guard pending entry`);
6420
+ return;
6421
+ }
6422
+ if (!detail.checkpointPath) {
6423
+ log(`Budget resume ${resumeId} ignored: missing checkpoint path`);
6424
+ return;
6425
+ }
6426
+ const claim = tryClaimPendingResume({
6427
+ stateDir: budgetGuardStateDir(),
6428
+ agent: "codex",
6429
+ pending: detail.pending,
6430
+ checkpointPath: detail.checkpointPath,
6431
+ claimTtlSec: resumeClaimTtlSec(),
6432
+ log
6433
+ });
6434
+ if (!claim.ok) {
6435
+ log(`Budget resume ${resumeId} not enqueued: pending claim ${claim.reason}${claim.error ? ` (${claim.error})` : ""}`);
6436
+ return;
6437
+ }
6438
+ resumeInjectionQueue.enqueue({ resumeId, prompt: RESUME_PROMPT, claim: claim.claim });
6439
+ }
5253
6440
  function ensureBudgetCoordinatorStarted() {
5254
6441
  if (!BUDGET_CONFIG.enabled)
5255
6442
  return;
5256
6443
  if (!budgetCoordinator) {
5257
- log(`Budget coordinator config: pollSeconds=${BUDGET_CONFIG.pollSeconds} pauseAt=${BUDGET_CONFIG.pauseAt} ` + `resumeBelow=${BUDGET_CONFIG.resumeBelow} syncDriftPct=${BUDGET_CONFIG.syncDriftPct} ` + `parallel=${BUDGET_CONFIG.parallel.minRemainingPct}%/${BUDGET_CONFIG.parallel.timeWindowSec}s ` + `codexTierControl=${BUDGET_CONFIG.codexTierControl} ` + `codexTiersFull=${BUDGET_CONFIG.codexTiers.full ? "configured" : "missing"} ` + `strategy=${BUDGET_CONFIG.strategy}`);
6444
+ log(`Budget coordinator config: pollSeconds=${BUDGET_CONFIG.pollSeconds} pauseAt=${BUDGET_CONFIG.pauseAt} ` + `resumeBelow=${BUDGET_CONFIG.resumeBelow} syncDriftPct=${BUDGET_CONFIG.syncDriftPct} ` + `parallel=${BUDGET_CONFIG.parallel.minRemainingPct}%/${BUDGET_CONFIG.parallel.timeWindowSec}s ` + `codexTierControl=${BUDGET_CONFIG.codexTierControl} ` + `codexTiersFull=${BUDGET_CONFIG.codexTiers.full ? "configured" : "missing"} ` + `targetUtil=${BUDGET_CONFIG.maximize.targetUtil} fallback=${BUDGET_CONFIG.pauseAt}/${BUDGET_CONFIG.resumeBelow}`);
5258
6445
  budgetCoordinator = new BudgetCoordinator({
5259
6446
  source: createQuotaSource({ log }),
5260
6447
  config: BUDGET_CONFIG,
@@ -5265,7 +6452,17 @@ function ensureBudgetCoordinatorStarted() {
5265
6452
  log(`Budget intervention ${paused ? "ACTIVE" : "CLEARED"} ` + `(gate ${budgetCoordinator?.isGateClosed() ? "CLOSED" : "OPEN"})`);
5266
6453
  },
5267
6454
  onSnapshot: () => broadcastStatus(),
5268
- log
6455
+ log,
6456
+ onResume: (side, _directive, resumeId) => {
6457
+ if (side === "claude") {
6458
+ log(`Budget resume ${resumeId} for Claude side \u2192 arming ack tracker`);
6459
+ }
6460
+ routeResume(side, resumeId, {
6461
+ claudeTracker: claudeResumeTracker,
6462
+ enqueueCodex: enqueueCodexBudgetResume
6463
+ });
6464
+ },
6465
+ resumeSignals: readResumeSignals
5269
6466
  });
5270
6467
  }
5271
6468
  budgetCoordinator.start();
@@ -5278,7 +6475,8 @@ function budgetPauseGateError() {
5278
6475
  const reason = snapshot?.pauseReason ?? "Codex \u4FA7\u989D\u5EA6\u63A5\u8FD1\u8017\u5C3D";
5279
6476
  const resumeAt = snapshot?.resumeAfterEpoch ? new Date(snapshot.resumeAfterEpoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z") : null;
5280
6477
  const sideHint = snapshot?.pauseSide === "both" ? "\u53CC\u4FA7\u989D\u5EA6\u5747\u5DF2\u8017\u5C3D\uFF0C\u8BF7\u5199 checkpoint \u7B49\u5F85\u5237\u65B0" : "\u4F60\u53EF\u7EE7\u7EED solo \u63A8\u8FDB\u53EF\u72EC\u7ACB\u90E8\u5206\uFF0C\u5E76\u5199 checkpoint \u6807\u6CE8\u5206\u5DE5\u65AD\u70B9";
5281
- return `\u9884\u7B97\u6682\u505C\uFF08\u95F8\u95E8\u5173\u95ED\uFF09\uFF0C\u5DF2\u62D2\u7EDD\u8F6C\u53D1\uFF1A${reason}\u3002` + `Codex \u4FA7 gateUtil \u4F4E\u4E8E ${BUDGET_CONFIG.resumeBelow}% \u540E\u95F8\u95E8\u81EA\u52A8\u653E\u5F00` + (resumeAt ? `\uFF08\u9884\u8BA1\u6062\u590D ${resumeAt}\uFF0C\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09` : "") + `\u3002\u6536\u5230 RESUME \u901A\u77E5\u524D\u8BF7\u52FF\u91CD\u8BD5\u5411 Codex \u53D1\u9001 reply\uFF1B${sideHint}\u3002`;
6478
+ const reopenText = `Codex \u4FA7\u5404\u7A97\u53E3 util \u56DE\u843D\u81F3\u52A8\u6001\u6682\u505C\u7EBF \u2212 ${BUDGET_CONFIG.maximize.resumeHysteresisPct}% \u4EE5\u4E0B\u6216\u5BF9\u5E94\u7A97\u53E3\u5237\u65B0\u540E\u95F8\u95E8\u81EA\u52A8\u653E\u5F00`;
6479
+ return `\u9884\u7B97\u6682\u505C\uFF08\u95F8\u95E8\u5173\u95ED\uFF09\uFF0C\u5DF2\u62D2\u7EDD\u8F6C\u53D1\uFF1A${reason}\u3002` + reopenText + (resumeAt ? `\uFF08\u9884\u8BA1\u6062\u590D ${resumeAt}\uFF0C\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09` : "") + `\u3002\u6536\u5230 RESUME \u901A\u77E5\u524D\u8BF7\u52FF\u91CD\u8BD5\u5411 Codex \u53D1\u9001 reply\uFF1B${sideHint}\u3002`;
5282
6480
  }
5283
6481
  var tuiConnectionState = new TuiConnectionState({
5284
6482
  disconnectGraceMs: TUI_DISCONNECT_GRACE_MS,
@@ -5324,6 +6522,12 @@ codex.on("steerAccepted", ({ requestId }) => {
5324
6522
  }
5325
6523
  });
5326
6524
  codex.on("bridgeTurnStarted", ({ requestId, turnId }) => {
6525
+ const pendingResume = pendingResumeTurnStarts.get(requestId);
6526
+ if (pendingResume) {
6527
+ pendingResumeTurnStarts.delete(requestId);
6528
+ resumeInjectionQueue.onBridgeTurnStarted({ resumeId: pendingResume.resumeId, requestId, turnId });
6529
+ return;
6530
+ }
5327
6531
  const pending = pendingTurnStarts.get(requestId);
5328
6532
  if (!pending) {
5329
6533
  log(`bridgeTurnStarted for unknown injection ${requestId} (turn ${turnId}) \u2014 correlation dropped`);
@@ -5345,6 +6549,12 @@ codex.on("bridgeTurnStarted", ({ requestId, turnId }) => {
5345
6549
  }
5346
6550
  });
5347
6551
  codex.on("bridgeTurnRejected", ({ requestId, error }) => {
6552
+ const pendingResume = pendingResumeTurnStarts.get(requestId);
6553
+ if (pendingResume) {
6554
+ pendingResumeTurnStarts.delete(requestId);
6555
+ resumeInjectionQueue.onBridgeTurnRejected({ resumeId: pendingResume.resumeId, requestId, error });
6556
+ return;
6557
+ }
5348
6558
  const pending = pendingTurnStarts.get(requestId);
5349
6559
  if (!pending)
5350
6560
  return;
@@ -5362,11 +6572,16 @@ codex.on("turnTrackingReset", (reason) => {
5362
6572
  if (pendingTurnStarts.size > 0) {
5363
6573
  log(`Cleared ${pendingTurnStarts.size} pending turn-start correlation(s) on turn tracking reset (${reason})`);
5364
6574
  }
6575
+ if (pendingResumeTurnStarts.size > 0) {
6576
+ log(`Cleared ${pendingResumeTurnStarts.size} pending resume turn-start correlation(s) on turn tracking reset (${reason})`);
6577
+ }
5365
6578
  if (pendingSteerDispatches.size > 0) {
5366
6579
  log(`Cleared ${pendingSteerDispatches.size} pending steer dispatch(es) on turn tracking reset (${reason})`);
5367
6580
  }
5368
6581
  pendingTurnStarts.clear();
6582
+ pendingResumeTurnStarts.clear();
5369
6583
  pendingSteerDispatches.clear();
6584
+ resumeInjectionQueue.onTurnTrackingReset();
5370
6585
  });
5371
6586
  codex.on("turnStarted", () => {
5372
6587
  log("Codex turn started");
@@ -5411,6 +6626,7 @@ codex.on("turnCompleted", () => {
5411
6626
  }
5412
6627
  emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
5413
6628
  startAttentionWindow();
6629
+ resumeInjectionQueue.onTurnDrained();
5414
6630
  });
5415
6631
  codex.on("turnAborted", (reason) => {
5416
6632
  log(`Codex turn aborted (${reason}) \u2014 clearing reply-required state`);
@@ -5470,7 +6686,9 @@ codex.on("exit", (code) => {
5470
6686
  replyTracker.reset();
5471
6687
  idempotencyTracker.terminateAll("aborted");
5472
6688
  pendingTurnStarts.clear();
6689
+ pendingResumeTurnStarts.clear();
5473
6690
  pendingSteerDispatches.clear();
6691
+ resumeInjectionQueue.onTurnTrackingReset();
5474
6692
  statusBuffer.flush("codex exited");
5475
6693
  tuiConnectionState.handleCodexExit();
5476
6694
  clearPendingClaudeDisconnect("Codex process exited");
@@ -5580,6 +6798,10 @@ function handleControlMessage(ws, raw) {
5580
6798
  case "status":
5581
6799
  sendStatus(ws);
5582
6800
  return;
6801
+ case "ack_resume":
6802
+ log(`Received ack_resume from Claude #${ws.data.clientId}: ${message.resumeId} (${message.status})`);
6803
+ claudeResumeTracker.ack(message.resumeId);
6804
+ return;
5583
6805
  case "probe_incumbent":
5584
6806
  handleProbeIncumbent(ws).catch((err) => {
5585
6807
  log(`handleProbeIncumbent threw for #${ws.data.clientId}: ${err?.message ?? err}`);
@@ -5685,7 +6907,7 @@ async function handleClaudeToCodex(ws, message) {
5685
6907
  const reason = budgetPauseGateError();
5686
6908
  log(`Injection rejected by budget pause gate`);
5687
6909
  const resumeAfterEpoch3 = budgetCoordinator?.getSnapshot()?.resumeAfterEpoch ?? null;
5688
- const retryAfterMs = resumeAfterEpoch3 !== null ? Math.max(0, resumeAfterEpoch3 * 1000 - Date.now()) : undefined;
6910
+ const retryAfterMs = retryAfterMsForResume(resumeAfterEpoch3, Date.now());
5689
6911
  sendClaudeToCodexResult(ws, message.requestId, {
5690
6912
  success: false,
5691
6913
  code: "budget_paused",
@@ -6233,6 +7455,8 @@ function shutdown(reason, exitCode = 0) {
6233
7455
  shuttingDown = true;
6234
7456
  log(`Shutting down daemon (${reason})...`);
6235
7457
  clearBootDeadline();
7458
+ resumeInjectionQueue.stop();
7459
+ claudeResumeTracker.stop();
6236
7460
  stopBudgetCoordinator();
6237
7461
  idempotencyTracker.dispose();
6238
7462
  tuiConnectionState.dispose(`daemon shutdown (${reason})`);