paseo-smart-session 3.3.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
 
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.3.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"
@@ -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
 
package/server/store.ts CHANGED
@@ -73,7 +73,18 @@ export interface ContextSample {
73
73
  readonly title: string | null;
74
74
  }
75
75
 
76
- type LogKind = "usage" | "context";
76
+ /**
77
+ * One ZeroSub account's reading, as ZeroSub took it (`usage.json`), kept so each
78
+ * account gets a pace and a forecast. Shaped like a usage sample, so the same block
79
+ * logic reads it, plus the account it belongs to: ZeroSub's id, never an email.
80
+ */
81
+ export interface AccountSample extends Omit<UsageSample, "src"> {
82
+ readonly src: "zerosub";
83
+ readonly accountId: string;
84
+ readonly family: "claude" | "codex";
85
+ }
86
+
87
+ type LogKind = "usage" | "context" | "accounts";
77
88
 
78
89
  /** Monthly files: small enough to read whole, coarse enough to stay few. */
79
90
  function logPath(kind: LogKind, at: string): string {
@@ -113,6 +124,10 @@ export async function appendContext(sample: ContextSample): Promise<void> {
113
124
  await append("context", sample.at, sample);
114
125
  }
115
126
 
127
+ export async function appendAccount(sample: AccountSample): Promise<void> {
128
+ await append("accounts", sample.at, sample);
129
+ }
130
+
116
131
  /**
117
132
  * Reads a log back.
118
133
  *
@@ -169,6 +184,14 @@ export async function readUsage(options: { sinceMs?: number } = {}): Promise<Usa
169
184
  return since === null ? rows : rows.filter((row) => Date.parse(row.at) >= since);
170
185
  }
171
186
 
187
+ export async function readAccounts(options: { sinceMs?: number } = {}): Promise<AccountSample[]> {
188
+ const since = options.sinceMs ?? null;
189
+ const rows = await readLog<AccountSample>("accounts", since);
190
+ // Defensive: a row from a future shape, or a hand-edited one, is skipped, not thrown on.
191
+ return rows.filter((row) => typeof row?.accountId === "string" && typeof row.at === "string" && typeof row.windows === "object" && row.windows !== null
192
+ && (since === null || Date.parse(row.at) >= since));
193
+ }
194
+
172
195
  export async function readContext(options: { sinceMs?: number } = {}): Promise<ContextSample[]> {
173
196
  const since = options.sinceMs ?? null;
174
197
  const rows = await readLog<ContextSample>("context", since);