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.
@@ -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
@@ -1,6 +1,6 @@
1
- # Pause at the limit
1
+ # Pause at the limit (Session Guard)
2
2
 
3
- Pause at the limit (formerly the Session Guard plugin, and still called Session Guard in the code and data paths) 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.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-smart-session",
3
3
  "type": "module",
4
- "version": "3.2.0",
4
+ "version": "3.4.0",
5
5
  "license": "MIT",
6
6
  "description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
7
7
  "repository": {
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"
@@ -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 || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
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 || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
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;
@@ -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
 
@@ -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