paseo-smart-session 3.3.0 → 3.5.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,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,60 @@
1
+ /**
2
+ * What the views say about Smart Compact's two lines, as plain functions.
3
+ *
4
+ * The lower line is the **end-of-turn line**: past it, the `Stop` hook asks the
5
+ * agent at the end of a turn whether to compact. The upper line is the
6
+ * **mid-turn line**: past it, the `PostToolUse` hook asks mid-turn, and the agent
7
+ * is told to finish only the step it is on. The pill, its popover and the overview
8
+ * all read these, so they cannot describe the same session two ways.
9
+ */
10
+
11
+ import { formatCompactTokens, formatDuration } from "./format.ts";
12
+ import type { ContextOutlook, Tone } from "./forecast.ts";
13
+
14
+ /** "asks at 30%, mid-turn at 40%", or the lower line alone when there is no upper one. */
15
+ export function linesSummary(lower: number, upper: number | null | undefined): string {
16
+ return typeof upper === "number" ? `asks at ${lower}%, mid-turn at ${upper}%` : `asks at ${lower}%`;
17
+ }
18
+
19
+ /** The popover's one sentence about where this session stands. */
20
+ export function outlookSentence(
21
+ outlook: ContextOutlook,
22
+ { enrolled, compactOn, growthPerHour }: { enrolled: boolean; compactOn: boolean; growthPerHour: number | null },
23
+ ): { line: string; tone: Tone } {
24
+ const pace = growthPerHour !== null && growthPerHour > 0 ? ` at +${formatCompactTokens(growthPerHour)}/h` : "";
25
+ if (outlook.past && !enrolled) {
26
+ return {
27
+ line: compactOn ? "Past its compact line, but not enrolled, so nothing will ask it." : "Past its compact line. Smart Compact is off, so nothing will ask it.",
28
+ tone: outlook.tone,
29
+ };
30
+ }
31
+ if (outlook.pastNow) {
32
+ return { line: "Past its mid-turn line. Smart Compact is asking it to finish the step it is on and compact.", tone: outlook.tone };
33
+ }
34
+ if (outlook.past) {
35
+ const next = outlook.untilNowMs !== null
36
+ ? ` Reaches its mid-turn line in about ${formatDuration(outlook.untilNowMs)}${pace}.`
37
+ : outlook.nowAt !== null ? ` From ${outlook.nowAt}% it is asked mid-turn.` : "";
38
+ return { line: `Past its end-of-turn line, so it is asked at the end of each turn whether to compact.${next}`, tone: outlook.tone };
39
+ }
40
+ if (outlook.untilCompactMs !== null) {
41
+ const upper = outlook.untilNowMs !== null ? ` and its mid-turn line in about ${formatDuration(outlook.untilNowMs)}` : "";
42
+ return { line: `Reaches its end-of-turn line in about ${formatDuration(outlook.untilCompactMs)}${upper}${pace}.`, tone: "default" };
43
+ }
44
+ return { line: outlook.nowAt !== null ? "Below both compact lines and not growing right now." : "Below its compact line and not growing right now.", tone: "default" };
45
+ }
46
+
47
+ /** The overview row's short line, or null when there is nothing to add. */
48
+ export function outlookShort(outlook: ContextOutlook, enrolled: boolean): string | null {
49
+ if (outlook.past && !enrolled) return "Past its compact line · not enrolled";
50
+ if (outlook.pastNow) return "Past its mid-turn line · asked now";
51
+ if (outlook.past) {
52
+ return outlook.untilNowMs !== null
53
+ ? `Past its end-of-turn line · mid-turn in ~${formatDuration(outlook.untilNowMs)}`
54
+ : "Past its end-of-turn line · asked at the end of a turn";
55
+ }
56
+ if (outlook.untilCompactMs === null) return null;
57
+ return outlook.untilNowMs !== null && outlook.nowAt !== null
58
+ ? `${outlook.compactAt}% in ~${formatDuration(outlook.untilCompactMs)} · ${outlook.nowAt}% in ~${formatDuration(outlook.untilNowMs)}`
59
+ : `Reaches it in ~${formatDuration(outlook.untilCompactMs)}`;
60
+ }
@@ -181,18 +181,29 @@ export function leadWindow(windows: readonly WindowForecast[]): WindowForecast |
181
181
  export interface ContextReading {
182
182
  readonly usedTokens: number;
183
183
  readonly maxTokens: number;
184
+ /** The lower line: Smart Compact asks at the end of a turn. */
184
185
  readonly compactAtPct: number;
186
+ /** The upper line: Smart Compact asks mid-turn. Absent from older daemons. */
187
+ readonly compactNowAtPct?: number | null;
185
188
  readonly growthTokensPerHour?: number | null;
186
189
  }
187
190
 
188
191
  export interface ContextOutlook {
189
192
  readonly pct: number;
190
193
  readonly compactAt: number;
191
- /** Tokens at the compact line. */
194
+ /** Tokens at the lower line. */
192
195
  readonly compactTokens: number;
196
+ /** Past the lower line. */
193
197
  readonly past: boolean;
194
- /** Time until the compact line at the measured growth; null when not growing or already past. */
198
+ /** Time until the lower line at the measured growth; null when not growing or already past. */
195
199
  readonly untilCompactMs: number | null;
200
+ /** The upper line, or null when the reading has none. */
201
+ readonly nowAt: number | null;
202
+ readonly nowTokens: number | null;
203
+ /** Past the upper line, where the agent is asked mid-turn. */
204
+ readonly pastNow: boolean;
205
+ /** Time until the upper line at the measured growth; null when not growing, already past or unknown. */
206
+ readonly untilNowMs: number | null;
196
207
  readonly tone: Tone;
197
208
  }
198
209
 
@@ -201,17 +212,24 @@ export function contextOutlook(reading: ContextReading): ContextOutlook {
201
212
  const compactTokens = Math.round((reading.compactAtPct / 100) * reading.maxTokens);
202
213
  const past = pct >= reading.compactAtPct;
203
214
  const growth = reading.growthTokensPerHour ?? null;
204
- const untilCompactMs = past || growth === null || growth <= 0 ? null : ((compactTokens - reading.usedTokens) / growth) * HOUR;
205
- return { pct, compactAt: reading.compactAtPct, compactTokens, past, untilCompactMs, tone: pct >= 90 ? "danger" : past ? "warning" : "default" };
215
+ const growing = growth !== null && growth > 0;
216
+ const untilCompactMs = past || !growing ? null : ((compactTokens - reading.usedTokens) / growth) * HOUR;
217
+ const nowAt = typeof reading.compactNowAtPct === "number" ? reading.compactNowAtPct : null;
218
+ const nowTokens = nowAt === null ? null : Math.round((nowAt / 100) * reading.maxTokens);
219
+ const pastNow = nowAt !== null && pct >= nowAt;
220
+ const untilNowMs = nowTokens === null || pastNow || !growing ? null : ((nowTokens - reading.usedTokens) / growth) * HOUR;
221
+ // Amber past the lower line, red past the upper one (or a nearly full window).
222
+ const tone = pct >= 90 || pastNow ? "danger" : past ? "warning" : "default";
223
+ return { pct, compactAt: reading.compactAtPct, compactTokens, past, untilCompactMs, nowAt, nowTokens, pastNow, untilNowMs, tone };
206
224
  }
207
225
 
208
226
  /**
209
227
  * The context ring: a faint full track, an arc for the share of the window used, and a
210
- * tick across the ring at the compact line. Inline SVG in the surface's own colour, so
228
+ * tick at each compact line: inward at the end-of-turn line, outward at the mid-turn line. Inline SVG in the surface's own colour, so
211
229
  * the host `Icon` can draw it anywhere a Lucide glyph fits. Quantized to 1/48 so a
212
230
  * ticking view rarely redraws.
213
231
  */
214
- export function contextRing(fraction: number, mark: number | null, track = 0.28): string {
232
+ export function contextRing(fraction: number, mark: number | null, track = 0.28, upper: number | null = null): string {
215
233
  const steps = 48;
216
234
  const f = Math.round(Math.min(1, Math.max(0, fraction)) * steps) / steps;
217
235
  const r = 8.5;
@@ -223,11 +241,19 @@ export function contextRing(fraction: number, mark: number | null, track = 0.28)
223
241
  const [x, y] = point(f, r);
224
242
  arc = `<path d="M${c} ${c - r}A${r} ${r} 0 ${f > 0.5 ? 1 : 0} 1 ${x} ${y}"/>`;
225
243
  }
226
- let tick = "";
227
- if (mark !== null && mark > 0 && mark < 1) {
228
- const [x1, y1] = point(mark, r - 3.6);
229
- const [x2, y2] = point(mark, r + 3.2);
230
- tick = `<path d="M${x1} ${y1}L${x2} ${y2}" stroke-width="1.8"/>`;
231
- }
244
+ // The end-of-turn tick points inward from the ring and the mid-turn tick outward, so the pair sits
245
+ // on opposite sides of the stroke and stays apart at pill size, where two ticks 36° apart that
246
+ // both crossed the ring merged with the arc's end into one hooked smudge.
247
+ const tickAt = (at: number | null, from: number, to: number) => {
248
+ if (at === null || !(at > 0 && at < 1)) return "";
249
+ const [x1, y1] = point(at, from);
250
+ const [x2, y2] = point(at, to);
251
+ return `<path d="M${x1} ${y1}L${x2} ${y2}" stroke-width="1.8"/>`;
252
+ };
253
+ // A muted ring (no lower line) has no upper one either.
254
+ const inward = tickAt(mark, r - 3.8, r);
255
+ // r + 2.6 plus the 0.9 round cap ends exactly at the 24-unit box edge, so the stub is never clipped.
256
+ const outward = upper !== null && upper !== mark ? tickAt(upper, r, r + 2.6) : "";
257
+ const tick = mark === null ? "" : inward + outward;
232
258
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><circle cx="${c}" cy="${c}" r="${r}" stroke-opacity="${track}"/>${arc}${tick}</svg>`;
233
259
  }
@@ -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. */
@@ -199,7 +214,7 @@ const pctOf = (window: UsageWindow): number | null =>
199
214
  * window: the session window the guard watches can show room while the account is
200
215
  * out for days. A recorded budget limit counts too: the account takes no turn.
201
216
  */
202
- function outUntil(row: ProviderUsage, account: ZeroSubAccount, now: number): number | null {
217
+ export function outUntil(row: ProviderUsage, account: ZeroSubAccount, now: number): number | null {
203
218
  const ends: number[] = [];
204
219
  const until = time(account.limitedUntil);
205
220
  if (until !== null && until > now) ends.push(until);
@@ -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(),
@@ -89,6 +155,10 @@ export const ContextStatusSchema = z.object({
89
155
  projectedFullAt: z.string().nullable(),
90
156
  /** The percentage at which this window is considered ready to compact. */
91
157
  compactAtPct: z.number(),
158
+ /** The upper line: past it, Smart Compact asks mid-turn. Optional for older daemons. */
159
+ compactNowAtPct: z.number().optional(),
160
+ /** When this agent would reach the upper line, at that rate. */
161
+ projectedNowAt: z.string().nullable().optional(),
92
162
  /** When this agent's task state file was last written; null when it has none. */
93
163
  stateWrittenAt: z.string().nullable().optional(),
94
164
  });
@@ -108,6 +178,8 @@ const ThresholdProfileSchema = z.object({
108
178
  notice: z.number().finite().min(1).max(99),
109
179
  closing: z.number().finite().min(1).max(99),
110
180
  compact: z.number().finite().min(1).max(99),
181
+ /** The upper line, where Smart Compact asks mid-turn. */
182
+ now: z.number().finite().min(1).max(99),
111
183
  });
112
184
 
113
185
  export const ThresholdsSchema = z.object({