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.
- package/README.md +29 -11
- package/client/charts.tsx +7 -6
- package/client/display.tsx +2 -2
- package/client/kit.tsx +2 -2
- package/client/live.ts +8 -1
- package/client/overview.tsx +72 -26
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +35 -17
- package/client/settings.tsx +42 -20
- 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 +2 -2
- package/hooks/ask-compact.mjs +13 -119
- package/hooks/ask.mjs +193 -0
- package/hooks/asks.mjs +53 -6
- package/hooks/context-threshold.mjs +31 -10
- package/hooks/context.mjs +18 -4
- package/index.client.tsx +4 -0
- package/index.server.ts +15 -0
- package/mcp.mjs +33 -1
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/growth.ts +2 -1
- package/server/recorder.ts +12 -0
- package/server/store.ts +24 -1
- package/server/subscriptions.ts +208 -0
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/guard/zerosub.ts +18 -3
- package/shared/smart-session.ts +72 -0
- package/shared/subscriptions.ts +540 -0
- package/shared/thresholds.ts +41 -6
|
@@ -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
|
+
}
|
package/shared/forecast.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
}
|
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. */
|
|
@@ -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);
|
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(),
|
|
@@ -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({
|