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.
package/server/store.ts CHANGED
@@ -73,7 +73,18 @@ export interface ContextSample {
73
73
  readonly title: string | null;
74
74
  }
75
75
 
76
- type LogKind = "usage" | "context";
76
+ /**
77
+ * One ZeroSub account's reading, as ZeroSub took it (`usage.json`), kept so each
78
+ * account gets a pace and a forecast. Shaped like a usage sample, so the same block
79
+ * logic reads it, plus the account it belongs to: ZeroSub's id, never an email.
80
+ */
81
+ export interface AccountSample extends Omit<UsageSample, "src"> {
82
+ readonly src: "zerosub";
83
+ readonly accountId: string;
84
+ readonly family: "claude" | "codex";
85
+ }
86
+
87
+ type LogKind = "usage" | "context" | "accounts";
77
88
 
78
89
  /** Monthly files: small enough to read whole, coarse enough to stay few. */
79
90
  function logPath(kind: LogKind, at: string): string {
@@ -113,6 +124,10 @@ export async function appendContext(sample: ContextSample): Promise<void> {
113
124
  await append("context", sample.at, sample);
114
125
  }
115
126
 
127
+ export async function appendAccount(sample: AccountSample): Promise<void> {
128
+ await append("accounts", sample.at, sample);
129
+ }
130
+
116
131
  /**
117
132
  * Reads a log back.
118
133
  *
@@ -169,6 +184,14 @@ export async function readUsage(options: { sinceMs?: number } = {}): Promise<Usa
169
184
  return since === null ? rows : rows.filter((row) => Date.parse(row.at) >= since);
170
185
  }
171
186
 
187
+ export async function readAccounts(options: { sinceMs?: number } = {}): Promise<AccountSample[]> {
188
+ const since = options.sinceMs ?? null;
189
+ const rows = await readLog<AccountSample>("accounts", since);
190
+ // Defensive: a row from a future shape, or a hand-edited one, is skipped, not thrown on.
191
+ return rows.filter((row) => typeof row?.accountId === "string" && typeof row.at === "string" && typeof row.windows === "object" && row.windows !== null
192
+ && (since === null || Date.parse(row.at) >= since));
193
+ }
194
+
172
195
  export async function readContext(options: { sinceMs?: number } = {}): Promise<ContextSample[]> {
173
196
  const since = options.sinceMs ?? null;
174
197
  const rows = await readLog<ContextSample>("context", since);
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Every ZeroSub subscription's history and status, read-only.
3
+ *
4
+ * The recorder already keeps the CLI login's plan usage (`usage-*.jsonl`). Under
5
+ * ZeroSub the other accounts' readings exist only as the latest copy in ZeroSub's own
6
+ * `usage.json`, overwritten in place. So each new one is kept here in
7
+ * `accounts-*.jsonl`, keyed by ZeroSub's account id (never an email), which gives
8
+ * every account a pace and a forecast. ZeroSub's files are only read: nothing here
9
+ * writes them, opens an account home, or asks ZeroSub for a refresh, a switch or a
10
+ * reset.
11
+ */
12
+
13
+ import { readAgents, withDaemon } from "./daemon.ts";
14
+ import { readZeroSub } from "./guard/zerosub.ts";
15
+ import { appendAccount, readAccounts, readUsage, type AccountSample } from "./store.ts";
16
+ import { burnRatePctPerHour, segmentBlocks, thinPoints } from "../shared/blocks.ts";
17
+ import { accountOf, outUntil, type ZeroSubAccount, type ZeroSubReading, type ZeroSubSnapshot, type ZeroSubWindow } from "../shared/guard/zerosub.ts";
18
+ import { STALE_AFTER_MS, poolForecast, subscriptionName, type Subscription, type SubscriptionWindow } from "../shared/subscriptions.ts";
19
+ import { canonicalWindowId, effectiveAt, type UsageSample } from "../shared/usage.ts";
20
+
21
+ const DAY = 24 * 60 * 60_000;
22
+ /** Enough history for an open week and its pace. */
23
+ const HISTORY_MS = 8 * DAY;
24
+ /** The CLI's own Claude login: the recorder measures it, and more often than ZeroSub. */
25
+ const RECORDED_ACCOUNT = "claude-main";
26
+
27
+ /**
28
+ * A ZeroSub window under Smart Session's ids: `five_hour` and `seven_day` for both
29
+ * families (Codex's are told apart by length), so one set of rules reads both.
30
+ */
31
+ export function canonicalAccountWindow(family: "claude" | "codex", window: ZeroSubWindow): string {
32
+ if (family === "claude") return canonicalWindowId(window.id);
33
+ if (window.minutes !== null) return window.minutes <= 6 * 60 ? "five_hour" : window.minutes >= 6 * DAY / 60_000 ? "seven_day" : window.id;
34
+ return window.id === "primary" ? "five_hour" : window.id === "secondary" ? "seven_day" : window.id;
35
+ }
36
+
37
+ /** ZeroSub's reading as an account sample, with the window ids Smart Session uses. */
38
+ export function accountSample(account: ZeroSubAccount, reading: ZeroSubReading, at: string): AccountSample {
39
+ const windows: Record<string, { pct: number; resetsAt: string | null }> = {};
40
+ for (const window of reading.windows) windows[canonicalAccountWindow(account.family, window)] = { pct: window.usedPct, resetsAt: window.resetsAt };
41
+ return { at, fetchedAt: reading.fetchedAt, src: "zerosub", account: null, accountId: account.id, family: account.family, windows, credits: null };
42
+ }
43
+
44
+ /** The newest reading already kept per account; filled from the log on first use. */
45
+ let lastKept: Map<string, string> | null = null;
46
+
47
+ /**
48
+ * Keeps every new ZeroSub reading. Runs on the recorder's tick; costs three small file
49
+ * reads when nothing changed and nothing at all without ZeroSub.
50
+ */
51
+ export async function recordAccounts(now = Date.now()): Promise<number> {
52
+ const snapshot = await readZeroSub();
53
+ if (snapshot === null) return 0;
54
+ if (lastKept === null) {
55
+ lastKept = new Map();
56
+ for (const row of await readAccounts({ sinceMs: now - HISTORY_MS })) {
57
+ const previous = lastKept.get(row.accountId);
58
+ if (row.fetchedAt && (!previous || Date.parse(row.fetchedAt) > Date.parse(previous))) lastKept.set(row.accountId, row.fetchedAt);
59
+ }
60
+ }
61
+ let kept = 0;
62
+ for (const account of snapshot.accounts) {
63
+ if (account.id === RECORDED_ACCOUNT) continue;
64
+ const reading = snapshot.readings[account.id];
65
+ if (!reading || reading.windows.length === 0) continue;
66
+ const previous = lastKept.get(account.id);
67
+ if (previous && Date.parse(reading.fetchedAt) <= Date.parse(previous)) continue;
68
+ await appendAccount(accountSample(account, reading, new Date(now).toISOString()));
69
+ lastKept.set(account.id, reading.fetchedAt);
70
+ kept += 1;
71
+ }
72
+ return kept;
73
+ }
74
+
75
+ export function forgetAccounts(): void {
76
+ lastKept = null;
77
+ cached = null;
78
+ }
79
+
80
+ /** One account's windows from its history, with the live reading as the newest point. */
81
+ export function windowsFrom(samples: readonly UsageSample[], readAt: string | null, now: number): { windows: SubscriptionWindow[]; history: { t: string; pct: number }[] } {
82
+ const open = new Map<string, ReturnType<typeof segmentBlocks>[number]>();
83
+ for (const block of segmentBlocks(samples)) open.set(block.windowId, block);
84
+ const fresh = readAt !== null && now - Date.parse(readAt) <= STALE_AFTER_MS;
85
+ const windows = [...open.values()].map((block) => ({
86
+ id: block.windowId,
87
+ pct: block.finalPct,
88
+ resetsAt: block.resetsAt,
89
+ // A rate measured hours ago is not the pace now.
90
+ burnPctPerHour: fresh ? burnRatePctPerHour(block) : null,
91
+ }));
92
+ const session = open.get("five_hour");
93
+ return { windows, history: session ? thinPoints(session.points, 48) : [] };
94
+ }
95
+
96
+ export interface SubscriptionsView {
97
+ readonly zerosub: boolean;
98
+ readonly autoSwitch: boolean;
99
+ readonly accounts: Subscription[];
100
+ /** Which account each live agent runs on. */
101
+ readonly agents: Record<string, string>;
102
+ }
103
+
104
+ const NONE: SubscriptionsView = { zerosub: false, autoSwitch: false, accounts: [], agents: {} };
105
+
106
+ export function buildView(
107
+ snapshot: ZeroSubSnapshot,
108
+ input: { recorded: readonly UsageSample[]; accountRows: readonly AccountSample[]; liveAgents: readonly { id: string; provider: string }[] },
109
+ now: number,
110
+ ): SubscriptionsView {
111
+ const agents: Record<string, string> = {};
112
+ const counts = new Map<string, number>();
113
+ for (const agent of input.liveAgents) {
114
+ const account = accountOf(snapshot, agent.id, agent.provider);
115
+ if (!account) continue;
116
+ agents[agent.id] = account.id;
117
+ counts.set(account.id, (counts.get(account.id) ?? 0) + 1);
118
+ }
119
+ const defaults = (family: "claude" | "codex") => {
120
+ const chosen = snapshot.defaults[family];
121
+ const ofFamily = snapshot.accounts.filter((a) => a.family === family);
122
+ return ofFamily.find((a) => a.id === chosen)?.id ?? ofFamily.find((a) => a.kind === "main")?.id ?? ofFamily[0]?.id ?? null;
123
+ };
124
+
125
+ const accounts = snapshot.accounts.map((account): Subscription => {
126
+ const reading = snapshot.readings[account.id];
127
+ const own = input.accountRows.filter((row) => row.accountId === account.id) as unknown as UsageSample[];
128
+ const live = reading ? [accountSample(account, reading, new Date(now).toISOString()) as unknown as UsageSample] : [];
129
+ // The CLI login's windows come from the recorder when it has them: fresher, and with history.
130
+ const recorded = account.id === RECORDED_ACCOUNT && input.recorded.length > 0;
131
+ const samples = recorded ? input.recorded : [...own, ...live];
132
+ const readAt = recorded ? effectiveAt(input.recorded[input.recorded.length - 1]!) : reading?.fetchedAt ?? null;
133
+ const { windows, history } = windowsFrom(samples, readAt, now);
134
+ const { name, named } = subscriptionName(account, snapshot.accounts);
135
+ return {
136
+ id: account.id,
137
+ family: account.family,
138
+ kind: account.kind,
139
+ name,
140
+ named,
141
+ isDefault: defaults(account.family) === account.id,
142
+ signedIn: account.signedIn,
143
+ disabled: account.disabled,
144
+ limitedUntil: account.limitedUntil,
145
+ outUntil: (() => {
146
+ // The guard's rule for "out", over the windows shown here.
147
+ const out = outUntil({ providerId: account.family, status: windows.length > 0 ? "available" : "unavailable", fetchedAt: readAt, windows: windows.map((w) => ({ id: w.id, usedPct: w.pct, remainingPct: null, resetsAt: w.resetsAt })) }, account, now);
148
+ return out === null ? null : new Date(out).toISOString();
149
+ })(),
150
+ createdAt: account.createdAt,
151
+ agents: counts.get(account.id) ?? 0,
152
+ readAt,
153
+ windows,
154
+ credits: reading?.credits ?? null,
155
+ history,
156
+ };
157
+ });
158
+ return { zerosub: true, autoSwitch: snapshot.autoSwitch, accounts, agents };
159
+ }
160
+
161
+ /** Several clients poll this; the answer is good for a few seconds. */
162
+ let cached: { at: number; view: SubscriptionsView } | null = null;
163
+ const CACHE_MS = 10_000;
164
+
165
+ export async function subscriptionsView(now = Date.now()): Promise<SubscriptionsView> {
166
+ if (cached && now - cached.at < CACHE_MS) return cached.view;
167
+ const snapshot = await readZeroSub();
168
+ if (snapshot === null) {
169
+ cached = { at: now, view: NONE };
170
+ return NONE;
171
+ }
172
+ const since = now - HISTORY_MS;
173
+ const [recorded, accountRows, liveAgents] = await Promise.all([
174
+ readUsage({ sinceMs: since }),
175
+ readAccounts({ sinceMs: since }),
176
+ withDaemon((client) => readAgents(client)).then((rows) => rows.filter((row) => row.status !== "closed")).catch(() => []),
177
+ ]);
178
+ const view = buildView(snapshot, { recorded, accountRows, liveAgents }, now);
179
+ cached = { at: now, view };
180
+ return view;
181
+ }
182
+
183
+ /**
184
+ * What `budget_status` adds for an agent under ZeroSub when its provider has several
185
+ * subscriptions: the pool, by name. Null without ZeroSub or with one account.
186
+ */
187
+ export async function budgetPool(agentId: string | undefined, now = Date.now()) {
188
+ const view = await subscriptionsView(now);
189
+ if (!view.zerosub) return null;
190
+ const own = agentId === undefined ? undefined : view.accounts.find((a) => a.id === view.agents[agentId]);
191
+ const family = own?.family ?? "claude";
192
+ const mine = view.accounts.filter((a) => a.family === family);
193
+ if (mine.length < 2) return null;
194
+ const pool = poolForecast(view.accounts, family, now, { autoSwitch: view.autoSwitch });
195
+ const name = (id: string | null) => mine.find((a) => a.id === id)?.name ?? "another account";
196
+ const at = (ms: number) => new Date(ms).toISOString();
197
+ const next = pool.takeovers[0];
198
+ return {
199
+ family,
200
+ accounts: pool.total,
201
+ withRoom: pool.withRoom,
202
+ pacePctPerHour: pool.pace === null ? null : Math.round(pool.pace * 10) / 10,
203
+ runsOutAt: pool.runsOutAt === null ? null : at(pool.runsOutAt),
204
+ next: next ? { name: name(next.accountId), at: at(next.at) } : null,
205
+ back: pool.backAt === null ? null : { name: name(pool.backAccount), at: at(pool.backAt) },
206
+ staleReadings: pool.stale,
207
+ };
208
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Whether a finished turn ended on the CLI's limit notice.
3
+ *
4
+ * Claude Code does not fail a turn at the limit: it completes it with a synthetic reply,
5
+ * "You've hit your 5-hour limit · resets 3pm (…)". Paseo reports that as an ordinary
6
+ * completed turn, so the guard's failed-turn path never sees it. ZeroSub reads the same
7
+ * reply to fail over (`server/limits.ts` in `@kapybara/zerosub` 1.5.0); when it finds no
8
+ * account with room it stops there, and nothing continues the agent after the reset
9
+ * unless Session Guard records a pause. These are ZeroSub's patterns, so the guard
10
+ * treats exactly the turns ZeroSub treats as limits as limits.
11
+ *
12
+ * Only the first line of each candidate counts, and only CLI-generated places are read:
13
+ * error rows, warning notifications, `[System Error]` replies and, for Claude, the
14
+ * final two replies. Prose earlier in a turn never matches.
15
+ */
16
+
17
+ export interface TurnRow {
18
+ readonly type: string;
19
+ readonly text?: string;
20
+ readonly message?: string;
21
+ readonly level?: string;
22
+ }
23
+
24
+ const END = String.raw`(?=\s*(?:·|\.|$))`;
25
+ const APOSTROPHE = "['’]";
26
+ const CLAUDE: readonly RegExp[] = [
27
+ new RegExp(`^You${APOSTROPHE}ve hit your (?:5-hour |session |weekly |Opus |Sonnet |Fable )?limit${END}`, "i"),
28
+ new RegExp(`^You${APOSTROPHE}ve reached your (?:Fable|Opus|Sonnet|Haiku)(?: [\\w.]{1,10})? limit${END}`),
29
+ /Claude AI usage limit reached\|\d{9,}/,
30
+ new RegExp(
31
+ `^You${APOSTROPHE}ve hit your (?:usage limit|usage credit limit|team${APOSTROPHE}s shared budget|(?:monthly|individual) spend limit|individual usage limit|(?:org|channel)${APOSTROPHE}s monthly (?:usage|spend) limit)${END}`,
32
+ "i",
33
+ ),
34
+ new RegExp(`^You${APOSTROPHE}re out of (?:usage credits|extra usage)${END}`, "i"),
35
+ /^Your org is out of usage\s*·/i,
36
+ ];
37
+ const CODEX: readonly RegExp[] = [
38
+ new RegExp(`^You${APOSTROPHE}ve hit your usage limit\\b`, "i"),
39
+ /^The usage limit has been reached\b/i,
40
+ /"type"\s*:\s*"usage_limit_reached"/,
41
+ /^Your workspace is out of credits\b/i,
42
+ /^You hit your spend cap\b/i,
43
+ ];
44
+
45
+ const SYSTEM_ERROR = /^\[System Error\]\s*/;
46
+ const MAX_NOTICE = 400;
47
+ const clean = (text: string) => text.replace(SYSTEM_ERROR, "").replace(/^API Error:\s*/i, "").trim();
48
+ const firstLine = (text: string) => (text.split("\n")[0] ?? text).trim();
49
+
50
+ /** The limit notice a finished turn ended on, given that turn's rows oldest first; null when none. */
51
+ export function limitNotice(provider: string, rows: readonly TurnRow[]): string | null {
52
+ const patterns = provider === "claude" ? CLAUDE : provider === "codex" ? CODEX : null;
53
+ if (!patterns) return null;
54
+ const found: string[] = [];
55
+ let replies = 0;
56
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
57
+ const row = rows[index]!;
58
+ if (row.type === "user_message") break;
59
+ if (row.type === "error" && row.message) found.push(row.message);
60
+ else if (row.type === "notification" && row.level !== "info" && row.message) found.push(row.message);
61
+ else if (row.type === "assistant_message" && typeof row.text === "string") {
62
+ if (SYSTEM_ERROR.test(row.text) || (provider === "claude" && replies < 2)) found.push(row.text);
63
+ replies += 1;
64
+ }
65
+ }
66
+ for (const text of found.map(clean)) {
67
+ if (text.length === 0 || text.length > MAX_NOTICE) continue;
68
+ const line = firstLine(text);
69
+ if (patterns.some((pattern) => pattern.test(provider === "claude" ? line : text))) return line;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * One turn's rows from a tail page of the agent's timeline, oldest first. Providers can
76
+ * reuse a turn id, so reading back from the turn's last row, another turn's prompt is
77
+ * where this one began (as ZeroSub's `turnRows`).
78
+ */
79
+ export function turnRows<T extends TurnRow>(entries: readonly { item: T; turnId?: string }[], turnId: string): T[] {
80
+ const rows: T[] = [];
81
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
82
+ const entry = entries[index]!;
83
+ if (entry.turnId === turnId) rows.push(entry.item);
84
+ else if (rows.length > 0 && entry.turnId && entry.item.type === "user_message") break;
85
+ }
86
+ return rows.reverse();
87
+ }
@@ -30,6 +30,8 @@ export interface ZeroSubAccount {
30
30
  readonly disabled: boolean;
31
31
  readonly limitedUntil: string | null;
32
32
  readonly limitKind: "window" | "budget" | null;
33
+ /** When it was added; ZeroSub's last tie-break between accounts with equal room. */
34
+ readonly createdAt: string | null;
33
35
  }
34
36
 
35
37
  export interface ZeroSubWindow {
@@ -43,6 +45,8 @@ export interface ZeroSubReading {
43
45
  readonly fetchedAt: string;
44
46
  readonly cached: boolean;
45
47
  readonly windows: readonly ZeroSubWindow[];
48
+ /** Prepaid credits past full windows (ChatGPT credits for Codex), when reported. */
49
+ readonly credits: { readonly balance: number | null; readonly unlimited: boolean } | null;
46
50
  }
47
51
 
48
52
  export interface ZeroSubSnapshot {
@@ -54,6 +58,8 @@ export interface ZeroSubSnapshot {
54
58
  readonly readings: Readonly<Record<string, ZeroSubReading>>;
55
59
  /** ZeroSub's "Automatic switching" preference. */
56
60
  readonly autoSwitch: boolean;
61
+ /** The account each family opens new sessions on, when the person chose one. */
62
+ readonly defaults: Readonly<Record<ZeroSubFamily, string | null>>;
57
63
  }
58
64
 
59
65
  /** ZeroSub's ids for the CLI's own login (`server/state.ts` `MAIN_ACCOUNT_ID`). */
@@ -89,6 +95,7 @@ function parseAccount(value: unknown): ZeroSubAccount | null {
89
95
  disabled: raw.disabled === true,
90
96
  limitedUntil: text(raw.limitedUntil),
91
97
  limitKind: raw.limitKind === "window" || raw.limitKind === "budget" ? raw.limitKind : null,
98
+ createdAt: text(raw.createdAt),
92
99
  };
93
100
  }
94
101
 
@@ -104,7 +111,13 @@ function parseReading(value: unknown): ZeroSubReading | null {
104
111
  if (!window || !id || usedPct === null) continue;
105
112
  windows.push({ id, usedPct, resetsAt: text(window.resetsAt), minutes: finite(window.minutes) });
106
113
  }
107
- return { fetchedAt, cached: raw.cached === true, windows };
114
+ const credits = record(raw.credits);
115
+ return {
116
+ fetchedAt,
117
+ cached: raw.cached === true,
118
+ windows,
119
+ credits: credits ? { balance: finite(credits.balance), unlimited: credits.unlimited === true } : null,
120
+ };
108
121
  }
109
122
 
110
123
  /**
@@ -141,7 +154,9 @@ export function parseZeroSub(state: unknown, usage: unknown, preferences: unknow
141
154
 
142
155
  // Paseo stores plugin settings as `{ version, values }`; missing means ZeroSub's default (on).
143
156
  const values = record(record(preferences)?.values);
144
- return { accounts, sessions, continuedElsewhere, readings, autoSwitch: values?.autoSwitch !== false };
157
+ const chosen = record(raw.defaults);
158
+ const defaults = { claude: text(chosen?.claude), codex: text(chosen?.codex) };
159
+ return { accounts, sessions, continuedElsewhere, readings, autoSwitch: values?.autoSwitch !== false, defaults };
145
160
  }
146
161
 
147
162
  /** The ZeroSub family a Paseo provider belongs to, if ZeroSub manages it. */
@@ -174,22 +189,63 @@ const limited = (account: ZeroSubAccount, now: number) => {
174
189
 
175
190
  /**
176
191
  * One account's usage as a provider row the guard reads, or an unavailable row when
177
- * there is no trustworthy reading. A limit the CLI reported is the freshest word on a
178
- * window, so it shows as full until it lifts.
192
+ * there is no trustworthy reading. Whether the account is out is `withOut`'s job.
179
193
  */
180
- export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount, now: number): ProviderUsage {
194
+ export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount): ProviderUsage {
181
195
  const reading = snapshot.readings[account.id];
182
196
  if (!reading || reading.windows.length === 0) return { providerId: account.family, status: "unavailable", fetchedAt: null, windows: [] };
183
- const full = limited(account, now) && account.limitKind === "window";
184
197
  const windows: UsageWindow[] = reading.windows.map((window) => ({
185
198
  id: paseoWindowId(account.family, window),
186
- usedPct: full ? Math.max(100, window.usedPct) : window.usedPct,
199
+ usedPct: window.usedPct,
187
200
  remainingPct: null,
188
201
  resetsAt: window.resetsAt,
189
202
  }));
190
203
  return { providerId: account.family, status: "available", fetchedAt: reading.fetchedAt, windows };
191
204
  }
192
205
 
206
+ const pctOf = (window: UsageWindow): number | null =>
207
+ typeof window.usedPct === "number" ? window.usedPct : typeof window.remainingPct === "number" ? 100 - window.remainingPct : null;
208
+
209
+ /**
210
+ * When an account is out, the time it gets room again (ms); null while it has room.
211
+ *
212
+ * Out means ZeroSub recorded a limit on it (the CLI's notice, or a full reading), or a
213
+ * window it reads that has not rolled over yet is full. That includes the weekly
214
+ * window: the session window the guard watches can show room while the account is
215
+ * out for days. A recorded budget limit counts too: the account takes no turn.
216
+ */
217
+ export function outUntil(row: ProviderUsage, account: ZeroSubAccount, now: number): number | null {
218
+ const ends: number[] = [];
219
+ const until = time(account.limitedUntil);
220
+ if (until !== null && until > now) ends.push(until);
221
+ for (const window of row.status === "available" ? row.windows : []) {
222
+ const resets = time(window.resetsAt);
223
+ if (resets !== null && resets <= now) continue; // Rolled over since that reading.
224
+ if ((pctOf(window) ?? 0) >= 100) ends.push(resets ?? now);
225
+ }
226
+ return ends.length ? Math.max(...ends) : null;
227
+ }
228
+
229
+ /**
230
+ * An out account's row, with the guard's session windows shown full until it gets room
231
+ * again. The guard pauses and anchors on its session window alone, so this is how an
232
+ * account out on another window, or at a limit no reading shows yet, reads as out; the
233
+ * anchor is when the account as a whole has room again.
234
+ */
235
+ function withOut(row: ProviderUsage, account: ZeroSubAccount, now: number, sessionWindowIds: readonly string[]): ProviderUsage {
236
+ const until = outUntil(row, account, now);
237
+ if (until === null || row.status !== "available") return row;
238
+ let changed = false;
239
+ const windows = row.windows.map((window) => {
240
+ const resets = time(window.resetsAt);
241
+ // Already full until the account has room again: the reading says so itself.
242
+ if (!sessionWindowIds.includes(window.id) || ((pctOf(window) ?? 0) >= 100 && resets !== null && resets >= until)) return window;
243
+ changed = true;
244
+ return { ...window, usedPct: Math.max(100, pctOf(window) ?? 100), remainingPct: null, resetsAt: new Date(Math.max(until, resets ?? 0)).toISOString() };
245
+ });
246
+ return changed ? { ...row, windows } : row;
247
+ }
248
+
193
249
  /**
194
250
  * Whether an account could take an agent right now: usable by ZeroSub's own rules
195
251
  * (signed in, not set aside, no recorded limit) and, stricter than ZeroSub, with a
@@ -214,12 +270,32 @@ export function accountsWithRoom(snapshot: ZeroSubSnapshot, current: ZeroSubAcco
214
270
  account.family === current.family && account.id !== current.id && hasRoom(snapshot, account, thresholdPct, sessionWindowIds, now)).length;
215
271
  }
216
272
 
273
+ /**
274
+ * Whether ZeroSub would move the agent to another account at the limit, by ZeroSub's
275
+ * own rule (`isUsable` in its routing): automatic switching on and another account of
276
+ * the provider signed in, not set aside and with no recorded limit. Looser than
277
+ * `hasRoom`, deliberately: once the CLI has reported the limit it is ZeroSub that
278
+ * decides, and this is what it decides with.
279
+ */
280
+ export function switchesAtLimit(snapshot: ZeroSubSnapshot, current: ZeroSubAccount, now: number): boolean {
281
+ return snapshot.autoSwitch && snapshot.accounts.some((account) =>
282
+ account.family === current.family && account.id !== current.id && account.signedIn && !account.disabled && !limited(account, now));
283
+ }
284
+
217
285
  /** The view Session Guard's engine asks, once per pass. */
218
286
  export interface AccountView {
219
- /** Usage as the agent's own account sees it. */
287
+ /** Usage as the agent's own account sees it, with an out account's session windows shown full. */
220
288
  usageFor(agentId: string, provider: string): Usage;
221
289
  /** ZeroSub will move this agent to another account with room when its account hits the limit. */
222
290
  failsOver(agentId: string, provider: string): boolean;
291
+ /** After the CLI reported the limit: ZeroSub is moving this agent to another account itself. */
292
+ switchesAtLimit(agentId: string, provider: string): boolean;
293
+ /**
294
+ * Whether an account this agent can use has room, shown by a reading taken after
295
+ * `since` (a pause): its own account, or — when ZeroSub would fail it over there —
296
+ * another one. Null while every account is still out.
297
+ */
298
+ roomSince(agentId: string, provider: string, since: number): "own" | "elsewhere" | null;
223
299
  /** ZeroSub already carries this agent's Codex task on in another tab. */
224
300
  continuedElsewhere(agentId: string): boolean;
225
301
  }
@@ -230,19 +306,40 @@ export function accountView(
230
306
  policy: { thresholdPct: number; windowIds: readonly string[] },
231
307
  now: number,
232
308
  ): AccountView {
309
+ const roomAfter = (account: ZeroSubAccount, since: number) => {
310
+ const fetched = time(snapshot.readings[account.id]?.fetchedAt);
311
+ return fetched !== null && fetched > since && hasRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now);
312
+ };
233
313
  return {
234
314
  usageFor(agentId, provider) {
235
315
  const account = accountOf(snapshot, agentId, provider);
236
- // The CLI login is what Paseo and the recorder already measure, and measure more often.
237
- if (!account || account.kind === "main") return usage;
238
- const own = accountUsage(snapshot, account, now);
239
- return { ...usage, providers: [...usage.providers.filter((p) => p.providerId !== provider), { ...own, providerId: provider }] };
316
+ if (!account) return usage;
317
+ // The CLI login is what Paseo and the recorder already measure, and measure more
318
+ // often; ZeroSub only adds a limit it recorded on it.
319
+ const own = account.kind === "main"
320
+ ? usage.providers.find((p) => p.providerId === provider)
321
+ : { ...accountUsage(snapshot, account), providerId: provider };
322
+ if (!own) return usage;
323
+ const shown = withOut(own, account, now, policy.windowIds);
324
+ if (shown === own && account.kind === "main") return usage;
325
+ return { ...usage, providers: [...usage.providers.filter((p) => p.providerId !== provider), shown] };
240
326
  },
241
327
  failsOver(agentId, provider) {
242
328
  if (!snapshot.autoSwitch) return false;
243
329
  const account = accountOf(snapshot, agentId, provider);
244
330
  return account !== null && accountsWithRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now) > 0;
245
331
  },
332
+ switchesAtLimit(agentId, provider) {
333
+ const account = accountOf(snapshot, agentId, provider);
334
+ return account !== null && switchesAtLimit(snapshot, account, now);
335
+ },
336
+ roomSince(agentId, provider, since) {
337
+ const account = accountOf(snapshot, agentId, provider);
338
+ if (!account) return null;
339
+ if (roomAfter(account, since)) return "own";
340
+ if (!snapshot.autoSwitch) return null;
341
+ return snapshot.accounts.some((other) => other.family === account.family && other.id !== account.id && roomAfter(other, since)) ? "elsewhere" : null;
342
+ },
246
343
  continuedElsewhere: (agentId) => snapshot.continuedElsewhere.has(agentId),
247
344
  };
248
345
  }
@@ -66,6 +66,27 @@ export const budgetStatus = defineRpc({
66
66
  readAt: z.string().nullable(),
67
67
  })
68
68
  .optional(),
69
+ /**
70
+ * All of the agent's provider's ZeroSub subscriptions together, when there are
71
+ * several: how many have room, how long they last at this pace, and which takes
72
+ * over next. Names only, never emails. Absent without ZeroSub.
73
+ */
74
+ pool: z
75
+ .object({
76
+ family: z.enum(["claude", "codex"]),
77
+ accounts: z.number(),
78
+ withRoom: z.number(),
79
+ /** The pool's pace in session-window points per hour; null when nothing is in use. */
80
+ pacePctPerHour: z.number().nullable(),
81
+ /** When no account has room at this pace; null when it lasts the forecast's day. */
82
+ runsOutAt: z.string().nullable(),
83
+ next: z.object({ name: z.string(), at: z.string() }).nullable(),
84
+ /** After it runs dry, the first account back. */
85
+ back: z.object({ name: z.string(), at: z.string() }).nullable(),
86
+ /** Accounts whose reading is over 30 minutes old. */
87
+ staleReadings: z.number(),
88
+ })
89
+ .optional(),
69
90
  /** Finished session windows from the last 14 days, oldest first. */
70
91
  past: z.array(PastWindowSchema).optional(),
71
92
  recorder: RecorderStatusSchema,
@@ -73,6 +94,51 @@ export const budgetStatus = defineRpc({
73
94
  }),
74
95
  });
75
96
 
97
+ const SubscriptionWindowSchema = z.object({
98
+ id: z.string(),
99
+ pct: z.number(),
100
+ resetsAt: z.string().nullable(),
101
+ burnPctPerHour: z.number().nullable(),
102
+ });
103
+
104
+ /** One ZeroSub account. Names are the person's own or neutral ("CLI login", "Account 2"); never an email. */
105
+ export const SubscriptionSchema = z.object({
106
+ id: z.string(),
107
+ family: z.enum(["claude", "codex"]),
108
+ kind: z.enum(["main", "managed"]),
109
+ name: z.string(),
110
+ named: z.boolean(),
111
+ isDefault: z.boolean(),
112
+ signedIn: z.boolean(),
113
+ disabled: z.boolean(),
114
+ limitedUntil: z.string().nullable(),
115
+ /** When the account has room again (the guard's own rule); null while it has room. */
116
+ outUntil: z.string().nullable().optional(),
117
+ createdAt: z.string().nullable(),
118
+ agents: z.number(),
119
+ readAt: z.string().nullable(),
120
+ windows: z.array(SubscriptionWindowSchema),
121
+ credits: z.object({ balance: z.number().nullable(), unlimited: z.boolean() }).nullable(),
122
+ history: z.array(z.object({ t: z.string(), pct: z.number() })).optional(),
123
+ });
124
+
125
+ /**
126
+ * Every ZeroSub subscription on this host, with its windows and pace. `zerosub` is
127
+ * false (and the lists empty) when ZeroSub is not enabled: then nothing changes.
128
+ */
129
+ export const subscriptionsStatus = defineRpc({
130
+ name: "smart-session.subscriptions",
131
+ input: z.object({}),
132
+ output: z.object({
133
+ zerosub: z.boolean(),
134
+ autoSwitch: z.boolean(),
135
+ accounts: z.array(SubscriptionSchema),
136
+ /** Which account each live session runs on, by agent id. */
137
+ agents: z.record(z.string(), z.string()),
138
+ error: z.string().nullable(),
139
+ }),
140
+ });
141
+
76
142
  export const ContextStatusSchema = z.object({
77
143
  agentId: z.string(),
78
144
  title: z.string().nullable(),