@tecorb/agent-pool 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to `@tecorb/agent-pool`. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.1.1 - 2026-10-15
6
+
7
+ ### Fixed
8
+
9
+ - **Automatic switching** no longer stops with "no other subscription can take over" when the other accounts are only Unknown. Claude Code reports usage only for the account it runs on, so an idle account is Unknown after `availability.maxAgeMinutes`. Such accounts are now tried after the Available ones, once their login check passes. An account whose last reported window is still at 100%, or that was left for a limit and has not reset since, stays out until its reset.
10
+ - When the chosen account's login check fails, the next account is tried, including Limited ones.
11
+ - After a full stop, Agent Pool checks again every 5 minutes and at the earliest known reset, and switches back to the first account that can serve again.
12
+ - A high-confidence authentication error now moves the work to the next account.
13
+
14
+ ### Added
15
+
16
+ - Events `autoswitch.candidates` (every account with its decision and why), `autoswitch.precheck` (which account would take over while the current one is Limited) and `autoswitch.retry`.
17
+
5
18
  ## 0.1.0 - 2026-10-15
6
19
 
7
20
  The first release.
@@ -7419,7 +7419,11 @@ function readToken(dp) {
7419
7419
  }
7420
7420
 
7421
7421
  // ../daemon/src/autoswitch/policy.ts
7422
- var TRIGGERS = ["LIMIT_REACHED", "SUBSCRIPTION_UNAVAILABLE"];
7422
+ var TRIGGERS = [
7423
+ "LIMIT_REACHED",
7424
+ "SUBSCRIPTION_UNAVAILABLE",
7425
+ "AUTHENTICATION_ERROR"
7426
+ ];
7423
7427
  var REASONS = {
7424
7428
  LIMIT_REACHED: "limit reached",
7425
7429
  SUBSCRIPTION_UNAVAILABLE: "subscription unavailable",
@@ -7447,8 +7451,11 @@ function isFreshProvider(a) {
7447
7451
  return a.source === "provider" && !a.stale;
7448
7452
  }
7449
7453
  function chooseCandidates(fromSubscriptionId, inPriorityOrder, now) {
7450
- const available = [];
7451
- const limited = [];
7454
+ const tiers = {
7455
+ available: [],
7456
+ unverified: [],
7457
+ limited: []
7458
+ };
7452
7459
  const skipped = [];
7453
7460
  const resets = [];
7454
7461
  for (const c of inPriorityOrder) {
@@ -7473,22 +7480,57 @@ function chooseCandidates(fromSubscriptionId, inPriorityOrder, now) {
7473
7480
  continue;
7474
7481
  }
7475
7482
  const a = c.availability;
7483
+ const exhaustion = c.exhaustion ?? null;
7484
+ if (exhaustion !== null && a.state !== "AVAILABLE" && Date.parse(exhaustion.until) > now.getTime()) {
7485
+ skip(`${exhaustion.why} (until ${exhaustion.until})`);
7486
+ resets.push(exhaustion.until);
7487
+ continue;
7488
+ }
7476
7489
  if (a.state === "AVAILABLE" && (isFreshProvider(a) || a.source === "estimated")) {
7477
- available.push(c);
7490
+ tiers.available.push({ ...c, tier: "available" });
7491
+ } else if (a.state === "UNKNOWN") {
7492
+ tiers.unverified.push({ ...c, tier: "unverified" });
7478
7493
  } else if (a.state === "LIMITED" && isFreshProvider(a)) {
7479
- limited.push(c);
7494
+ tiers.limited.push({ ...c, tier: "limited" });
7480
7495
  } else {
7481
- skip(a.state === "UNKNOWN" ? "availability Unknown" : `${a.state} (${a.source})`);
7496
+ skip(`${a.state} (${a.source})`);
7482
7497
  }
7483
7498
  }
7484
7499
  const earliest = resets.filter((r) => Date.parse(r) > now.getTime()).sort((x, y) => Date.parse(x) - Date.parse(y))[0];
7485
7500
  return {
7486
- ordered: available.length > 0 ? available : limited,
7487
- limitedOnly: available.length === 0 && limited.length > 0,
7501
+ ordered: [...tiers.available, ...tiers.unverified, ...tiers.limited],
7502
+ limitedOnly: tiers.available.length === 0 && tiers.unverified.length === 0 && tiers.limited.length > 0,
7488
7503
  earliestReset: earliest ?? null,
7489
7504
  skipped
7490
7505
  };
7491
7506
  }
7507
+ function knownExhaustion(input) {
7508
+ const nowMs = input.now.getTime();
7509
+ const windows = (input.snapshot?.windows ?? []).flatMap((w) => {
7510
+ const resetMs = w.resetsAt === null ? Number.NaN : Date.parse(w.resetsAt);
7511
+ return w.usedPercentage === null || Number.isNaN(resetMs) || resetMs <= nowMs ? [] : [{ name: w.name, pct: w.usedPercentage, resetsAt: w.resetsAt, resetMs }];
7512
+ });
7513
+ const latestOf = (ws) => [...ws].sort((a, b) => b.resetMs - a.resetMs)[0];
7514
+ const full = latestOf(windows.filter((w) => w.pct >= 100));
7515
+ if (full !== void 0) {
7516
+ return {
7517
+ until: full.resetsAt,
7518
+ why: `the ${full.name} usage window was at ${Math.round(full.pct)}% when last reported`
7519
+ };
7520
+ }
7521
+ const away = input.lastAutoAway;
7522
+ if (away === null || away.category !== "LIMIT_REACHED") return null;
7523
+ const awayMs = Date.parse(away.at);
7524
+ const snapMs = input.snapshot === null ? Number.NaN : Date.parse(input.snapshot.observedAt);
7525
+ if (!Number.isNaN(snapMs) && snapMs > awayMs) return null;
7526
+ const high = latestOf(windows.filter((w) => w.pct >= input.limitedThreshold));
7527
+ const next = high ?? [...windows].sort((a, b) => a.resetMs - b.resetMs)[0];
7528
+ if (next === void 0) return null;
7529
+ return {
7530
+ until: next.resetsAt,
7531
+ why: `the limit was reached at ${away.at}; the ${next.name} usage window resets later`
7532
+ };
7533
+ }
7492
7534
  function checkRateGuard(input) {
7493
7535
  if (input.switchesLastHour >= input.maxPerSessionPerHour) {
7494
7536
  return {
@@ -9066,6 +9108,7 @@ export {
9066
9108
  evaluateTrigger,
9067
9109
  canSwitchNow,
9068
9110
  chooseCandidates,
9111
+ knownExhaustion,
9069
9112
  checkRateGuard,
9070
9113
  cooldownUntil,
9071
9114
  connectDaemon,
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ if (isCallback) {
23
23
  });
24
24
  process.stdin.destroy();
25
25
  } else {
26
- const { run } = await import("./main-45TVEBCR.js");
26
+ const { run } = await import("./main-LFJGWMAM.js");
27
27
  process.exitCode = await run(argv, {
28
28
  stdout: process.stdout,
29
29
  stderr: process.stderr,
@@ -61,7 +61,7 @@ import {
61
61
  withSubscriptionEnv,
62
62
  workerJson,
63
63
  workerManager
64
- } from "./chunk-C6GZJCDH.js";
64
+ } from "./chunk-6AS5JAKB.js";
65
65
  import {
66
66
  AppError,
67
67
  EXIT_CODES,
@@ -353,7 +353,7 @@ Example:
353
353
  );
354
354
  group.command("serve", { hidden: true }).description("Run the daemon in this process (used by daemon start)").action(
355
355
  action(rt, async (ctx) => {
356
- const { serveDaemon } = await import("./serve-IOY77YRF.js");
356
+ const { serveDaemon } = await import("./serve-MAWWQPZB.js");
357
357
  const daemon = await serveDaemon({
358
358
  env: ctx.io.env,
359
359
  entryPath: ctx.io.entryPath,
@@ -651,8 +651,12 @@ function registerEvents(program, rt) {
651
651
  Events come from the audit log in SQLite, so this works with or without the
652
652
  daemon. The daemon's availability monitor records availability.changed and
653
653
  availability.signal; automatic switching records autoswitch.* (with the
654
- reason, confidence, evidence, and from and to subscription). With --json,
655
- --follow prints one JSON object per line.
654
+ reason, confidence, evidence, and from and to subscription).
655
+ autoswitch.candidates lists every subscription with its decision (selected,
656
+ rejected or not tried) and why; autoswitch.precheck looks ahead while a
657
+ subscription is LIMITED; autoswitch.retry records a new check after no
658
+ subscription could take over. With --json, --follow prints one JSON object
659
+ per line.
656
660
 
657
661
  Examples:
658
662
  agent-pool events
@@ -2435,7 +2439,7 @@ Claude Code credential files.`
2435
2439
  }
2436
2440
 
2437
2441
  // src/main.ts
2438
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
2442
+ var VERSION = true ? "0.1.1" : "0.0.0-dev";
2439
2443
  async function run(argv, io) {
2440
2444
  const rt = { io, version: VERSION, exitCode: 0 };
2441
2445
  const program = buildProgram(rt);
@@ -22,6 +22,7 @@ import {
22
22
  errorObject,
23
23
  evaluateTrigger,
24
24
  handoffService,
25
+ knownExhaustion,
25
26
  newNonce,
26
27
  openSubscriptionEnv,
27
28
  parseMessage,
@@ -33,7 +34,7 @@ import {
33
34
  switchService,
34
35
  withSubscriptionEnv,
35
36
  workerManager
36
- } from "./chunk-C6GZJCDH.js";
37
+ } from "./chunk-6AS5JAKB.js";
37
38
  import {
38
39
  AppError,
39
40
  ENFORCES_POSIX_MODES,
@@ -708,6 +709,27 @@ function effectiveState(row, lastHook) {
708
709
  return row;
709
710
  }
710
711
  var QUIET_MS = 5 * 6e4;
712
+ var RETRY_MS = 5 * 6e4;
713
+ function ageText(ms) {
714
+ const minutes = Math.round(ms / 6e4);
715
+ if (minutes < 120) return `${minutes} min`;
716
+ const hours = Math.round(minutes / 60);
717
+ return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`;
718
+ }
719
+ function tierWhy(tier, view) {
720
+ if (tier === "available") {
721
+ return `AVAILABLE (${view?.source ?? "unknown"})`;
722
+ }
723
+ if (tier === "limited") return "LIMITED (provider): close to its limit";
724
+ const age = view?.usage === null || view?.usage === void 0 ? "no usage data from the provider yet" : `provider data ${ageText(view.usage.ageMs)} old`;
725
+ return `availability Unknown (${age}); only a session on it can tell, so it is tried after the login check`;
726
+ }
727
+ function atLimit(snapshot, now) {
728
+ if (snapshot === null) return false;
729
+ return [snapshot.fiveHour, snapshot.sevenDay].some(
730
+ (w) => w !== void 0 && w.usedPercentage !== null && w.usedPercentage >= 100 && w.resetsAt !== null && Date.parse(w.resetsAt) > now.getTime()
731
+ );
732
+ }
711
733
  var AUTO_DECISIONS = {
712
734
  overrideGate: () => Promise.resolve(false),
713
735
  checkpointCommit: () => Promise.resolve(false),
@@ -757,7 +779,9 @@ var AutoSwitchWatcher = class {
757
779
  quietUntil: 0,
758
780
  switched: false,
759
781
  lastHook: null,
760
- approved: false
782
+ approved: false,
783
+ waiting: null,
784
+ prechecked: false
761
785
  });
762
786
  }
763
787
  untrack(run) {
@@ -837,14 +861,48 @@ var AutoSwitchWatcher = class {
837
861
  this.signal(t, worker, { classification, source });
838
862
  }
839
863
  }
840
- if (t.pending !== null && canSwitchNow(effectiveState(worker.state, t.lastHook))) {
864
+ if (t.waiting !== null) {
865
+ if (t.lastHook === "hook.Stop" || newSnapshot && !atLimit(usage.snapshot, this.now())) {
866
+ t.waiting = null;
867
+ }
868
+ }
869
+ const switchable = canSwitchNow(effectiveState(worker.state, t.lastHook));
870
+ if (t.pending !== null && switchable) {
841
871
  const signal = t.pending;
842
872
  t.pending = null;
843
873
  t.busy = true;
844
874
  void this.decideLive(t, worker, signal).finally(() => {
845
875
  t.busy = false;
846
876
  });
877
+ return;
878
+ }
879
+ if (t.waiting !== null && switchable && this.now().getTime() >= t.waiting.retryAt) {
880
+ const { signal } = t.waiting;
881
+ t.busy = true;
882
+ void this.decideLive(t, worker, signal, { retry: true }).finally(() => {
883
+ t.busy = false;
884
+ });
885
+ return;
847
886
  }
887
+ if (newSnapshot && t.pending === null && t.waiting === null) {
888
+ const sub = this.storage.subscriptions.getById(worker.subscriptionId);
889
+ const state = sub === void 0 ? null : this.options.env.availability.evaluate(sub).state;
890
+ if (state === "LIMITED" && !t.prechecked) {
891
+ t.prechecked = true;
892
+ t.busy = true;
893
+ void this.precheck(t, worker).finally(() => {
894
+ t.busy = false;
895
+ });
896
+ } else if (state === "AVAILABLE") {
897
+ t.prechecked = false;
898
+ }
899
+ }
900
+ }
901
+ /** When the candidates are checked again after none could take over. */
902
+ retryAt(earliestReset) {
903
+ const next = this.now().getTime() + (this.options.retryMs ?? RETRY_MS);
904
+ const reset = earliestReset === null ? Number.NaN : Date.parse(earliestReset) + 1e3;
905
+ return Number.isNaN(reset) ? next : Math.min(next, reset);
848
906
  }
849
907
  /** Records a classified signal and decides whether it waits for a switch. */
850
908
  signal(t, worker, signal) {
@@ -895,10 +953,59 @@ var AutoSwitchWatcher = class {
895
953
  sessionId: worker.sessionId
896
954
  };
897
955
  }
956
+ /**
957
+ * Every subscription as a switching candidate, in priority order, and the
958
+ * policy's choice. Availability that is no longer current does not rule a
959
+ * subscription out: an idle account has no fresh usage data, because only a
960
+ * running session reports it. What older data still proves (a window at
961
+ * its limit, or a switch away for a limit) keeps it out until the reset.
962
+ */
963
+ candidates(fromSubscriptionId, config, now) {
964
+ const { storage } = this;
965
+ const all = storage.subscriptions.list();
966
+ const views = /* @__PURE__ */ new Map();
967
+ const inputs = all.map((s) => {
968
+ const view = this.options.env.availability.evaluate(s, now);
969
+ views.set(s.id, view);
970
+ const snap = storage.snapshots.latestBySource(s.id, "provider");
971
+ const away = storage.switches.lastAutoAwayFrom(s.id);
972
+ return {
973
+ id: s.id,
974
+ label: s.label,
975
+ removed: s.removedAt !== null,
976
+ status: s.status,
977
+ availability: {
978
+ state: view.state,
979
+ source: view.source,
980
+ stale: view.stale,
981
+ resetsAt: view.resetsAt
982
+ },
983
+ cooldownUntil: cooldownUntil(away?.requestedAt ?? null, config.switching.cooldownMinutes),
984
+ exhaustion: knownExhaustion({
985
+ snapshot: snap === void 0 ? null : {
986
+ observedAt: snap.observedAt,
987
+ windows: [
988
+ { name: "5-hour", ...snap.fiveHour },
989
+ { name: "7-day", ...snap.sevenDay }
990
+ ]
991
+ },
992
+ lastAutoAway: away === void 0 ? null : { at: away.requestedAt, category: away.details?.category ?? null },
993
+ limitedThreshold: config.availability.limitedThreshold,
994
+ now
995
+ })
996
+ };
997
+ });
998
+ return { all, views, choice: chooseCandidates(fromSubscriptionId, inputs, now) };
999
+ }
898
1000
  /**
899
1001
  * The shared part of both paths: mode, loop guards and candidates. Returns
900
- * the target, or null after recording why there is none (and telling the
901
- * attached terminal).
1002
+ * the target, or why there is none (after recording it and telling the
1003
+ * attached terminal). Each candidate's login is checked in turn; one that
1004
+ * fails is skipped and the next one is tried.
1005
+ *
1006
+ * revalidate: the ask path checking again after the answer (no new
1007
+ * triggered or candidates event). retry: checking again after no
1008
+ * subscription could take over (records only what changed).
902
1009
  */
903
1010
  async plan(run, worker, classification, options = {}) {
904
1011
  const { storage } = this;
@@ -908,6 +1015,7 @@ var AutoSwitchWatcher = class {
908
1015
  const label = sub?.label ?? worker.subscriptionId;
909
1016
  const reason = categoryReason(classification.category);
910
1017
  const base = this.base(worker);
1018
+ const quiet = options.revalidate === true || options.retry === true;
911
1019
  const details = {
912
1020
  mode,
913
1021
  reason,
@@ -915,7 +1023,7 @@ var AutoSwitchWatcher = class {
915
1023
  confidence: classification.confidence,
916
1024
  evidence: classification.evidence
917
1025
  };
918
- if (options.revalidate !== true) {
1026
+ if (!quiet) {
919
1027
  storage.events.append({
920
1028
  type: "autoswitch.triggered",
921
1029
  severity: "warn",
@@ -926,22 +1034,24 @@ var AutoSwitchWatcher = class {
926
1034
  }
927
1035
  });
928
1036
  }
929
- const say = (type, summary, extra = {}) => {
1037
+ const say = (type, summary2, extra2 = {}) => {
930
1038
  storage.events.append({
931
1039
  type,
932
1040
  severity: "warn",
933
1041
  ...base,
934
- data: { ...details, ...extra, summary }
1042
+ data: { ...details, ...extra2, summary: summary2 }
935
1043
  });
936
- run.line(`Agent Pool: ${summary}`);
937
- run.notice(summary);
938
- return null;
1044
+ run.line(`Agent Pool: ${summary2}`);
1045
+ run.notice(summary2);
939
1046
  };
940
1047
  if (mode === "off") {
941
- return say(
942
- "autoswitch.off",
943
- `"${label}": ${reason} (${classification.confidence}). autoSwitch is off, so the worker stays on "${label}"; switch by hand with "agent-pool worker switch".`
944
- );
1048
+ if (options.retry !== true) {
1049
+ say(
1050
+ "autoswitch.off",
1051
+ `"${label}": ${reason} (${classification.confidence}). autoSwitch is off, so the worker stays on "${label}"; switch by hand with "agent-pool worker switch".`
1052
+ );
1053
+ }
1054
+ return { kind: "off" };
945
1055
  }
946
1056
  const now = this.now();
947
1057
  if (worker.sessionId !== null) {
@@ -953,48 +1063,138 @@ var AutoSwitchWatcher = class {
953
1063
  maxPerSessionPerHour: config.switching.maxPerSessionPerHour
954
1064
  });
955
1065
  if (!guard.ok) {
956
- return say(
957
- "autoswitch.blocked",
958
- `"${label}": ${reason}, but automatic switching stopped: ${guard.reason}. Switch by hand with "agent-pool worker switch".`,
959
- { guard: "rate" }
960
- );
1066
+ if (options.retry !== true) {
1067
+ say(
1068
+ "autoswitch.blocked",
1069
+ `"${label}": ${reason}, but automatic switching stopped: ${guard.reason}. Switch by hand with "agent-pool worker switch".`,
1070
+ { guard: "rate" }
1071
+ );
1072
+ }
1073
+ return { kind: "blocked" };
961
1074
  }
962
1075
  }
963
- const all = storage.subscriptions.list();
964
- const inputs = all.map((s) => {
965
- const view = this.options.env.availability.evaluate(s, now);
966
- return {
967
- id: s.id,
968
- label: s.label,
969
- removed: s.removedAt !== null,
970
- status: s.status,
971
- availability: {
972
- state: view.state,
973
- source: view.source,
974
- stale: view.stale,
975
- resetsAt: view.resetsAt
976
- },
977
- cooldownUntil: cooldownUntil(
978
- storage.switches.lastAutoAwayFrom(s.id)?.requestedAt ?? null,
979
- config.switching.cooldownMinutes
980
- )
981
- };
982
- });
983
- const choice = chooseCandidates(worker.subscriptionId, inputs, now);
1076
+ const { all, views, choice } = this.candidates(worker.subscriptionId, config, now);
984
1077
  const switches = switchService(run.ctx ?? this.options.ctx, this.options.env, quietReporter());
1078
+ const rejected = new Map(choice.skipped.map((x) => [x.id, x.why]));
1079
+ let selected = null;
985
1080
  for (const candidate of choice.ordered) {
986
1081
  const target = all.find((s) => s.id === candidate.id);
987
1082
  if (target === void 0) continue;
988
1083
  const check = await switches.checkTarget(target, worker.subscriptionId);
989
- if (check.usable) return target;
990
- choice.skipped.push({ id: target.id, label: target.label, why: check.reasons.join(" ") });
1084
+ if (check.usable) {
1085
+ selected = { target, tier: candidate.tier };
1086
+ break;
1087
+ }
1088
+ const why = check.reasons.join(" ");
1089
+ rejected.set(target.id, why);
1090
+ choice.skipped.push({ id: target.id, label: target.label, why });
1091
+ }
1092
+ const rows = all.filter((s) => s.removedAt === null).map((s) => {
1093
+ const ranked = choice.ordered.find((c) => c.id === s.id);
1094
+ const tier = ranked === void 0 ? {} : { tier: ranked.tier };
1095
+ if (s.id === worker.subscriptionId) {
1096
+ return { id: s.id, label: s.label, decision: "current", why: reason };
1097
+ }
1098
+ const no = rejected.get(s.id);
1099
+ if (no !== void 0)
1100
+ return { id: s.id, label: s.label, decision: "rejected", ...tier, why: no };
1101
+ if (selected?.target.id === s.id) {
1102
+ return {
1103
+ id: s.id,
1104
+ label: s.label,
1105
+ decision: "selected",
1106
+ ...tier,
1107
+ why: `${tierWhy(selected.tier, views.get(s.id))}; logged in`
1108
+ };
1109
+ }
1110
+ return {
1111
+ id: s.id,
1112
+ label: s.label,
1113
+ decision: "not tried",
1114
+ ...tier,
1115
+ why: "a subscription earlier in the order was selected"
1116
+ };
1117
+ });
1118
+ const key = JSON.stringify(rows.map((r) => [r.id, r.decision, r.why]));
1119
+ const changed = options.retry !== true || key !== options.previousKey;
1120
+ if (options.revalidate !== true && changed) {
1121
+ storage.events.append({
1122
+ type: "autoswitch.candidates",
1123
+ ...base,
1124
+ data: {
1125
+ summary: selected === null ? `No subscription can take over from "${label}".` : `Selected "${selected.target.label}" to take over from "${label}" (${selected.tier}).`,
1126
+ selected: selected?.target.id ?? null,
1127
+ ...options.retry === true ? { retry: true } : {},
1128
+ candidates: rows
1129
+ }
1130
+ });
991
1131
  }
1132
+ if (selected !== null) return { kind: "target", ...selected };
992
1133
  const reset = choice.earliestReset === null ? "No reset time is known." : `The earliest known reset is ${choice.earliestReset}.`;
993
- return say(
994
- "autoswitch.exhausted",
995
- `"${label}": ${reason}, and no other subscription can take over. ${reset} Claude Code continues automatically after a reset.`,
996
- { earliestReset: choice.earliestReset, skipped: choice.skipped }
997
- );
1134
+ const summary = `"${label}": ${reason}, and no other subscription can take over. ${reset} Agent Pool checks again every ${Math.round((this.options.retryMs ?? RETRY_MS) / 6e4)} min and at the earliest reset; Claude Code continues automatically after a reset.`;
1135
+ const extra = { earliestReset: choice.earliestReset, skipped: choice.skipped };
1136
+ if (options.retry !== true) {
1137
+ say("autoswitch.exhausted", summary, extra);
1138
+ } else if (changed) {
1139
+ storage.events.append({
1140
+ type: "autoswitch.retry",
1141
+ ...base,
1142
+ data: { ...details, ...extra, summary }
1143
+ });
1144
+ }
1145
+ return { kind: "exhausted", earliestReset: choice.earliestReset, key };
1146
+ }
1147
+ /**
1148
+ * While the worker's subscription is LIMITED: checks ahead which
1149
+ * subscription would take over, so a missing login shows before the limit
1150
+ * is reached. Usage of an idle account cannot be read, so only logins are
1151
+ * checked; the switch itself checks everything again.
1152
+ */
1153
+ async precheck(t, worker) {
1154
+ try {
1155
+ const config = this.liveConfig();
1156
+ if (config.autoSwitch === "off") return;
1157
+ const { storage } = this;
1158
+ const label = storage.subscriptions.getById(worker.subscriptionId)?.label ?? worker.subscriptionId;
1159
+ const { all, views, choice } = this.candidates(worker.subscriptionId, config, this.now());
1160
+ const switches = switchService(
1161
+ t.run.ctx ?? this.options.ctx,
1162
+ this.options.env,
1163
+ quietReporter()
1164
+ );
1165
+ const rows = choice.skipped.map((x) => ({
1166
+ id: x.id,
1167
+ label: x.label,
1168
+ decision: "rejected",
1169
+ why: x.why
1170
+ }));
1171
+ const ready = [];
1172
+ for (const candidate of choice.ordered) {
1173
+ const target = all.find((s) => s.id === candidate.id);
1174
+ if (target === void 0) continue;
1175
+ const check = await switches.checkTarget(target, worker.subscriptionId);
1176
+ if (check.usable) ready.push(target.label);
1177
+ rows.push({
1178
+ id: target.id,
1179
+ label: target.label,
1180
+ tier: candidate.tier,
1181
+ decision: check.usable ? "ready" : "rejected",
1182
+ why: check.usable ? `${tierWhy(candidate.tier, views.get(target.id))}; logged in` : check.reasons.join(" ")
1183
+ });
1184
+ }
1185
+ const summary = ready.length > 0 ? `"${label}" is close to its limit; next in line: ${ready.map((l) => `"${l}"`).join(", ")}.` : `"${label}" is close to its limit and no other subscription is ready to take over: ${rows.map((r) => `"${r.label}" ${r.why}`).join("; ") || "there is no other subscription"}.`;
1186
+ storage.events.append({
1187
+ type: "autoswitch.precheck",
1188
+ severity: ready.length > 0 ? "info" : "warn",
1189
+ ...this.base(worker),
1190
+ data: { summary, candidates: rows }
1191
+ });
1192
+ if (ready.length === 0) {
1193
+ t.run.line(`Agent Pool: ${summary}`);
1194
+ t.run.notice(summary);
1195
+ }
1196
+ } catch {
1197
+ }
998
1198
  }
999
1199
  /** In ask mode: the question waits in the daemon until a terminal answers. */
1000
1200
  async confirm(run, worker, target, classification) {
@@ -1019,18 +1219,35 @@ var AutoSwitchWatcher = class {
1019
1219
  }
1020
1220
  return ok;
1021
1221
  }
1022
- /** A signal while the worker is alive: hand off and ask its owner (this daemon) to switch. */
1023
- async decideLive(t, worker, signal) {
1222
+ /**
1223
+ * A signal while the worker is alive: hand off and ask its owner (this
1224
+ * daemon) to switch. With retry: the candidates are checked again after
1225
+ * none could take over earlier.
1226
+ */
1227
+ async decideLive(t, worker, signal, options = {}) {
1024
1228
  const { storage } = this;
1025
1229
  const quiet = () => {
1026
1230
  t.quietUntil = Date.now() + QUIET_MS;
1027
1231
  };
1232
+ const retry = options.retry === true;
1233
+ const previousKey = t.waiting?.key;
1028
1234
  try {
1029
- const target = await this.plan(t.run, worker, signal.classification);
1030
- if (target === null) {
1031
- quiet();
1235
+ const outcome = await this.plan(t.run, worker, signal.classification, {
1236
+ retry,
1237
+ ...previousKey === void 0 ? {} : { previousKey }
1238
+ });
1239
+ if (outcome.kind !== "target") {
1240
+ if (outcome.kind === "exhausted") {
1241
+ t.waiting = { signal, retryAt: this.retryAt(outcome.earliestReset), key: outcome.key };
1242
+ } else if (outcome.kind === "blocked" && t.waiting !== null) {
1243
+ t.waiting.retryAt = this.retryAt(null);
1244
+ } else {
1245
+ t.waiting = null;
1246
+ }
1247
+ if (!retry) quiet();
1032
1248
  return;
1033
1249
  }
1250
+ const { target, tier } = outcome;
1034
1251
  if (this.mode() === "ask") {
1035
1252
  if (!t.approved) {
1036
1253
  if (!await this.confirm(t.run, worker, target, signal.classification)) {
@@ -1052,7 +1269,7 @@ var AutoSwitchWatcher = class {
1052
1269
  return;
1053
1270
  }
1054
1271
  const again = await this.plan(t.run, current, signal.classification, { revalidate: true });
1055
- if (again === null || again.id !== target.id) {
1272
+ if (again.kind !== "target" || again.target.id !== target.id) {
1056
1273
  quiet();
1057
1274
  return;
1058
1275
  }
@@ -1087,9 +1304,15 @@ var AutoSwitchWatcher = class {
1087
1304
  });
1088
1305
  t.run.line(`Agent Pool: ${summary}`);
1089
1306
  t.run.notice(summary);
1307
+ if (t.waiting !== null) t.waiting.retryAt = this.retryAt(null);
1090
1308
  quiet();
1091
1309
  return;
1092
1310
  }
1311
+ if (tier === "unverified") {
1312
+ t.run.line(
1313
+ `Agent Pool: switching to "${target.label}": its availability is Unknown (no current usage data), and its login is verified.`
1314
+ );
1315
+ }
1093
1316
  const details = {
1094
1317
  category: signal.classification.category,
1095
1318
  confidence: signal.classification.confidence,
@@ -1116,12 +1339,15 @@ var AutoSwitchWatcher = class {
1116
1339
  reason,
1117
1340
  ...details,
1118
1341
  from: worker.subscriptionId,
1119
- to: target.id
1342
+ to: target.id,
1343
+ tier,
1344
+ verified: tier !== "unverified"
1120
1345
  }
1121
1346
  });
1122
1347
  }
1123
1348
  });
1124
1349
  t.switched = true;
1350
+ t.waiting = null;
1125
1351
  } catch (err) {
1126
1352
  storage.events.append({
1127
1353
  type: "autoswitch.failed",
@@ -1141,8 +1367,9 @@ var AutoSwitchWatcher = class {
1141
1367
  const decision = evaluateTrigger(finished.classification);
1142
1368
  if (!decision.trigger) return null;
1143
1369
  const worker = finished.worker;
1144
- const target = await this.plan(run, worker, finished.classification);
1145
- if (target === null) return null;
1370
+ const outcome = await this.plan(run, worker, finished.classification);
1371
+ if (outcome.kind !== "target") return null;
1372
+ const { target, tier } = outcome;
1146
1373
  if (this.mode() === "ask" && !await this.confirm(run, worker, target, finished.classification)) {
1147
1374
  return null;
1148
1375
  }
@@ -1181,6 +1408,8 @@ var AutoSwitchWatcher = class {
1181
1408
  ...details,
1182
1409
  from: subscription.id,
1183
1410
  to: target.id,
1411
+ tier,
1412
+ verified: tier !== "unverified",
1184
1413
  after: "exit"
1185
1414
  }
1186
1415
  });
@@ -1517,7 +1746,8 @@ async function serveDaemon(io) {
1517
1746
  env,
1518
1747
  config,
1519
1748
  clock,
1520
- ...io.watchIntervalMs === void 0 ? {} : { intervalMs: io.watchIntervalMs }
1749
+ ...io.watchIntervalMs === void 0 ? {} : { intervalMs: io.watchIntervalMs },
1750
+ ...io.autoSwitchRetryMs === void 0 ? {} : { retryMs: io.autoSwitchRetryMs }
1521
1751
  });
1522
1752
  const runFor = (params) => {
1523
1753
  const runId = optString(obj(params), "runId", 64);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tecorb/agent-pool",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Orchestrate Claude Code across multiple Claude subscriptions owned by one developer.",
5
5
  "keywords": [
6
6
  "claude",