paseo-smart-session 3.2.0 → 3.4.0

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.
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Every ZeroSub subscription, as time: how long each lasts, which one takes over
3
+ * next, and how long the pool lasts at this pace.
4
+ *
5
+ * ZeroSub manages accounts (sign-in, default, switching). It moves an agent to the
6
+ * usable account with the most room when the CLI reports a limit. Smart Session
7
+ * already forecasts one login's windows from its recorded history; this file
8
+ * extends that to all of them. It simulates ZeroSub's failover order across the
9
+ * accounts' windows and resets.
10
+ *
11
+ * Plain functions over plain values, shared by the server (which builds the
12
+ * accounts), the overview, the sidebar row, the pill and `budget_status`.
13
+ * `subscriptions.test.ts` holds the rules.
14
+ */
15
+
16
+ import { formatDuration } from "./format.ts";
17
+ import { forecastWindow, formatMoment, type Tone } from "./forecast.ts";
18
+
19
+ export type Family = "claude" | "codex";
20
+
21
+ const MINUTE = 60_000;
22
+ const HOUR = 60 * MINUTE;
23
+ const FIVE_HOURS = 5 * HOUR;
24
+ const WEEK = 7 * 24 * HOUR;
25
+
26
+ /** ZeroSub refreshes an account in use at least every half hour; older readings are not live. */
27
+ export const STALE_AFTER_MS = 30 * MINUTE;
28
+ /** Rates below this mean nobody is using the window right now (as in `forecast.ts`). */
29
+ const IDLE_RATE = 0.2;
30
+ /** How far ahead the pool is simulated. Past a day, "at this pace" means nothing. */
31
+ export const POOL_HORIZON_MS = 24 * HOUR;
32
+
33
+ export interface SubscriptionWindow {
34
+ /** `five_hour` and `seven_day` for both families; other caps keep their own id. */
35
+ readonly id: string;
36
+ readonly pct: number;
37
+ readonly resetsAt: string | null;
38
+ /** Percentage points per hour over the last half hour; null when stale or unmeasured. */
39
+ readonly burnPctPerHour: number | null;
40
+ }
41
+
42
+ /** One subscription, as the server hands it over: no email, no credential, no home path. */
43
+ export interface Subscription {
44
+ readonly id: string;
45
+ readonly family: Family;
46
+ readonly kind: "main" | "managed";
47
+ /** The name the person gave it in ZeroSub, else a neutral one (`subscriptionName`). */
48
+ readonly name: string;
49
+ /** True when `name` is the person's own; false for "CLI login" / "Account 2". */
50
+ readonly named: boolean;
51
+ readonly isDefault: boolean;
52
+ readonly signedIn: boolean;
53
+ /** Set aside in ZeroSub: signed in, but nothing is routed to it. */
54
+ readonly disabled: boolean;
55
+ /** A limit the CLI reported, until it lifts. */
56
+ readonly limitedUntil: string | null;
57
+ /**
58
+ * When the account has room again, by the guard's own rule (`outUntil` in
59
+ * `shared/guard/zerosub.ts`): a limit ZeroSub recorded or a full window, weekly
60
+ * included. Null while it has room; absent from older servers, when the windows decide.
61
+ */
62
+ readonly outUntil?: string | null;
63
+ readonly createdAt: string | null;
64
+ /** Live sessions on this account now. */
65
+ readonly agents: number;
66
+ /** When the provider produced the reading the windows come from; null with no reading. */
67
+ readonly readAt: string | null;
68
+ readonly windows: SubscriptionWindow[];
69
+ readonly credits: { readonly balance: number | null; readonly unlimited: boolean } | null;
70
+ /** The open session window's readings, oldest first, thinned to a sparkline's worth. */
71
+ readonly history?: { t: string; pct: number }[];
72
+ }
73
+
74
+ const time = (iso: string | null | undefined): number | null => {
75
+ if (!iso) return null;
76
+ const ms = Date.parse(iso);
77
+ return Number.isFinite(ms) ? ms : null;
78
+ };
79
+
80
+ export const familyName = (family: Family) => (family === "claude" ? "Claude" : "Codex");
81
+
82
+ /**
83
+ * What to call an account. The name the person chose, else a neutral one: "CLI login"
84
+ * for the CLI's own login, "Account 2", "Account 3"… for the ones ZeroSub added, in
85
+ * the order they were added. Never the email, and never ZeroSub's generated label,
86
+ * which is the email or part of it.
87
+ */
88
+ export function subscriptionName(
89
+ account: { id: string; family: Family; kind: "main" | "managed"; label: string | null; createdAt: string | null },
90
+ all: readonly { id: string; family: Family; kind: "main" | "managed"; createdAt: string | null }[],
91
+ ): { name: string; named: boolean } {
92
+ if (account.label !== null) return { name: account.label, named: true };
93
+ if (account.kind === "main") return { name: "CLI login", named: false };
94
+ const managed = all
95
+ .filter((other) => other.family === account.family && other.kind === "managed")
96
+ .sort((a, b) => (time(a.createdAt) ?? 0) - (time(b.createdAt) ?? 0) || a.id.localeCompare(b.id));
97
+ const hasMain = all.some((other) => other.family === account.family && other.kind === "main");
98
+ return { name: `Account ${managed.findIndex((other) => other.id === account.id) + (hasMain ? 2 : 1)}`, named: false };
99
+ }
100
+
101
+ export const sessionWindow = (account: Subscription) => account.windows.find((w) => w.id === "five_hour") ?? null;
102
+ export const weeklyWindow = (account: Subscription) => account.windows.find((w) => w.id === "seven_day") ?? null;
103
+
104
+ /** How old the reading is, when that is worth saying; null while it is fresh. */
105
+ export function readingAge(account: Subscription, now: number): number | null {
106
+ const at = time(account.readAt);
107
+ if (at === null) return null;
108
+ return now - at > STALE_AFTER_MS ? now - at : null;
109
+ }
110
+
111
+ export type SubscriptionStateKind = "room" | "limit" | "aside" | "signed-out" | "unknown";
112
+
113
+ export interface SubscriptionState {
114
+ readonly kind: SubscriptionStateKind;
115
+ /** The chip: "Room", "At the limit until 3:12 PM", "Set aside", "Signed out", "No reading yet". */
116
+ readonly label: string;
117
+ readonly tone: Tone | "muted";
118
+ /** When an account at the limit is back, if known. */
119
+ readonly until: number | null;
120
+ /** The reading is older than ZeroSub refreshes an account in use. */
121
+ readonly staleMs: number | null;
122
+ }
123
+
124
+ /** How close ZeroSub's recorded limit and a window's reset must be to be the same moment. */
125
+ const SAME_MOMENT_MS = 20 * MINUTE;
126
+
127
+ /** The window ZeroSub's recorded limit is about: the one resetting when the limit lifts. */
128
+ export function limitedWindow(account: Subscription, now: number): SubscriptionWindow | null {
129
+ const limited = time(account.limitedUntil);
130
+ if (limited === null || limited <= now) return null;
131
+ return account.windows.find((w) => time(w.resetsAt) !== null && Math.abs(time(w.resetsAt)! - limited) <= SAME_MOMENT_MS) ?? null;
132
+ }
133
+
134
+ /**
135
+ * When an account at the limit is back: the reset of its full window. ZeroSub's own
136
+ * limit time stands only when no window explains it (a budget limit, or a later one).
137
+ */
138
+ export function limitEnd(account: Subscription, now: number): number | null {
139
+ if (account.outUntil !== undefined) { const out = time(account.outUntil); return out !== null && out > now ? out : null; }
140
+ const limited = time(account.limitedUntil);
141
+ const full = fullUntil(account, now);
142
+ const window = limitedWindow(account, now);
143
+ const zs = limited !== null && limited > now ? (window ? time(window.resetsAt)! : limited) : 0;
144
+ return Math.max(zs, full ?? 0) || null;
145
+ }
146
+
147
+ /** When a full window lets go: the soonest future reset among the windows at 100%. */
148
+ function fullUntil(account: Subscription, now: number): number | null | undefined {
149
+ const full = account.windows.filter((w) => w.pct >= 99.5 && !(time(w.resetsAt) !== null && time(w.resetsAt)! <= now));
150
+ if (full.length === 0) return undefined;
151
+ // Full until every full window has reset: the latest of their resets.
152
+ const resets = full.map((w) => time(w.resetsAt));
153
+ return resets.some((r) => r === null) ? null : Math.max(...(resets as number[]));
154
+ }
155
+
156
+ export function subscriptionState(account: Subscription, now: number, hour12?: boolean): SubscriptionState {
157
+ const staleMs = readingAge(account, now);
158
+ const at = (ms: number) => formatMoment(new Date(ms).toISOString(), now, hour12);
159
+ if (account.disabled) return { kind: "aside", label: "Set aside", tone: "muted", until: null, staleMs };
160
+ if (!account.signedIn) return { kind: "signed-out", label: "Signed out", tone: "muted", until: null, staleMs };
161
+ const limited = time(account.limitedUntil);
162
+ const full = fullUntil(account, now);
163
+ const out = account.outUntil === undefined ? (limited !== null && limited > now) || full !== undefined : limitEnd(account, now) !== null;
164
+ if (out) {
165
+ const until = limitEnd(account, now);
166
+ return { kind: "limit", label: until === null ? "At the limit" : `At the limit until ${at(until)}`, tone: "danger", until, staleMs };
167
+ }
168
+ if (account.readAt === null || account.windows.length === 0) return { kind: "unknown", label: "No reading yet", tone: "muted", until: null, staleMs };
169
+ return { kind: "room", label: "Room", tone: "default", until: null, staleMs };
170
+ }
171
+
172
+ /** The share of an account's tightest window still free, 0–100, as ZeroSub ranks room. */
173
+ export function roomLeft(account: Subscription, now: number): number | null {
174
+ if (account.windows.length === 0) return null;
175
+ const live = account.windows.map((w) => (time(w.resetsAt) !== null && time(w.resetsAt)! <= now ? 0 : w.pct));
176
+ return Math.max(0, 100 - Math.max(...live));
177
+ }
178
+
179
+ // ---------------------------------------------------------------- the pool
180
+
181
+ export interface Takeover {
182
+ /** Where the work was. */
183
+ readonly from: string;
184
+ readonly accountId: string;
185
+ readonly at: number;
186
+ }
187
+
188
+ export interface PoolForecast {
189
+ readonly family: Family;
190
+ readonly total: number;
191
+ /** Accounts signed in and not set aside: the ones the pool can use at all. */
192
+ readonly usable: number;
193
+ /** Accounts that could take an agent right now. */
194
+ readonly withRoom: number;
195
+ /** The pool's measured pace in session-window points per hour; null when nothing is in use. */
196
+ readonly pace: number | null;
197
+ /** The account the work is on now, in the simulation. */
198
+ readonly current: string | null;
199
+ /** Accounts the work moves to before the pool runs dry, in order. */
200
+ readonly takeovers: readonly Takeover[];
201
+ /** When no account has room at this pace (now, when it already happened); null when it lasts. */
202
+ readonly runsOutAt: number | null;
203
+ /** After it runs dry: when the first account is back, and which. */
204
+ readonly backAt: number | null;
205
+ readonly backAccount: string | null;
206
+ /** How far the forecast looked. */
207
+ readonly horizon: number;
208
+ /** ZeroSub's automatic switching; off, the work stays where it is. */
209
+ readonly autoSwitch: boolean;
210
+ /** Some account's numbers are older than ZeroSub's refresh: the forecast is rough. */
211
+ readonly stale: number;
212
+ /** Each account's state over the forecast, as runs: in use, room, at the limit, out. */
213
+ readonly lanes: Readonly<Record<string, readonly LaneSegment[]>>;
214
+ }
215
+
216
+ export type LaneKind = "active" | "room" | "limit" | "out";
217
+ export interface LaneSegment { from: number; to: number; kind: LaneKind }
218
+
219
+ interface SimAccount {
220
+ readonly a: Subscription;
221
+ readonly usable: boolean;
222
+ limitedUntil: number | null;
223
+ p5: number;
224
+ /** The session window's reset; null while the window has not started. */
225
+ r5: number | null;
226
+ weeklyPct: number;
227
+ /** When the week is full (or runs out at its own pace) until `wReset`. */
228
+ weeklyFullAt: number | null;
229
+ wReset: number | null;
230
+ rank: number;
231
+ }
232
+
233
+ function simAccount(a: Subscription, rank: number, now: number): SimAccount {
234
+ const s = sessionWindow(a);
235
+ const w = weeklyWindow(a);
236
+ let r5 = time(s?.resetsAt);
237
+ let p5 = s?.pct ?? 0;
238
+ // A window past its reset has rolled over; the next one starts at the next use.
239
+ if (r5 !== null && r5 <= now) { p5 = 0; r5 = null; }
240
+ // A reading at 99.5% is the provider's "full" (as in forecast.ts).
241
+ if (p5 >= 99.5) p5 = 100;
242
+ let wReset = time(w?.resetsAt);
243
+ let weeklyPct = w?.pct ?? 0;
244
+ let weeklyFullAt: number | null = null;
245
+ if (wReset !== null && wReset <= now) { weeklyPct = 0; wReset += Math.ceil((now - wReset) / WEEK) * WEEK; }
246
+ else if (w) {
247
+ const f = forecastWindow({ ...w, pct: weeklyPct }, now);
248
+ weeklyFullAt = f.state === "full" ? now : f.state === "short" && f.runsOutAt ? Date.parse(f.runsOutAt) : null;
249
+ }
250
+ // Another cap at 100% (a per-model week) holds the account until it resets.
251
+ const other = a.windows.filter((x) => x.id !== "five_hour" && x.id !== "seven_day" && x.pct >= 99.5 && (time(x.resetsAt) ?? Infinity) > now);
252
+ const otherUntil = other.length === 0 ? null : Math.max(...other.map((x) => time(x.resetsAt) ?? now + POOL_HORIZON_MS * 2));
253
+ const limited = time(a.limitedUntil);
254
+ return {
255
+ a, rank, p5, r5, weeklyPct, weeklyFullAt, wReset,
256
+ usable: a.signedIn && !a.disabled,
257
+ // The guard's verdict when the server sends it; else ZeroSub's limit and any other full cap.
258
+ limitedUntil: a.outUntil !== undefined ? limitEnd(a, now) : Math.max(limited !== null && limited > now ? limited : 0, otherUntil ?? 0) || null,
259
+ };
260
+ }
261
+
262
+ const available = (s: SimAccount, t: number) =>
263
+ s.usable && !(s.limitedUntil !== null && t < s.limitedUntil) && s.p5 < 100 &&
264
+ !(s.weeklyFullAt !== null && t >= s.weeklyFullAt && (s.wReset === null || t < s.wReset));
265
+
266
+ /** ZeroSub's pick: the least-used usable account; then the default, the CLI login, the oldest. */
267
+ function mostRoom(sims: readonly SimAccount[], t: number, exclude?: SimAccount): SimAccount | null {
268
+ const candidates = sims.filter((s) => s !== exclude && available(s, t));
269
+ candidates.sort((x, y) => Math.max(x.p5, x.weeklyPct) - Math.max(y.p5, y.weeklyPct) || x.rank - y.rank);
270
+ return candidates[0] ?? null;
271
+ }
272
+
273
+ /**
274
+ * How long one family's subscriptions last together at this pace.
275
+ *
276
+ * Each account in use burns at its own measured pace (session-window points per
277
+ * hour), and that work stays on it until a window there is full or the week runs
278
+ * out at its own pace. Then ZeroSub moves it to the usable account with the most
279
+ * room, where it adds to whatever already runs there. Resets refill a window, and a
280
+ * session window starts again at the next use. Moved work is assumed to cost the
281
+ * same share of the new account's window; plans of different sizes make that rough.
282
+ * "Runs out" is the first moment some work has nowhere to go.
283
+ *
284
+ * A reading older than ZeroSub's refresh adds no pace and is never extrapolated. Its
285
+ * last numbers stand until its reset, and the forecast counts it as stale.
286
+ */
287
+ export function poolForecast(
288
+ accounts: readonly Subscription[],
289
+ family: Family,
290
+ now: number,
291
+ options: { autoSwitch: boolean; horizonMs?: number; stepMs?: number } = { autoSwitch: true },
292
+ ): PoolForecast {
293
+ const mine = accounts.filter((a) => a.family === family);
294
+ const horizon = now + (options.horizonMs ?? POOL_HORIZON_MS);
295
+ const step = options.stepMs ?? MINUTE;
296
+ const order = [...mine].sort((x, y) =>
297
+ Number(y.isDefault) - Number(x.isDefault) || Number(y.kind === "main") - Number(x.kind === "main") || (time(x.createdAt) ?? 0) - (time(y.createdAt) ?? 0));
298
+ const sims = order.map((a, rank) => simAccount(a, rank, now));
299
+ const stale = mine.filter((a) => readingAge(a, now) !== null).length;
300
+ const withRoom = sims.filter((s) => available(s, now)).length;
301
+
302
+ // The work in use now: what each fresh account burns, as its own stream.
303
+ const streams = sims.flatMap((s) => {
304
+ const rate = readingAge(s.a, now) === null ? sessionWindow(s.a)?.burnPctPerHour ?? null : null;
305
+ return rate !== null && rate >= IDLE_RATE ? [{ on: s, rate, stalled: false }] : [];
306
+ }).sort((x, y) => y.rate - x.rate);
307
+ const pace = streams.reduce((sum, w) => sum + w.rate, 0);
308
+ const base = { family, total: mine.length, usable: sims.filter((s) => s.usable).length, withRoom, autoSwitch: options.autoSwitch, horizon, stale };
309
+
310
+ const takeovers: Takeover[] = [];
311
+ let runsOutAt: number | null = null;
312
+ let backAt: number | null = null;
313
+ let backAccount: string | null = null;
314
+
315
+ // Each account's lane over the forecast, for the runway chart.
316
+ const lanes: Record<string, LaneSegment[]> = Object.fromEntries(sims.map((s) => [s.a.id, []]));
317
+ const mark = (s: SimAccount, t: number, kind: LaneKind) => {
318
+ const lane = lanes[s.a.id]!;
319
+ const last = lane[lane.length - 1];
320
+ if (last && last.kind === kind && last.to === t) last.to = t + step;
321
+ else lane.push({ from: t, to: t + step, kind });
322
+ };
323
+
324
+ for (let t = now; t < horizon; t += step) {
325
+ for (const s of sims) {
326
+ if (s.r5 !== null && t >= s.r5) { s.p5 = 0; s.r5 = null; }
327
+ if (s.wReset !== null && t >= s.wReset) { s.weeklyPct = 0; s.weeklyFullAt = null; s.wReset += WEEK; }
328
+ }
329
+ // Each account's work stays on it until it is full; then ZeroSub moves it to the
330
+ // account with the most room. With nowhere to go it stops until one is back.
331
+ for (const work of streams) {
332
+ if (available(work.on, t)) {
333
+ if (work.stalled) { work.stalled = false; if (backAt === null) { backAt = t; backAccount = work.on.a.id; } }
334
+ continue;
335
+ }
336
+ const next = options.autoSwitch ? mostRoom(sims, t) : null;
337
+ if (next === null) {
338
+ if (!work.stalled) { work.stalled = true; if (runsOutAt === null) runsOutAt = t; }
339
+ continue;
340
+ }
341
+ if (work.stalled) { if (backAt === null) { backAt = t; backAccount = next.a.id; } }
342
+ else if (runsOutAt === null && t > now) takeovers.push({ from: work.on.a.id, accountId: next.a.id, at: t });
343
+ work.on = next;
344
+ work.stalled = false;
345
+ }
346
+ // Nothing in use: only the limits now and their resets.
347
+ if (streams.length === 0) {
348
+ const open = mostRoom(sims, t);
349
+ if (open === null && t === now) runsOutAt = now;
350
+ else if (open !== null && runsOutAt !== null && backAt === null) { backAt = t; backAccount = open.a.id; }
351
+ }
352
+ for (const s of sims) {
353
+ const busy = streams.some((w) => w.on === s && !w.stalled);
354
+ mark(s, t, !s.usable ? "out" : !available(s, t) ? "limit" : busy ? "active" : "room");
355
+ }
356
+ for (const work of streams) {
357
+ if (work.stalled) continue;
358
+ if (work.on.r5 === null) work.on.r5 = t + FIVE_HOURS;
359
+ work.on.p5 += (work.rate * step) / HOUR;
360
+ }
361
+ }
362
+
363
+ // The account carrying the most work now, else the one ZeroSub would open next.
364
+ const lead = streams.length > 0 ? lanes[streams[0]!.on.a.id] : null;
365
+ const first = Object.entries(lanes).find(([, lane]) => lane[0]?.kind === "active");
366
+ const opener = mostRoom(sims.map((s) => simAccount(s.a, s.rank, now)), now);
367
+ return {
368
+ ...base,
369
+ pace: pace > 0 ? pace : null,
370
+ current: lead === null ? opener?.a.id ?? null : first?.[0] ?? null,
371
+ takeovers,
372
+ runsOutAt,
373
+ backAt,
374
+ backAccount,
375
+ lanes,
376
+ };
377
+ }
378
+
379
+ // ---------------------------------------------------------------- words
380
+
381
+ export interface PoolHeadline {
382
+ readonly title: string;
383
+ readonly detail: string;
384
+ readonly tone: Tone;
385
+ }
386
+
387
+ const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
388
+
389
+ /** The overview's lead and a family's group header, in one sentence and a line under it. */
390
+ export function poolHeadline(pool: PoolForecast, accounts: readonly Subscription[], now: number, hour12?: boolean): PoolHeadline {
391
+ const fam = familyName(pool.family);
392
+ const name = (id: string | null) => accounts.find((a) => a.id === id)?.name ?? "another account";
393
+ const at = (ms: number) => formatMoment(new Date(ms).toISOString(), now, hour12);
394
+ const subs = `${pool.total} ${plural(pool.total, "subscription", "subscriptions")}`;
395
+ // What lasts is what can be used: set-aside and signed-out accounts are not counted in.
396
+ const usable = `${pool.usable} ${plural(pool.usable, "subscription", "subscriptions")}`;
397
+ const roughly = pool.stale > 0 ? `${pool.stale} ${plural(pool.stale, "reading is", "readings are")} over 30 min old` : null;
398
+ const join = (...parts: (string | null)[]) => parts.filter(Boolean).join(" · ");
399
+
400
+ if (pool.runsOutAt !== null && pool.runsOutAt <= now) {
401
+ const title = pool.total === 1 ? `${fam} is at the limit` : pool.total === 2 ? `Both ${fam} subscriptions are at the limit` : `All ${pool.total} ${fam} subscriptions are at the limit`;
402
+ const back = pool.backAt !== null ? `${pool.total === 1 ? "Back" : "First one back"} at ${at(pool.backAt)}${pool.total === 1 ? "" : ` · ${name(pool.backAccount)}`} · in ${formatDuration(pool.backAt - now)}`
403
+ : "No reset reported yet";
404
+ return { title, detail: join(back, roughly), tone: "danger" };
405
+ }
406
+ const moves = pool.takeovers.slice(0, 2).map((m, i) => `${i === 0 ? "" : "then "}${name(m.accountId)} ${i === 0 ? "takes over" : ""} at ${at(m.at)}`.replace(/ {2}/g, " "));
407
+ if (pool.runsOutAt !== null) {
408
+ const soon = pool.runsOutAt - now <= 30 * MINUTE;
409
+ const lead = pool.usable <= 1 ? `${fam} runs out around ${at(pool.runsOutAt)}` : `${fam} · ${usable} last until about ${at(pool.runsOutAt)}`;
410
+ const back = pool.backAt !== null ? `first one back at ${at(pool.backAt)}` : null;
411
+ const off = !pool.autoSwitch && pool.total > 1 ? "automatic switching is off in ZeroSub" : null;
412
+ return { title: lead, detail: join(...moves, off, back, `${rounded(pool.pace ?? 0)}%/h combined`, roughly), tone: soon ? "danger" : "warning" };
413
+ }
414
+ if (pool.pace !== null) {
415
+ const through = `past ${at(pool.horizon)}`;
416
+ return {
417
+ title: pool.usable <= 1 ? `${fam} lasts ${through}` : `${fam} · ${usable} last ${through}`,
418
+ detail: join(moves.length > 0 ? moves.join(", ") : "No switch needed", `${rounded(pool.pace)}%/h combined`, roughly),
419
+ tone: "default",
420
+ };
421
+ }
422
+ return {
423
+ title: `${fam} · ${pool.withRoom} of ${subs} with room`,
424
+ detail: join("Nothing used in the last half hour", roughly),
425
+ tone: pool.withRoom === 0 ? "danger" : "default",
426
+ };
427
+ }
428
+
429
+ const rounded = (value: number) => (value >= 10 ? Math.round(value) : Math.round(value * 10) / 10);
430
+
431
+ /** The sidebar row's right slot for the pool: a time when it runs short, else how many have room. */
432
+ export function poolTrailing(pool: PoolForecast, now: number, hour12?: boolean): { label: string; tone: Tone | "muted" } {
433
+ if (pool.runsOutAt !== null && pool.runsOutAt <= now) {
434
+ return { label: pool.backAt !== null ? `back ${formatMoment(new Date(pool.backAt).toISOString(), now, hour12)}` : "all at limit", tone: "danger" };
435
+ }
436
+ if (pool.runsOutAt !== null) {
437
+ return { label: `out ${formatMoment(new Date(pool.runsOutAt).toISOString(), now, hour12)}`, tone: pool.runsOutAt - now <= 30 * MINUTE ? "danger" : "warning" };
438
+ }
439
+ return { label: `${pool.withRoom} of ${pool.total}`, tone: "muted" };
440
+ }
441
+
442
+ /** Which family the page leads with: the one in the most trouble, Claude on a tie. */
443
+ export function leadPool(pools: readonly PoolForecast[], now: number): PoolForecast | null {
444
+ const weight = (p: PoolForecast) => (p.runsOutAt !== null && p.runsOutAt <= now ? 3 : p.runsOutAt !== null ? 2 : p.pace !== null ? 1 : 0);
445
+ return [...pools].filter((p) => p.total > 0).sort((a, b) => weight(b) - weight(a) || (a.family === "claude" ? -1 : 1))[0] ?? null;
446
+ }
447
+
448
+ /**
449
+ * One account's outlook inside the pool: full now, runs out at a moment (with the
450
+ * pooled work that moves onto it counted), or lasts to its reset. Read from the
451
+ * simulation, so a row, the lead and the popover never disagree about a time.
452
+ */
453
+ export type AccountOutlook = { kind: "full" } | { kind: "runs-out"; at: number } | { kind: "lasts"; reset: number | null } | { kind: "idle" };
454
+
455
+ export function accountOutlook(pool: PoolForecast, account: Subscription, now: number): AccountOutlook {
456
+ const lane = pool.lanes[account.id] ?? [];
457
+ if (lane[0]?.kind === "limit") return { kind: "full" };
458
+ const reset = time(sessionWindow(account)?.resetsAt);
459
+ const until = reset !== null && reset > now ? reset : pool.horizon;
460
+ const active = lane.find((run) => run.kind === "active" && run.from < until);
461
+ if (!active) return { kind: "idle" };
462
+ const out = lane.find((run) => run.kind === "limit" && run.from > now && run.from <= until);
463
+ return out ? { kind: "runs-out", at: out.from } : { kind: "lasts", reset: reset !== null && reset > now ? reset : null };
464
+ }
465
+
466
+ /**
467
+ * What happens when this session's account hits the limit, for the pill's popover:
468
+ * when it runs out at this pace and where ZeroSub moves the work then, or that
469
+ * nothing has room and the pause (when on) holds it until its own reset.
470
+ */
471
+ function limitSentence(
472
+ accounts: readonly Subscription[],
473
+ accountId: string,
474
+ now: number,
475
+ options: { autoSwitch: boolean; protectedHere: boolean; hour12?: boolean },
476
+ ): { text: string; tone: Tone; target: string | null } {
477
+ const own = accounts.find((a) => a.id === accountId);
478
+ if (!own) return { text: "", tone: "default", target: null };
479
+ const mine = accounts.filter((a) => a.family === own.family);
480
+ const others = mine.filter((a) => a.id !== own.id);
481
+ const pool = poolForecast(mine, own.family, now, { autoSwitch: options.autoSwitch });
482
+ const at = (ms: number) => formatMoment(new Date(ms).toISOString(), now, options.hour12);
483
+ const name = (id: string | null) => mine.find((a) => a.id === id)?.name ?? "another account";
484
+ const outlook = accountOutlook(pool, own, now);
485
+ const ownReset = (() => {
486
+ const state = subscriptionState(own, now, options.hour12);
487
+ if (state.kind === "limit") return state.until;
488
+ const reset = time(sessionWindow(own)?.resetsAt);
489
+ return reset !== null && reset > now ? reset : null;
490
+ })();
491
+ const pause = `Smart Session pauses it until ${ownReset === null ? "its reset" : `its ${at(ownReset)} reset`}`;
492
+ const lead = others.length === 0 ? "No other account" : !options.autoSwitch ? "Automatic switching is off" : "No other account has room";
493
+ // Where the work goes, and how long that account then lasts.
494
+ const lasting = (id: string) => {
495
+ const target = mine.find((a) => a.id === id)!;
496
+ const after = accountOutlook(pool, target, now);
497
+ return after.kind === "runs-out" ? `, which lasts until about ${at(after.at)}` : "";
498
+ };
499
+
500
+ if (outlook.kind === "full") {
501
+ const sims = [own, ...others].map((a, i) => simAccount(a, a.isDefault ? -1 : a.kind === "main" ? 0 : i + 1, now));
502
+ const target = options.autoSwitch ? mostRoom(sims, now, sims[0]) : null;
503
+ if (target) return { text: `At the limit · ZeroSub moves it to ${target.a.name}${lasting(target.a.id)}`, tone: "default", target: target.a.id };
504
+ const back = pool.backAt !== null && pool.backAccount !== own.id ? ` until ${at(pool.backAt)} (${name(pool.backAccount)})` : ownReset !== null ? ` until ${at(ownReset)}` : "";
505
+ return { text: options.protectedHere ? `${lead} · ${pause}` : `${lead} · it waits${back}`, tone: others.length > 0 ? "warning" : "default", target: null };
506
+ }
507
+ if (outlook.kind === "runs-out") {
508
+ const move = pool.takeovers.find((m) => m.from === own.id && Math.abs(m.at - outlook.at) <= 2 * MINUTE);
509
+ if (move) return { text: `Runs out ~${at(outlook.at)} · ZeroSub moves it to ${name(move.accountId)}${lasting(move.accountId)}`, tone: "warning", target: move.accountId };
510
+ const back = pool.backAt !== null ? ` until ${at(pool.backAt)}${pool.backAccount !== own.id ? ` (${name(pool.backAccount)})` : ""}` : "";
511
+ return { text: options.protectedHere ? `Runs out ~${at(outlook.at)} · ${lead.toLowerCase()}, so ${pause}` : `Runs out ~${at(outlook.at)} · ${lead.toLowerCase()}${back}`, tone: "warning", target: null };
512
+ }
513
+ if (outlook.kind === "lasts") return { text: outlook.reset === null ? "Lasts at this pace" : `Lasts to its ${at(outlook.reset)} reset at this pace`, tone: "default", target: null };
514
+ // Not in use: what would happen if it filled.
515
+ const sims = [own, ...others].map((a, i) => simAccount(a, a.isDefault ? -1 : a.kind === "main" ? 0 : i + 1, now));
516
+ const target = options.autoSwitch ? mostRoom(sims, now, sims[0]) : null;
517
+ if (target) return { text: `If it fills, ZeroSub moves it to ${target.a.name}`, tone: "default", target: target.a.id };
518
+ return { text: options.protectedHere ? `If it fills: ${lead.toLowerCase()}, so ${pause}` : `If it fills: ${lead.toLowerCase()}`, tone: "default", target: null };
519
+ }
520
+
521
+ /**
522
+ * What happens when this session's account hits the limit, for the pill's popover,
523
+ * with a word on old readings: a forecast over numbers ZeroSub has not refreshed for
524
+ * half an hour says so rather than reading as current.
525
+ */
526
+ export function atTheLimit(
527
+ accounts: readonly Subscription[],
528
+ accountId: string,
529
+ now: number,
530
+ options: { autoSwitch: boolean; protectedHere: boolean; hour12?: boolean },
531
+ ): { text: string; tone: Tone; target: string | null } {
532
+ const said = limitSentence(accounts, accountId, now, options);
533
+ const own = accounts.find((a) => a.id === accountId);
534
+ const target = accounts.find((a) => a.id === said.target);
535
+ const ownAge = own ? readingAge(own, now) : null;
536
+ const targetAge = target ? readingAge(target, now) : null;
537
+ if (ownAge !== null) return { ...said, text: `${said.text.replace(/ at this pace$/, "")} · this reading is ${formatDuration(ownAge)} old` };
538
+ if (targetAge !== null) return { ...said, text: `${said.text} · its reading is ${formatDuration(targetAge)} old` };
539
+ return said;
540
+ }