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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { formatDuration } from "../shared/format";
|
|
5
|
+
import { forecastWindow, formatMoment, paceLine } from "../shared/forecast";
|
|
6
|
+
import {
|
|
7
|
+
accountOutlook, familyName, limitedWindow, poolHeadline, sessionWindow, subscriptionState, weeklyWindow,
|
|
8
|
+
type LaneKind, type PoolForecast, type Subscription, type SubscriptionWindow,
|
|
9
|
+
} from "../shared/subscriptions";
|
|
10
|
+
import { Meter, ink } from "./charts";
|
|
11
|
+
import { Card, CardRow, Chip, SectionHead, text } from "./kit";
|
|
12
|
+
import { tabular, toneColor, withAlpha, type Theme } from "./theme";
|
|
13
|
+
|
|
14
|
+
const HOUR = 3_600_000;
|
|
15
|
+
|
|
16
|
+
/** How a lane run is drawn: magnitude is ink, the limit is red, out is a hairline. */
|
|
17
|
+
function laneFill(theme: Theme, kind: LaneKind): string {
|
|
18
|
+
const c = theme.colors;
|
|
19
|
+
if (kind === "active") return ink(c, 0.82);
|
|
20
|
+
if (kind === "room") return ink(c, 0.16);
|
|
21
|
+
if (kind === "limit") return withAlpha(c.statusDanger, 0.42);
|
|
22
|
+
return "transparent";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LANE_WORDS: Record<LaneKind, string> = { active: "In use", room: "Room", limit: "At the limit", out: "Set aside or signed out" };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The runway: one lane per account across the next hours, as the pooled forecast
|
|
29
|
+
* runs the work through them: in use, waiting with room, at the limit. Reading down
|
|
30
|
+
* a column says who carries the work then; reading along a lane says when an
|
|
31
|
+
* account fills and when it is back.
|
|
32
|
+
*/
|
|
33
|
+
function Runway({ theme, pool, accounts, now, compact }: { theme: Theme; pool: PoolForecast; accounts: readonly Subscription[]; now: number; compact: boolean }) {
|
|
34
|
+
const c = theme.colors;
|
|
35
|
+
const t = text(c);
|
|
36
|
+
const span = (compact ? 8 : 12) * HOUR;
|
|
37
|
+
const end = now + span;
|
|
38
|
+
const at = (ms: number) => `${((ms - now) / span) * 100}%`;
|
|
39
|
+
const width = (from: number, to: number) => `${((Math.min(to, end) - Math.max(from, now)) / span) * 100}%`;
|
|
40
|
+
// Hour ticks on the clock, labelled every few hours.
|
|
41
|
+
const first = Math.ceil(now / HOUR) * HOUR;
|
|
42
|
+
const ticks: number[] = [];
|
|
43
|
+
for (let h = first; h < end; h += HOUR) ticks.push(h);
|
|
44
|
+
const every = 2;
|
|
45
|
+
const kinds = new Set<LaneKind>();
|
|
46
|
+
for (const account of accounts) for (const run of pool.lanes[account.id] ?? []) if (run.from < end) kinds.add(run.kind);
|
|
47
|
+
const nameWidth = compact ? 0 : 112;
|
|
48
|
+
const summary = poolHeadline(pool, accounts, now);
|
|
49
|
+
const dry = pool.runsOutAt !== null && pool.runsOutAt > now && pool.runsOutAt < end ? pool.runsOutAt : null;
|
|
50
|
+
const near = (ms: number, width: number) => dry !== null && Math.abs(ms - dry) / span < width;
|
|
51
|
+
|
|
52
|
+
return <View accessible accessibilityRole="image" accessibilityLabel={`${summary.title}. ${summary.detail}`} style={{ gap: 6 }}>
|
|
53
|
+
{accounts.map((account) => <View key={account.id} style={{ flexDirection: compact ? "column" : "row", alignItems: compact ? "stretch" : "center", gap: compact ? 3 : 10 }}>
|
|
54
|
+
<Text numberOfLines={1} style={{ ...t.small, width: compact ? undefined : nameWidth, color: c.foreground }}>{account.name}</Text>
|
|
55
|
+
<View style={{ flex: compact ? undefined : 1, height: 10, borderRadius: 3, overflow: "hidden", backgroundColor: ink(c, 0.04) }}>
|
|
56
|
+
{(pool.lanes[account.id] ?? []).filter((run) => run.from < end).map((run, i) => <View key={i} style={{
|
|
57
|
+
position: "absolute", left: at(Math.max(run.from, now)) as `${number}%`, width: width(run.from, run.to) as `${number}%`,
|
|
58
|
+
...(run.kind === "out" ? { top: 4.5, bottom: 4.5, backgroundColor: c.border } : { top: 0, bottom: 0, backgroundColor: laneFill(theme, run.kind) }),
|
|
59
|
+
}} />)}
|
|
60
|
+
{/* The moment the pool runs dry, across every lane. */}
|
|
61
|
+
{dry !== null ? <View style={{ position: "absolute", top: 0, bottom: 0, left: at(dry) as `${number}%`, width: 1, backgroundColor: c.foreground }} /> : null}
|
|
62
|
+
</View>
|
|
63
|
+
</View>)}
|
|
64
|
+
{/* The clock under the lanes, aligned with them. */}
|
|
65
|
+
<View style={{ flexDirection: "row", gap: compact ? 0 : 10 }}>
|
|
66
|
+
{compact ? null : <View style={{ width: nameWidth }} />}
|
|
67
|
+
<View style={{ flex: 1, height: 14 }}>
|
|
68
|
+
<Text style={{ ...t.small, fontSize: 10, lineHeight: 14, position: "absolute", left: 0 }}>Now</Text>
|
|
69
|
+
{ticks.map((h, i) => (Math.round(h / HOUR) % every === 0 && (h - now) / span > (compact ? 0.1 : 0.08) && (end - h) / span > (compact ? 0.22 : 0.09) && !near(h, compact ? 0.18 : 0.1))
|
|
70
|
+
? <Text key={i} style={{ ...t.small, fontSize: 10, lineHeight: 14, position: "absolute", left: at(h) as `${number}%`, transform: [{ translateX: -30 }], width: 60, textAlign: "center" }}>{hourLabel(h, now)}</Text>
|
|
71
|
+
: null)}
|
|
72
|
+
{dry !== null ? <Text style={{ ...t.small, fontSize: 10, lineHeight: 14, fontWeight: "600", color: c.foreground, position: "absolute", left: at(dry) as `${number}%`, transform: [{ translateX: -40 }], width: 80, textAlign: "center" }}>{`out ${formatMoment(new Date(dry).toISOString(), now)}`}</Text> : null}
|
|
73
|
+
<Text style={{ ...t.small, fontSize: 10, lineHeight: 14, position: "absolute", right: 0 }}>{near(end, compact ? 0.2 : 0.12) ? "" : formatMoment(new Date(end).toISOString(), now)}</Text>
|
|
74
|
+
</View>
|
|
75
|
+
</View>
|
|
76
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 14, rowGap: 4, paddingLeft: compact ? 0 : nameWidth + 10 }}>
|
|
77
|
+
{(["active", "room", "limit", "out"] as LaneKind[]).filter((k) => kinds.has(k)).map((k) => <View key={k} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
78
|
+
<View style={{ width: 14, height: k === "out" ? 1 : 6, borderRadius: 2, backgroundColor: k === "out" ? c.border : laneFill(theme, k) }} />
|
|
79
|
+
<Text style={t.small}>{LANE_WORDS[k]}</Text>
|
|
80
|
+
</View>)}
|
|
81
|
+
</View>
|
|
82
|
+
</View>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "8 AM" or "20:00": an hour mark on the runway's clock, in the device's convention. */
|
|
86
|
+
function hourLabel(ms: number, now: number): string {
|
|
87
|
+
const clock = formatMoment(new Date(ms).toISOString(), now);
|
|
88
|
+
return clock.replace(/:00(?=\s|$)/, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One window of one account: "5h 42%", a thin meter, and its reset. */
|
|
92
|
+
function MiniWindow({ theme, label, w, now, stale, full = false }: { theme: Theme; label: string; w: SubscriptionWindow | null; now: number; stale: boolean; full?: boolean }) {
|
|
93
|
+
const c = theme.colors;
|
|
94
|
+
const t = text(c);
|
|
95
|
+
if (w === null) return <View style={{ flex: 1, minWidth: 130, gap: 2 }}><Text style={t.meta}>{`${label} · not reported`}</Text></View>;
|
|
96
|
+
const reset = w.resetsAt === null ? null : Date.parse(w.resetsAt);
|
|
97
|
+
const rolled = reset !== null && reset <= now;
|
|
98
|
+
// A limit the CLI reported is the freshest word on its window: full until it resets.
|
|
99
|
+
const f = forecastWindow({ ...w, pct: rolled ? 0 : full ? Math.max(100, w.pct) : w.pct, burnPctPerHour: stale ? null : w.burnPctPerHour }, now);
|
|
100
|
+
const pct = rolled ? null : Math.round(full ? Math.max(100, w.pct) : w.pct);
|
|
101
|
+
return <View style={{ flex: 1, minWidth: 130, gap: 1 }}>
|
|
102
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
|
|
103
|
+
<Text style={{ ...t.meta, color: c.foreground }}>{label}</Text>
|
|
104
|
+
<Text style={{ ...t.label, ...tabular, color: f.tone === "default" ? c.foreground : toneColor(c, f.tone) }}>{pct === null ? "reset" : `${pct}%`}</Text>
|
|
105
|
+
<View style={{ flex: 1 }} />
|
|
106
|
+
<Text numberOfLines={1} style={t.small}>{reset === null ? "" : rolled ? `was ${formatMoment(w.resetsAt!, now)}` : `resets ${formatMoment(w.resetsAt!, now)}`}</Text>
|
|
107
|
+
</View>
|
|
108
|
+
<Meter theme={theme} pct={pct ?? 0} tone={f.tone} projected={f.atResetPct} height={5} label={`${label}: ${pct === null ? "reset since the last reading" : `${pct}% used`}`} />
|
|
109
|
+
</View>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function AccountRow({ theme, account, pool, now, compact, first, alone }: { theme: Theme; account: Subscription; pool: PoolForecast; now: number; compact: boolean; first: boolean; alone: boolean }) {
|
|
113
|
+
const c = theme.colors;
|
|
114
|
+
const t = text(c);
|
|
115
|
+
const state = subscriptionState(account, now);
|
|
116
|
+
const stale = state.staleMs !== null;
|
|
117
|
+
const s5 = sessionWindow(account);
|
|
118
|
+
const week = weeklyWindow(account);
|
|
119
|
+
const muted = state.kind === "aside" || state.kind === "signed-out";
|
|
120
|
+
// When it runs out comes from the pool's simulation, so the row, the lead and the
|
|
121
|
+
// popover say the same time; "on pace for" is its own pace when nothing moves onto it.
|
|
122
|
+
const outlook = state.kind === "room" ? accountOutlook(pool, account, now) : null;
|
|
123
|
+
const f = s5 && !stale && !(s5.resetsAt && Date.parse(s5.resetsAt) <= now) ? forecastWindow(s5, now) : null;
|
|
124
|
+
const pace: { text: string; tone: "default" | "warning" | "danger" } | null = outlook?.kind === "runs-out"
|
|
125
|
+
? { text: `Runs out ~${formatMoment(new Date(outlook.at).toISOString(), now)}`, tone: outlook.at - now <= 30 * 60_000 ? "danger" : "warning" }
|
|
126
|
+
: outlook?.kind === "lasts" && f && f.state === "ok" ? { text: paceLine(f, now), tone: "default" } : null;
|
|
127
|
+
const facts = [
|
|
128
|
+
account.agents > 0 ? `${account.agents} ${account.agents === 1 ? "session" : "sessions"} on it` : null,
|
|
129
|
+
stale && !muted ? `Reading ${formatDuration(state.staleMs!)} old` : null,
|
|
130
|
+
account.credits ? (account.credits.unlimited ? "Unlimited credits" : account.credits.balance !== null ? `${Math.round(account.credits.balance).toLocaleString()} credits` : null) : null,
|
|
131
|
+
].filter(Boolean);
|
|
132
|
+
const limitedId = state.kind === "limit" ? limitedWindow(account, now)?.id ?? null : null;
|
|
133
|
+
|
|
134
|
+
return <CardRow theme={theme} first={first} style={{ gap: 8, opacity: muted ? 0.62 : 1 }}>
|
|
135
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
136
|
+
<Icon name={account.kind === "main" ? "Terminal" : "UserRound"} size={14} color={c.foregroundMuted} />
|
|
137
|
+
<Text numberOfLines={1} style={{ ...t.label, flexShrink: 1 }}>{account.name}</Text>
|
|
138
|
+
{account.isDefault && !alone ? <Chip theme={theme} label="Default" /> : null}
|
|
139
|
+
<View style={{ flex: 1 }} />
|
|
140
|
+
{state.kind === "room" ? null : <Chip theme={theme} icon={state.kind === "limit" ? "OctagonAlert" : undefined} label={state.label} color={toneColor(c, state.tone)} />}
|
|
141
|
+
</View>
|
|
142
|
+
{muted ? null : <View style={{ flexDirection: compact ? "column" : "row", gap: compact ? 8 : 20, paddingLeft: 22 }}>
|
|
143
|
+
<MiniWindow theme={theme} label="5h" w={s5} now={now} stale={stale} full={limitedId !== null && limitedId === s5?.id} />
|
|
144
|
+
<MiniWindow theme={theme} label="Week" w={week} now={now} stale={stale} full={limitedId !== null && limitedId === week?.id} />
|
|
145
|
+
</View>}
|
|
146
|
+
{facts.length > 0 || pace ? <Text style={{ ...t.meta, paddingLeft: 22 }}>
|
|
147
|
+
{facts.join(" · ")}{facts.length > 0 && pace ? " · " : ""}
|
|
148
|
+
{/* Only the clause that is state wears the state colour. */}
|
|
149
|
+
{pace ? <Text style={{ color: pace.tone === "default" ? c.foregroundMuted : toneColor(c, pace.tone), fontWeight: pace.tone === "default" ? "400" : "600" }}>{pace.text}</Text> : null}
|
|
150
|
+
</Text> : null}
|
|
151
|
+
</CardRow>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every ZeroSub subscription, grouped by provider: the pool's runway and headline,
|
|
156
|
+
* then one row per account. ZeroSub manages the accounts; this says when.
|
|
157
|
+
*/
|
|
158
|
+
export function SubscriptionsSection({ theme, compact, accounts, pools, now, autoSwitch, leadFamily }: {
|
|
159
|
+
theme: Theme; compact: boolean; accounts: readonly Subscription[]; pools: readonly PoolForecast[]; now: number; autoSwitch: boolean;
|
|
160
|
+
/** The family the page's headline already speaks for; its card does not repeat it. */
|
|
161
|
+
leadFamily: string | null;
|
|
162
|
+
}) {
|
|
163
|
+
const c = theme.colors;
|
|
164
|
+
const t = text(c);
|
|
165
|
+
return <View style={{ gap: 8 }}>
|
|
166
|
+
<SectionHead theme={theme} title="Subscriptions" count={accounts.length}
|
|
167
|
+
trailing={autoSwitch ? null : <Text style={t.small}>Automatic switching is off in ZeroSub</Text>} />
|
|
168
|
+
{pools.map((pool) => {
|
|
169
|
+
const mine = [...accounts.filter((a) => a.family === pool.family)].sort((a, b) =>
|
|
170
|
+
Number(b.agents > 0) - Number(a.agents > 0) || Number(b.isDefault) - Number(a.isDefault) || Number(b.kind === "main") - Number(a.kind === "main"));
|
|
171
|
+
const head = poolHeadline(pool, mine, now);
|
|
172
|
+
return <Card key={pool.family} theme={theme}>
|
|
173
|
+
<CardRow theme={theme} first style={{ gap: 12 }}>
|
|
174
|
+
<View style={{ gap: 2 }}>
|
|
175
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
176
|
+
<Text style={{ ...t.title }}>{familyName(pool.family)}</Text>
|
|
177
|
+
<Text style={t.meta}>{`${pool.withRoom} of ${pool.total} with room`}</Text>
|
|
178
|
+
</View>
|
|
179
|
+
{mine.length > 1 && pool.family !== leadFamily ? <Text style={{ ...t.meta, color: head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone) }}>{headLine(head, pool)}</Text> : null}
|
|
180
|
+
</View>
|
|
181
|
+
{mine.length > 1 ? <Runway theme={theme} pool={pool} accounts={mine} now={now} compact={compact} /> : null}
|
|
182
|
+
</CardRow>
|
|
183
|
+
{mine.map((account) => <AccountRow key={account.id} theme={theme} account={account} pool={pool} now={now} compact={compact} first={false} alone={mine.length === 1} />)}
|
|
184
|
+
</Card>;
|
|
185
|
+
})}
|
|
186
|
+
{/* What the rows' meters mean, once. */}
|
|
187
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 14, rowGap: 4, paddingHorizontal: 2 }}>
|
|
188
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}><View style={{ width: 14, height: 5, borderRadius: 3, backgroundColor: ink(c, 0.82) }} /><Text style={t.small}>Used</Text></View>
|
|
189
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}><View style={{ width: 14, height: 5, borderRadius: 3, backgroundColor: ink(c, 0.16) }} /><Text style={t.small}>By the reset at its own pace</Text></View>
|
|
190
|
+
</View>
|
|
191
|
+
</View>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The family's one line in its card: the headline's facts, which the title already frames. */
|
|
195
|
+
function headLine(head: { title: string; detail: string }, pool: PoolForecast): string {
|
|
196
|
+
// "Claude · 2 subscriptions last until about 9:40 PM" → "Last until about 9:40 PM".
|
|
197
|
+
const lead = head.title.replace(/^(Claude|Codex) · \d+ subscriptions? /, "").replace(/^(Claude|Codex) /, "");
|
|
198
|
+
const sentence = lead.charAt(0).toUpperCase() + lead.slice(1);
|
|
199
|
+
if (pool.pace === null && pool.runsOutAt === null) return head.detail;
|
|
200
|
+
return [sentence, head.detail].filter(Boolean).join(" · ");
|
|
201
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZeroSub subscriptions for this client bundle: one poll of
|
|
3
|
+
* `smart-session.subscriptions` that the overview, the sidebar row and the pill's
|
|
4
|
+
* popover all read. Without ZeroSub it answers `zerosub: false`, is asked rarely, and
|
|
5
|
+
* every view looks exactly as it does without this file.
|
|
6
|
+
*/
|
|
7
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import { subscriptionsStatus } from "../shared/smart-session.ts";
|
|
11
|
+
import { poolForecast, type Family, type PoolForecast } from "../shared/subscriptions.ts";
|
|
12
|
+
|
|
13
|
+
export type Subscriptions = z.infer<(typeof subscriptionsStatus)["output"]>;
|
|
14
|
+
|
|
15
|
+
/** ZeroSub refreshes an account in use every few minutes; half a minute keeps up. */
|
|
16
|
+
const ZEROSUB_POLL_MS = 30_000;
|
|
17
|
+
/** Without ZeroSub: only to notice it being turned on. */
|
|
18
|
+
const IDLE_POLL_MS = 5 * 60_000;
|
|
19
|
+
|
|
20
|
+
let current: Subscriptions | null = null;
|
|
21
|
+
const listeners = new Set<() => void>();
|
|
22
|
+
|
|
23
|
+
export function subscriptionsSnapshot(): Subscriptions | null {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function subscribeSubscriptions(listener: () => void): () => void {
|
|
28
|
+
listeners.add(listener);
|
|
29
|
+
return () => { listeners.delete(listener); };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function publishSubscriptions(next: Subscriptions | null): void {
|
|
33
|
+
current = next;
|
|
34
|
+
for (const listener of [...listeners]) listener();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The families worth a pooled view: ZeroSub on, and at least one account in them. */
|
|
38
|
+
export function pools(subs: Subscriptions | null, now: number): PoolForecast[] {
|
|
39
|
+
if (!subs?.zerosub) return [];
|
|
40
|
+
const families = (["claude", "codex"] as Family[]).filter((family) => subs.accounts.some((a) => a.family === family));
|
|
41
|
+
return families.map((family) => poolForecast(subs.accounts, family, now, { autoSwitch: subs.autoSwitch }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether the views speak about subscriptions at all: ZeroSub with more than one login. */
|
|
45
|
+
export function hasSubscriptions(subs: Subscriptions | null): subs is Subscriptions {
|
|
46
|
+
return subs?.zerosub === true && subs.accounts.length > 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Starts the one poll for this bundle. */
|
|
50
|
+
export function watchSubscriptions(client: PluginClientContext, onChange?: (next: Subscriptions) => void): () => void {
|
|
51
|
+
let stopped = false;
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
53
|
+
const refresh = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const next = await client.rpc(subscriptionsStatus, {});
|
|
56
|
+
if (!stopped) { publishSubscriptions(next); onChange?.(next); }
|
|
57
|
+
} catch {
|
|
58
|
+
// Keep the last reading across a brief disconnect.
|
|
59
|
+
} finally {
|
|
60
|
+
if (!stopped) timer = setTimeout(() => void refresh(), current?.zerosub ? ZEROSUB_POLL_MS : IDLE_POLL_MS);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
void refresh();
|
|
64
|
+
return () => {
|
|
65
|
+
stopped = true;
|
|
66
|
+
if (timer !== null) clearTimeout(timer);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/session-guard.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Pause at the limit
|
|
1
|
+
# Pause at the limit (Session Guard)
|
|
2
2
|
|
|
3
|
-
Pause at the limit (
|
|
3
|
+
Pause at the limit (Session Guard) (the standalone Session Guard plugin, now part of Smart Session) pauses agents at their provider's session limit and continues the same conversations after the window resets, even while the Paseo app is closed. It began as the standalone [paseo-session-guard](https://github.com/tomgrin10/paseo-session-guard) plugin and now ships inside Smart Session. See the [README](../README.md) for installation.
|
|
4
4
|
|
|
5
5
|
## Turning it on
|
|
6
6
|
|
|
@@ -81,6 +81,8 @@ If a reload or connection loss interrupts an unconfirmed stop/send, the pill rea
|
|
|
81
81
|
- **ZeroSub goes first.** The guard doesn't pause an agent while ZeroSub's automatic switching is on and another account of the same provider has room. Room means signed in, not disabled, no recorded limit, and a reading under 30 minutes old that is below the threshold.
|
|
82
82
|
- A session the guard already froze is released when such an account appears, so the CLI reaches the limit and ZeroSub can switch it.
|
|
83
83
|
- Once no account has room, the guard pauses and continues as before.
|
|
84
|
+
- **Every account out pauses, whichever side sees it first.** An account is out when it's at or over the threshold, at a limit ZeroSub recorded, or has any full window, including the weekly one. When the guard's reading gets there first, it pauses ahead of the CLI as usual. When the CLI gets there first, Claude ends the turn with its "You've hit your … limit" reply rather than an error. The guard reads that reply the way ZeroSub does and records a waiting pause once ZeroSub has recorded the limit. A pause racing that reply is kept, not released and taken again.
|
|
85
|
+
- **Any account getting room continues it, once.** If the agent's own account resets, it continues there. If another account gets room first (confirmed by a ZeroSub reading taken after the pause), the guard sends its continuation anyway. That turn meets the limit on the old account, and ZeroSub moves the agent to the account with room and continues it with its own prompt. With automatic continuation off (`autoResume`), the pause waits for you as before.
|
|
84
86
|
- **A moved session continues sooner.** An interrupted session that ZeroSub moves to an account with room is continued as soon as that account's reading confirms the room. It doesn't wait for the old account's reset.
|
|
85
87
|
- **Codex tasks aren't continued twice.** When ZeroSub carries a Codex task on in another tab, the old tab's waiting continuation is cancelled.
|
|
86
88
|
|
package/index.client.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { createLive } from "./client/live";
|
|
|
11
11
|
import { OVERVIEW_SCREEN, PAUSED_SCREEN, SMART_COMPACT_SETTINGS, makeOverview } from "./client/overview";
|
|
12
12
|
import { contributeClient } from "./client/pill";
|
|
13
13
|
import { makeSettings } from "./client/settings";
|
|
14
|
+
import { watchSubscriptions } from "./client/subscriptions";
|
|
14
15
|
import { budgetStatus } from "./shared/smart-session";
|
|
15
16
|
|
|
16
17
|
export default function contribute(host: PluginClientContext) {
|
|
@@ -37,11 +38,14 @@ export default function contribute(host: PluginClientContext) {
|
|
|
37
38
|
// One agent observation and one protection poll for the one pill.
|
|
38
39
|
const agents = observeAgents(client);
|
|
39
40
|
const stopGuard = watchGuard(client, live);
|
|
41
|
+
// ZeroSub's subscriptions, for the overview, the sidebar row and the popover; rare without ZeroSub.
|
|
42
|
+
const stopSubscriptions = watchSubscriptions(client, (next) => live.setSubscriptions(next));
|
|
40
43
|
// The pill registers on the host's own client: a paused session keeps it even when pills are hidden.
|
|
41
44
|
const removeClient = contributeClient(host, agents);
|
|
42
45
|
return () => {
|
|
43
46
|
clearInterval(budgetTimer);
|
|
44
47
|
stopGuard();
|
|
48
|
+
stopSubscriptions();
|
|
45
49
|
removeClient();
|
|
46
50
|
for (const remove of removers.splice(0)) void remove();
|
|
47
51
|
visibility.dispose();
|
package/index.server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { contributeGuard } from "./server/guard/index.ts";
|
|
2
2
|
import { zerosubBudgetAccount } from "./server/guard/zerosub.ts";
|
|
3
3
|
import { contributeSessionMcp } from "./server/session-mcp.ts";
|
|
4
|
+
import { budgetPool, subscriptionsView } from "./server/subscriptions.ts";
|
|
4
5
|
import { displayPreferences } from "./shared/display";
|
|
5
6
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
installStatus,
|
|
18
19
|
setSettings,
|
|
19
20
|
spendSummary,
|
|
21
|
+
subscriptionsStatus,
|
|
20
22
|
} from "./shared/smart-session.ts";
|
|
21
23
|
|
|
22
24
|
import {
|
|
@@ -76,9 +78,12 @@ export default function contribute(server: PluginServerContext) {
|
|
|
76
78
|
const newestInstant = newest === null ? null : effectiveAt(newest);
|
|
77
79
|
// Under ZeroSub the agent may run on another account than the one recorded.
|
|
78
80
|
const account = agentId === undefined ? null : await zerosubBudgetAccount(agentId).catch(() => null);
|
|
81
|
+
// …and with several subscriptions, how long they last together.
|
|
82
|
+
const pool = agentId === undefined ? null : await budgetPool(agentId).catch(() => null);
|
|
79
83
|
return {
|
|
80
84
|
windows: account?.windows ?? windows,
|
|
81
85
|
...(account ? { account: account.account } : {}),
|
|
86
|
+
...(pool ? { pool } : {}),
|
|
82
87
|
past,
|
|
83
88
|
recorder: {
|
|
84
89
|
newestAt: newestInstant,
|
|
@@ -107,6 +112,14 @@ export default function contribute(server: PluginServerContext) {
|
|
|
107
112
|
}
|
|
108
113
|
});
|
|
109
114
|
|
|
115
|
+
server.handle(subscriptionsStatus, async () => {
|
|
116
|
+
try {
|
|
117
|
+
return { ...(await subscriptionsView()), error: null };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { zerosub: false, autoSwitch: false, accounts: [], agents: {}, error: error instanceof Error ? error.message : String(error) };
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
110
123
|
server.handle(contextStatus, async ({ agentId }) => {
|
|
111
124
|
try {
|
|
112
125
|
const rows = await withDaemon((client) => readAgents(client));
|
package/mcp.mjs
CHANGED
|
@@ -280,6 +280,27 @@ function accountLines(account) {
|
|
|
280
280
|
return { head, tail, fromRecorder: account.kind === "main" };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* All of this provider's ZeroSub subscriptions together, when there are several:
|
|
285
|
+
* how long they last at this pace and which takes over next. Names are the person's
|
|
286
|
+
* own or neutral ("CLI login", "Account 2"); the RPC never carries an email.
|
|
287
|
+
*/
|
|
288
|
+
function poolLines(pool) {
|
|
289
|
+
if (pool === undefined || pool === null) return [];
|
|
290
|
+
const fam = pool.family === "codex" ? "Codex" : "Claude";
|
|
291
|
+
const stale = pool.staleReadings > 0 ? ` (${pool.staleReadings} reading${pool.staleReadings === 1 ? " is" : "s are"} over 30 min old, so this is rough)` : "";
|
|
292
|
+
const head = `All ${pool.accounts} ${fam} subscriptions: ${pool.withRoom} with room now`;
|
|
293
|
+
if (pool.runsOutAt !== null && Date.parse(pool.runsOutAt) <= Date.now()) {
|
|
294
|
+
return [`${head}. Every one is at a limit${pool.back ? `; ${pool.back.name} is back ${describeRelative(pool.back.at)}` : ""}${stale}.`];
|
|
295
|
+
}
|
|
296
|
+
const next = pool.next ? ` ${pool.next.name} takes over ${describeRelative(pool.next.at)}.` : "";
|
|
297
|
+
if (pool.runsOutAt !== null) {
|
|
298
|
+
return [`${head}. At the current pace (${pool.pacePctPerHour}%/h of a session window) they run out ${describeRelative(pool.runsOutAt)}${pool.back ? `, and ${pool.back.name} is back ${describeRelative(pool.back.at)}` : ""}.${next}${stale}`];
|
|
299
|
+
}
|
|
300
|
+
if (pool.pacePctPerHour !== null) return [`${head}. At the current pace they last the next 24 hours.${next}${stale}`];
|
|
301
|
+
return [`${head}${stale}.`];
|
|
302
|
+
}
|
|
303
|
+
|
|
283
304
|
/**
|
|
284
305
|
* Session Guard's word on this session, for `budget_status`.
|
|
285
306
|
*
|
|
@@ -351,6 +372,7 @@ const TOOLS = {
|
|
|
351
372
|
if (result.error) return `Could not read plan usage: ${result.error}`;
|
|
352
373
|
const guard = await guardSummary();
|
|
353
374
|
const account = accountLines(result.account);
|
|
375
|
+
account.tail.push(...poolLines(result.pool));
|
|
354
376
|
const interesting = (result.windows ?? []).filter(
|
|
355
377
|
(window) => window.id === "five_hour" || window.id === "seven_day" || window.pct > 0,
|
|
356
378
|
);
|
package/package.json
CHANGED
package/paseo-plugin.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"docs/media/smart-session-flow.gif",
|
|
9
9
|
"docs/media/smart-session-pill.png",
|
|
10
10
|
"docs/media/smart-session-chat.png",
|
|
11
|
+
"docs/media/smart-session-subscriptions.png",
|
|
11
12
|
"docs/media/smart-session-guard.png",
|
|
12
13
|
"docs/media/smart-session-settings.png",
|
|
13
14
|
"docs/media/smart-session-mobile.png"
|
package/server/guard/engine.ts
CHANGED
|
@@ -166,6 +166,15 @@ export class Engine {
|
|
|
166
166
|
this.quotaFailures.set(agentId, { at: new Date(this.now()).toISOString(), turnId });
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* The agent's finished turn ended on the CLI's limit notice (`shared/guard/limit-notice.ts`).
|
|
171
|
+
* Claude completes such a turn rather than failing it, so this is how a pause is recorded
|
|
172
|
+
* when the CLI reaches the limit before the guard's reading does. Only reported while
|
|
173
|
+
* ZeroSub is enabled, whose own detection this mirrors.
|
|
174
|
+
*/
|
|
175
|
+
limitReached(agentId: string, turnId: string | null) {
|
|
176
|
+
this.quotaFailures.set(agentId, { at: new Date(this.now()).toISOString(), turnId });
|
|
177
|
+
}
|
|
169
178
|
private async leftDaemon(suspension: NonNullable<Pause["suspension"]>) {
|
|
170
179
|
return !!this.processes && ((this.processes.alive && !await this.processes.alive(suspension)) ||
|
|
171
180
|
(this.processes.owned && !await this.processes.owned(suspension))) === true;
|
|
@@ -327,7 +336,7 @@ export class Engine {
|
|
|
327
336
|
pause.phase = "cancelled"; pause.note = note; pause.settledAt = new Date(this.now()).toISOString();
|
|
328
337
|
}
|
|
329
338
|
/** A normally completed turn racing cancel must never become a continuation. */
|
|
330
|
-
async turnEnded(agentId: string, turnId: string | null, outcome: string) {
|
|
339
|
+
async turnEnded(agentId: string, turnId: string | null, outcome: string, endedOnLimit = false) {
|
|
331
340
|
return this.exclusive(async () => {
|
|
332
341
|
if (!["completed", "canceled"].includes(outcome)) return;
|
|
333
342
|
const state = await this.store.read();
|
|
@@ -342,6 +351,12 @@ export class Engine {
|
|
|
342
351
|
}
|
|
343
352
|
if (outcome !== "completed") continue;
|
|
344
353
|
if (turnId !== null && pause.turnId === turnId) {
|
|
354
|
+
if (endedOnLimit) {
|
|
355
|
+
// It completed on the limit notice, not with the work done: the same pause
|
|
356
|
+
// stands, now as the quota stop it is, rather than a release and a second pause.
|
|
357
|
+
if (!pause.wasQuotaFailure) { pause.wasQuotaFailure = true; changed = true; }
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
345
360
|
await this.cancel(pause, "The interrupted turn completed before the pause."); changed = true;
|
|
346
361
|
}
|
|
347
362
|
}
|
|
@@ -397,6 +412,10 @@ export class Engine {
|
|
|
397
412
|
this.accounts = accounts;
|
|
398
413
|
const usageOf = (agentId: string, provider: string) => accounts?.usageFor(agentId, provider) ?? usage;
|
|
399
414
|
const failsOver = (agentId: string, provider: string) => accounts?.failsOver(agentId, provider) ?? false;
|
|
415
|
+
// A pause can continue once its own account resets, or — with ZeroSub — once any
|
|
416
|
+
// account it can use has room, shown by a reading newer than the pause.
|
|
417
|
+
const ready = (pause: Pause) => resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now()) ||
|
|
418
|
+
(!!accounts && accounts.roomSince(pause.agentId, pause.provider, timestamp(pause.pausedAt)!) !== null);
|
|
400
419
|
const byId = new Map(agents.map(a => [a.id, a]));
|
|
401
420
|
this.providers = new Map(agents.map(a => [a.id, a.provider]));
|
|
402
421
|
const currentDaemon = await this.processes?.daemonIdentity?.();
|
|
@@ -465,7 +484,7 @@ export class Engine {
|
|
|
465
484
|
if ((agent.activeTurn?.turnId ?? null) !== pause.turnId && !await nativeRunning(agent.id)) {
|
|
466
485
|
await this.cancel(pause, "The suspended turn was stopped or changed outside Session Guard."); changed = true; continue;
|
|
467
486
|
}
|
|
468
|
-
if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !
|
|
487
|
+
if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !ready(pause)) continue;
|
|
469
488
|
const latest = (await client.fetchAgent(pause.agentId))?.agent;
|
|
470
489
|
if (!latest || latest.archivedAt || latest.status === "closed" || latest.provider !== pause.provider ||
|
|
471
490
|
(latest.lastUserMessageAt ?? null) !== pause.lastUserMessageAt ||
|
|
@@ -488,8 +507,10 @@ export class Engine {
|
|
|
488
507
|
await this.cancel(pause, "Work was continued outside Session Guard."); changed = true; continue;
|
|
489
508
|
}
|
|
490
509
|
const canContinue = (candidate: Agent) => candidate.status === "idle" || (pause.closedOnRestart && candidate.status === "closed") || (pause.wasQuotaFailure && candidate.status === "error");
|
|
510
|
+
// With ZeroSub, room on another account also continues it: the continuation meets the
|
|
511
|
+
// limit on this account, and ZeroSub fails it over there and carries it on.
|
|
491
512
|
if (!canContinue(agent) || agent.pendingPermissions?.length || !policy.autoResume ||
|
|
492
|
-
this.now() < this.nextResumeAt || !
|
|
513
|
+
this.now() < this.nextResumeAt || !ready(pause)) continue;
|
|
493
514
|
// Re-read just before sending: a person may have continued while usage was fetched.
|
|
494
515
|
const latest = (await client.fetchAgent(pause.agentId))?.agent;
|
|
495
516
|
if (!latest || !canContinue(latest) || latest.archivedAt || latest.pendingPermissions?.length ||
|
|
@@ -529,6 +550,9 @@ export class Engine {
|
|
|
529
550
|
const window = sessionWindow(provider, policy);
|
|
530
551
|
if (!window || (usedPct(window) ?? -1) < policy.thresholdPct) continue;
|
|
531
552
|
if (failsOver(agent.id, agent.provider)) continue;
|
|
553
|
+
// Past the limit notice it is ZeroSub that switches, by its own rule: leave it be
|
|
554
|
+
// while that rule has an account to move to.
|
|
555
|
+
if (quotaFailed && accounts?.switchesAtLimit(agent.id, agent.provider)) continue;
|
|
532
556
|
const fetchedAt = timestamp(provider?.fetchedAt ?? own.fetchedAt);
|
|
533
557
|
const windowEnd = timestamp(window.resetsAt);
|
|
534
558
|
if (windowEnd !== null && windowEnd <= this.now() && (fetchedAt === null || fetchedAt <= windowEnd)) continue;
|
package/server/guard/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* pass, and gives the lock back once no pause is left waiting on it.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type { PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
|
|
19
|
+
import type { PluginHandlerContext, PluginHookContext, PluginServerContext } from "@getpaseo/plugin/server";
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
agentStatusRpc,
|
|
@@ -44,6 +44,18 @@ import { timelinePublisher } from "./timeline.ts";
|
|
|
44
44
|
import { withRecordedUsage } from "./usage.ts";
|
|
45
45
|
import { readZeroSub } from "./zerosub.ts";
|
|
46
46
|
import { accountView } from "../../shared/guard/zerosub.ts";
|
|
47
|
+
import { limitNotice, turnRows, type TurnRow } from "../../shared/guard/limit-notice.ts";
|
|
48
|
+
|
|
49
|
+
/** How many timeline rows to read back for a finished turn's limit notice (as ZeroSub). */
|
|
50
|
+
const NOTICE_ROWS = 40;
|
|
51
|
+
|
|
52
|
+
/** Whether a finished turn ended on the CLI's limit notice, read from the agent's timeline tail. */
|
|
53
|
+
async function readLimitNotice(context: PluginHookContext, agentId: string, provider: string, turnId: string): Promise<boolean> {
|
|
54
|
+
if (provider !== "claude" && provider !== "codex") return false;
|
|
55
|
+
const page = await context.paseo.agents.ref(agentId).timeline.refetch({ direction: "tail", limit: NOTICE_ROWS });
|
|
56
|
+
const entries = page.entries as unknown as readonly { item: TurnRow; turnId?: string }[];
|
|
57
|
+
return limitNotice(provider, turnRows(entries, turnId)) !== null;
|
|
58
|
+
}
|
|
47
59
|
|
|
48
60
|
/** The install id this plugin's own RPCs are reached through. */
|
|
49
61
|
const PLUGIN_ID = "smart-session";
|
|
@@ -366,12 +378,24 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
366
378
|
const removeTurnStarted = server.on("agent.turn_started", () => {
|
|
367
379
|
if (governor !== "off") void refresh();
|
|
368
380
|
});
|
|
369
|
-
const removeTurnEnded = server.on("agent.turn_ended", (event) => {
|
|
381
|
+
const removeTurnEnded = server.on("agent.turn_ended", (event, context) => {
|
|
370
382
|
if (governor !== "active") return;
|
|
371
383
|
// Never await a governor tick inside a lifecycle hook: cancelAgent itself causes
|
|
372
384
|
// turn_ended, and awaiting both would deadlock.
|
|
373
|
-
if (!stopped) void engine.turnEnded(event.agent.id, event.turnId, event.outcome.kind).catch(() => undefined);
|
|
374
385
|
if (event.outcome.kind === "failed") engine.quotaFailure(event.agent.id, event.outcome.error.message, event.turnId);
|
|
386
|
+
// Claude completes a turn at the limit with a notice instead of failing it. Under
|
|
387
|
+
// ZeroSub, when no account has room, that notice is all there is: read it so the
|
|
388
|
+
// agent is paused and continued after a reset, and so a pause racing it is kept
|
|
389
|
+
// rather than cancelled as finished work. Without ZeroSub nothing changes.
|
|
390
|
+
if (event.outcome.kind === "completed" && event.turnId && engine.accounts && !stopped) {
|
|
391
|
+
const turnId = event.turnId;
|
|
392
|
+
void readLimitNotice(context, event.agent.id, event.agent.provider, turnId).catch(() => false).then(async (found) => {
|
|
393
|
+
if (stopped) return;
|
|
394
|
+
if (found) engine.limitReached(event.agent.id, turnId);
|
|
395
|
+
await engine.turnEnded(event.agent.id, turnId, "completed", found).catch(() => undefined);
|
|
396
|
+
void refresh();
|
|
397
|
+
});
|
|
398
|
+
} else if (!stopped) void engine.turnEnded(event.agent.id, event.turnId, event.outcome.kind).catch(() => undefined);
|
|
375
399
|
void refresh();
|
|
376
400
|
});
|
|
377
401
|
|
package/server/recorder.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
noteNewestUsage,
|
|
27
27
|
type ContextSample,
|
|
28
28
|
} from "./store.ts";
|
|
29
|
+
import { forgetAccounts, recordAccounts } from "./subscriptions.ts";
|
|
29
30
|
import { fetchUpstreamUsage } from "./upstream.ts";
|
|
30
31
|
import {
|
|
31
32
|
acceptReading,
|
|
@@ -182,6 +183,15 @@ async function pollUpstream(): Promise<void> {
|
|
|
182
183
|
if (sample !== null) await record(sample);
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/** ZeroSub's other accounts, kept as ZeroSub reads them; nothing happens without ZeroSub. */
|
|
187
|
+
async function pollAccounts(): Promise<void> {
|
|
188
|
+
try {
|
|
189
|
+
await recordAccounts();
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error("[smart-session] could not record ZeroSub's account readings", String(error));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
185
195
|
async function pollDaemon(): Promise<void> {
|
|
186
196
|
await withDaemon(async (client) => {
|
|
187
197
|
const at = new Date().toISOString();
|
|
@@ -232,6 +242,7 @@ function startRecorder(): void {
|
|
|
232
242
|
await pollUpstream();
|
|
233
243
|
await pollDaemon();
|
|
234
244
|
await pollClaudeJson();
|
|
245
|
+
await pollAccounts();
|
|
235
246
|
await pollSpend();
|
|
236
247
|
} catch (error) {
|
|
237
248
|
// A daemon restart, a lost socket, a machine asleep — none of these should
|
|
@@ -251,6 +262,7 @@ function startRecorder(): void {
|
|
|
251
262
|
clearInterval(timer);
|
|
252
263
|
lastContext.clear();
|
|
253
264
|
clearCaches();
|
|
265
|
+
forgetAccounts();
|
|
254
266
|
});
|
|
255
267
|
}
|
|
256
268
|
|