agent-dag 1.30.6 → 1.30.7

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-CBP4rn8o.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-LYGlqJEO.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-BHfNMFBX.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.30.6",
3
+ "version": "1.30.7",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,9 +42,31 @@ const CACHE_MS = 5_000;
42
42
  // without a marker would misrepresent them (its own trust ceiling is 3600s).
43
43
  const STALE_AFTER_MS = 15 * 60_000;
44
44
 
45
- // Nudging the collector: at most this often, however many times the panel asks.
46
- const NUDGE_EVERY_MS = 60_000;
45
+ // Nudging the collector.
46
+ //
47
+ // Two throttles, because the cost of asking is not the cost of fetching. When
48
+ // claude-swap's plan says nothing is due, `cswap list` fetches nothing — the
49
+ // spawn rate is bounded by the plan (one per 180-600s), not by how often we
50
+ // ask. So asking often is cheap in requests and only costs a subprocess, and
51
+ // asking often is exactly how `cswap watch` stays current: it re-asks every
52
+ // three seconds and therefore collects the moment a plan comes due.
53
+ //
54
+ // The exception is an ask that changes nothing — a claim held by another
55
+ // collector, a backoff, a plan that stays overdue. Repeating that every few
56
+ // seconds is pure spawn churn, so a second, slower throttle applies until the
57
+ // store actually moves.
58
+ const NUDGE_EVERY_MS = 15_000; // when the last ask produced new data
59
+ const NUDGE_QUIET_MS = 60_000; // when it did not
47
60
  let _lastNudge = 0;
61
+ let _lastSeenFetch = 0; // newest fetchedAt observed, in seconds
62
+
63
+ // claude-swap's SERVE_TTL_S (180s) plus slack: below this age it serves from
64
+ // the store and fetches nothing, so asking earlier only costs a subprocess.
65
+ const FRESH_MIN_AGE_MS = 190_000;
66
+
67
+ // claude-swap's RECENT_429_WINDOW_S. While a 429 is this recent, its own
68
+ // congestion control is deliberately holding back, and so do we.
69
+ const RECENT_429_MS = 3_600_000;
48
70
 
49
71
  /**
50
72
  * Ask claude-swap to collect, if its own schedule says anything is due.
@@ -91,14 +113,107 @@ export function collectionDue(rows, slots, now) {
91
113
  return false;
92
114
  }
93
115
 
94
- function nudgeCollector(rows, slots, now) {
95
- if (now - _lastNudge < NUDGE_EVERY_MS) return;
96
- if (!collectionDue(rows, slots, now)) return;
116
+ /**
117
+ * Whether the engine path may be used to refresh this row now.
118
+ *
119
+ * claude-swap gates fetches two different ways (usage_store._row_eligible).
120
+ * On-demand surfaces — `cswap list`, status, switch — need the row to be BOTH
121
+ * stale and past its planned poll time. The auto engine needs it to be stale
122
+ * OR due, and stale means older than SERVE_TTL_S, which is 180 seconds. That
123
+ * OR is the whole difference: it is why a plan stretched out to 30 minutes
124
+ * still yields three-minute-old numbers to the engine, and why `cswap list`
125
+ * cannot do the same however often it is called.
126
+ *
127
+ * The plan is stretched for a reason, though, and one of those reasons must be
128
+ * respected rather than routed around: claude-swap runs AIMD congestion
129
+ * control on a budget it shares with every other machine holding the same
130
+ * account (POST_429_BACKOFF_MULT, RECENT_429_WINDOW_S). While an account is
131
+ * recovering from a 429, backing off IS the correct behaviour, and polling
132
+ * every 180 seconds through it would re-saturate exactly the window that needs
133
+ * to drain. So the engine path is used only for a healthy row: no live
134
+ * backoff, no failures, and no 429 seen within claude-swap's own recovery
135
+ * window.
136
+ *
137
+ * Exported for tests.
138
+ */
139
+ export function freshenAllowed(row, now, recent429Ms = RECENT_429_MS) {
140
+ if (!row || typeof row.fetchedAt !== "number") return false; // the list path owns this case
141
+ if (process.env.AGENTS_DECK_NO_FRESHEN === "1") return false;
142
+ if (typeof row.backoffUntil === "number" && row.backoffUntil * 1000 > now) return false;
143
+ if ((row.consecutiveFailures ?? 0) > 0) return false;
144
+ if (typeof row.last429At === "number" && now - row.last429At * 1000 < recent429Ms) return false;
145
+ return true;
146
+ }
147
+
148
+ /** freshenAllowed, plus old enough that asking would actually fetch. */
149
+ export function freshenDue(row, now, { minAgeMs = FRESH_MIN_AGE_MS, recent429Ms = RECENT_429_MS } = {}) {
150
+ if (!freshenAllowed(row, now, recent429Ms)) return false;
151
+ // Younger than the serve TTL: claude-swap would answer from the store
152
+ // without fetching, so asking achieves nothing but a subprocess.
153
+ return now - row.fetchedAt * 1000 >= minAgeMs;
154
+ }
155
+
156
+ /**
157
+ * When this account's numbers will next be refreshed, in epoch ms.
158
+ *
159
+ * claude-swap's plan, except for a healthy active account, where the deck's
160
+ * own freshen tick gets there first. Exported for tests.
161
+ */
162
+ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
163
+ const planned = matches && typeof row?.nextPollAt === "number"
164
+ ? Math.round(row.nextPollAt * 1000)
165
+ : null;
166
+ const freshenAt = (isActive && fetchedAtMs != null && freshenAllowed(row, now))
167
+ ? fetchedAtMs + FRESH_MIN_AGE_MS
168
+ : null;
169
+ if (planned == null) return freshenAt;
170
+ if (freshenAt == null) return planned;
171
+ return Math.min(planned, freshenAt);
172
+ }
173
+
174
+ /**
175
+ * Keep the store moving while someone is looking at it.
176
+ *
177
+ * Two ways to ask, and the cheaper one is preferred:
178
+ * - something is due by claude-swap's own plan → `cswap list`, the ordinary
179
+ * on-demand pass every surface uses;
180
+ * - nothing is due but the active account's numbers have aged past the serve
181
+ * TTL → one `cswap auto --once --dry-run`, which is the engine path and so
182
+ * is judged on staleness rather than on the plan.
183
+ *
184
+ * What dry run guarantees is narrower than the name suggests, and worth stating
185
+ * exactly: it never switches accounts and never writes autoswitch state — the
186
+ * switch call is unreachable behind its dry-run return. Its collect pass, on
187
+ * the other hand, runs unconditionally, which is the point: it fetches, writes
188
+ * usage rows, and can rotate and persist an OAuth token exactly as `cswap list`
189
+ * does. So this is not a read-only call; it is the same collection every other
190
+ * surface performs, minus the switch.
191
+ *
192
+ * Either way claude-swap decides whether a network call actually happens, and
193
+ * this is throttled on top of that.
194
+ */
195
+ function nudgeCollector(rows, slots, now, activeNum) {
196
+ // Did the last ask accomplish anything? Cheap proxy: the newest collection
197
+ // timestamp in the store.
198
+ let newest = 0;
199
+ for (const slot of slots) {
200
+ const f = rows[slot]?.fetchedAt;
201
+ if (typeof f === "number" && f > newest) newest = f;
202
+ }
203
+ const moved = newest > _lastSeenFetch;
204
+ _lastSeenFetch = Math.max(_lastSeenFetch, newest);
205
+
206
+ if (now - _lastNudge < (moved ? NUDGE_EVERY_MS : NUDGE_QUIET_MS)) return;
207
+
208
+ const due = collectionDue(rows, slots, now);
209
+ const freshen = !due && freshenDue(rows[String(activeNum)], now);
210
+ if (!due && !freshen) return;
97
211
 
98
212
  _lastNudge = now;
213
+ const args = due ? ["list"] : ["auto", "--once", "--dry-run", "--json"];
99
214
  // Fire-and-forget: this function is deliberately synchronous so callers never
100
215
  // wait on it, and resolving the binary is the only async part.
101
- cswapBin().then(bin => runDetached(bin, ["list"])).catch(() => {});
216
+ cswapBin().then(bin => runDetached(bin, args)).catch(() => {});
102
217
  }
103
218
 
104
219
  async function readJson(path) {
@@ -161,7 +276,7 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
161
276
  // Kick a collection for the NEXT poll if anything is due — either because
162
277
  // claude-swap's schedule says so, or because an account has never been
163
278
  // fetched at all.
164
- nudgeCollector(rows, Object.keys(seq.accounts), now);
279
+ nudgeCollector(rows, Object.keys(seq.accounts), now, seq.activeAccountNumber);
165
280
 
166
281
  const order = Array.isArray(seq.sequence) && seq.sequence.length
167
282
  ? seq.sequence.map(String)
@@ -203,12 +318,11 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
203
318
  // this account is worth switching to.
204
319
  headroom: lanes.length ? Math.max(0, 100 - Math.max(...lanes.map(l => l.pct))) : null,
205
320
  fetchedAt: fetchedAtMs,
206
- // When claude-swap plans to read this account again. It sets the
207
- // interval per account (180s floor, out to 1800s while a token is
208
- // recovering from a 429), and every surface this panel, the TUI,
209
- // `cswap watch` — inherits the same plan. Showing it turns "collected
210
- // 13m ago" from a complaint into a schedule.
211
- nextAt: matches && typeof row.nextPollAt === "number" ? Math.round(row.nextPollAt * 1000) : null,
321
+ // When this account will next be read the earlier of claude-swap's own
322
+ // plan and, for a healthy active account, the deck's freshen tick. The
323
+ // plan alone would promise "next in 15m" while the panel actually
324
+ // updates in three.
325
+ nextAt: nextReadAt(row, matches, fetchedAtMs, String(seq.activeAccountNumber) === num, now),
212
326
  stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
213
327
  // Surfaced rather than hidden: a rate-limited or re-login-needed account
214
328
  // is exactly the one the user is about to try switching to.
@@ -279,7 +393,7 @@ export async function requestCollection() {
279
393
  const usage = await readJson(join(root, "cache", "usage.json"));
280
394
  const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
281
395
  const before = _lastNudge;
282
- nudgeCollector(rows, Object.keys(seq.accounts), Date.now());
396
+ nudgeCollector(rows, Object.keys(seq.accounts), Date.now(), seq.activeAccountNumber);
283
397
  return _lastNudge !== before;
284
398
  }
285
399