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/README.md +23 -7
- package/client/display.tsx +2 -2
- package/client/kit.tsx +2 -2
- package/client/live.ts +8 -1
- package/client/overview.tsx +59 -18
- package/client/popover.tsx +26 -7
- package/client/settings.tsx +3 -3
- package/client/subscriptions-view.tsx +201 -0
- package/client/subscriptions.ts +68 -0
- package/docs/media/before-after.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/media/smart-session-settings.png +0 -0
- package/docs/media/smart-session-subscriptions-light.png +0 -0
- package/docs/media/smart-session-subscriptions.png +0 -0
- package/docs/session-guard.md +4 -2
- package/index.client.tsx +4 -0
- package/index.server.ts +13 -0
- package/mcp.mjs +22 -0
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/engine.ts +27 -3
- package/server/guard/index.ts +27 -3
- package/server/recorder.ts +12 -0
- package/server/store.ts +24 -1
- package/server/subscriptions.ts +208 -0
- package/shared/guard/limit-notice.ts +87 -0
- package/shared/guard/zerosub.ts +109 -12
- package/shared/smart-session.ts +66 -0
- package/shared/subscriptions.ts +540 -0
package/server/store.ts
CHANGED
|
@@ -73,7 +73,18 @@ export interface ContextSample {
|
|
|
73
73
|
readonly title: string | null;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
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
|
+
}
|
package/shared/guard/zerosub.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
}
|
package/shared/smart-session.ts
CHANGED
|
@@ -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(),
|