paseo-smart-session 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/client/display.tsx +2 -2
- package/client/kit.tsx +2 -2
- package/client/live.ts +8 -1
- package/client/overview.tsx +59 -18
- package/client/popover.tsx +26 -7
- package/client/settings.tsx +3 -3
- package/client/subscriptions-view.tsx +201 -0
- package/client/subscriptions.ts +68 -0
- package/docs/media/before-after.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/media/smart-session-settings.png +0 -0
- package/docs/media/smart-session-subscriptions-light.png +0 -0
- package/docs/media/smart-session-subscriptions.png +0 -0
- package/docs/session-guard.md +4 -2
- package/index.client.tsx +4 -0
- package/index.server.ts +13 -0
- package/mcp.mjs +22 -0
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/engine.ts +27 -3
- package/server/guard/index.ts +27 -3
- package/server/recorder.ts +12 -0
- package/server/store.ts +24 -1
- package/server/subscriptions.ts +208 -0
- package/shared/guard/limit-notice.ts +87 -0
- package/shared/guard/zerosub.ts +109 -12
- package/shared/smart-session.ts +66 -0
- package/shared/subscriptions.ts +540 -0
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Each session has one pill above its message box: a ring of its context, with a t
|
|
|
35
35
|
|
|
36
36
|

|
|
37
37
|
|
|
38
|
-

|
|
39
39
|
|
|
40
40
|
Agents get these tools:
|
|
41
41
|
|
|
@@ -59,7 +59,23 @@ After compaction, the agent chooses to stop (`continue_after_compaction: false`)
|
|
|
59
59
|
|
|
60
60
|
The forecast uses the last half hour for the session window and the window's own average for a week, so a burst of work doesn't read as "the week runs out tonight".
|
|
61
61
|
|
|
62
|
-
##
|
|
62
|
+
## Every ZeroSub subscription
|
|
63
|
+
|
|
64
|
+
With [ZeroSub](https://github.com/kapybara-org/zerosub) running several Claude or Codex subscriptions, the question becomes how long all of them last, which one is next, and whether the work makes it to a reset. ZeroSub manages the accounts; Smart Session adds the time.
|
|
65
|
+
|
|
66
|
+
- **The pool, as a time.** The overview leads with "Claude · 3 subscriptions last until about 9:40 PM", or "Both Claude subscriptions are at the limit · first one back at 5:58 PM". The forecast runs each account's own pace forward and moves work the way ZeroSub does, to the account with the most room, through each account's resets.
|
|
67
|
+
- **A runway.** One lane per account across the next 12 hours: in use, room, at the limit. Reading down a column says who carries the work then.
|
|
68
|
+
- **One row per account.** The name you gave it in ZeroSub (else "CLI login" or "Account 2", never the email), the 5-hour and weekly windows with their resets, the sessions on it, when it runs out, and how old its reading is. Codex credits show when ZeroSub reports them.
|
|
69
|
+
- **Everywhere else.** The sidebar row summarises the pool, amber or red only when the pool is in trouble. The pill's popover says which subscription the session runs on and what happens at its limit. `budget_status` tells agents the pooled runway and which account is next.
|
|
70
|
+
|
|
71
|
+
Smart Session keeps each account's readings in its own history (by ZeroSub's account id, never the email), so every account has a pace. ZeroSub's files are only read. A reading older than half an hour says so and adds no pace. Without ZeroSub, nothing changes.
|
|
72
|
+
|
|
73
|
+
<picture>
|
|
74
|
+
<source media="(prefers-color-scheme: light)" srcset="docs/media/smart-session-subscriptions-light.png">
|
|
75
|
+
<img alt="The Smart Session overview with ZeroSub: the headline Claude · 3 subscriptions last past Sat 1:05 AM, Account 3 takes over at 2:12 AM, then Personal at 10:03 AM. A runway with one lane per Claude account showing when each is in use, has room or is at the limit, a row per account with its 5-hour and weekly windows, reset times and when it runs out, and a Codex card with credits" src="docs/media/smart-session-subscriptions.png">
|
|
76
|
+
</picture>
|
|
77
|
+
|
|
78
|
+
## Pause at the limit (Session Guard)
|
|
63
79
|
|
|
64
80
|
When the provider's session window is spent, Smart Session pauses running sessions instead of letting them fail, and continues the same conversations once the window resets, even with the app closed. It is **off by default**: turn it on from the overview or in settings. It was the standalone [Session Guard](https://github.com/tomgrin10/paseo-session-guard) plugin, and its per-session choices and waiting pauses carry over.
|
|
65
81
|
|
|
@@ -71,7 +87,7 @@ If the standalone Session Guard plugin is still enabled, it keeps doing the paus
|
|
|
71
87
|
|
|
72
88
|
On a phone, the pill opens as a sheet and every screen fits one column.
|
|
73
89
|
|
|
74
|
-

|
|
75
91
|
|
|
76
92
|
## Settings
|
|
77
93
|
|
|
@@ -83,7 +99,7 @@ Everything is on one screen, **Settings → Plugins → Smart Session**, in the
|
|
|
83
99
|
| | Enrol sessions automatically | On | Enrol a session once it writes task state |
|
|
84
100
|
| When to ask | Windows of 400k or more | 30% | Where large windows are asked to compact |
|
|
85
101
|
| | Smaller windows | 85% | Where smaller windows are asked to compact |
|
|
86
|
-
| Pause at the limit | Pause at the limit | Off | Pause sessions at the session limit |
|
|
102
|
+
| Pause at the limit (Session Guard) | Pause at the limit (Session Guard) | Off | Pause sessions at the session limit |
|
|
87
103
|
| | Sessions | Every session | Or only the ones you turn on from their pill |
|
|
88
104
|
| | Continue after the reset | On | Off, you continue each one by hand |
|
|
89
105
|
| | Include native subagents | On | Freeze nested workers too (Linux) |
|
|
@@ -95,13 +111,13 @@ Everything is on one screen, **Settings → Plugins → Smart Session**, in the
|
|
|
95
111
|
|
|
96
112
|
Hiding the row or the pill never turns a feature off.
|
|
97
113
|
|
|
98
|
-

|
|
114
|
+

|
|
99
115
|
|
|
100
116
|
## Data and access
|
|
101
117
|
|
|
102
|
-
Everything lives in `$PASEO_HOME/plugin-data/smart-session/`: monthly usage and context logs, compaction records, an hourly spend index by model, workspace and agent type, and one `state/<agentId>.md` per agent. Logs are append-only, and a damaged line is skipped rather than risking the rest.
|
|
118
|
+
Everything lives in `$PASEO_HOME/plugin-data/smart-session/`: monthly usage and context logs (and, with ZeroSub, a monthly log of each subscription's readings, keyed by account id), compaction records, an hourly spend index by model, workspace and agent type, and one `state/<agentId>.md` per agent. Logs are append-only, and a damaged line is skipped rather than risking the rest.
|
|
103
119
|
|
|
104
|
-
Paseo plugins run trusted, unsandboxed code on the daemon host. Smart Session reads local Claude Code usage data and talks only to the local daemon. For password-protected daemons it reads `PASEO_PASSWORD`, then `PASEO_PASSWORD_FILE`, then `~/paseo-hub/secrets/daemon-password`, and never writes or logs it.
|
|
120
|
+
Paseo plugins run trusted, unsandboxed code on the daemon host. Smart Session reads local Claude Code usage data, reads ZeroSub's account registry and usage readings when ZeroSub is enabled (never its account homes or credentials), and talks only to the local daemon. For password-protected daemons it reads `PASEO_PASSWORD`, then `PASEO_PASSWORD_FILE`, then `~/paseo-hub/secrets/daemon-password`, and never writes or logs it.
|
|
105
121
|
|
|
106
122
|
## Development
|
|
107
123
|
|
package/client/display.tsx
CHANGED
|
@@ -30,8 +30,8 @@ export function contributeDisplay(client: PluginClientContext, live: Live) {
|
|
|
30
30
|
active={currentScreen?.screenId === OVERVIEW_SCREEN || currentScreen?.screenId === "paused-sessions"}
|
|
31
31
|
onPress={() => openScreen({ screenId: OVERVIEW_SCREEN })}
|
|
32
32
|
trailing={trailing === null ? undefined : trailing.tone === "muted"
|
|
33
|
-
? <Text accessibilityLabel={
|
|
34
|
-
: <Chip theme={theme} icon={trailing.label.endsWith("paused") ? "Pause" : undefined} label={trailing.label} color={toneColor(c, trailing.tone)} />}
|
|
33
|
+
? <Text accessibilityLabel={trailing.a11y} numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, ...tabular }}>{trailing.label}</Text>
|
|
34
|
+
: <Chip theme={theme} icon={trailing.label.endsWith("paused") ? "Pause" : undefined} label={trailing.label} color={toneColor(c, trailing.tone)} accessibilityLabel={trailing.a11y} />}
|
|
35
35
|
/>
|
|
36
36
|
);
|
|
37
37
|
}
|
package/client/kit.tsx
CHANGED
|
@@ -85,10 +85,10 @@ export function CardRow({ theme, onPress, accessibilityLabel, children, style, f
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/** A count or status tag in its own hue at 12–16%. */
|
|
88
|
-
export function Chip({ theme, label, color, icon }: { theme: Theme; label: string; color?: string; icon?: string }) {
|
|
88
|
+
export function Chip({ theme, label, color, icon, accessibilityLabel }: { theme: Theme; label: string; color?: string; icon?: string; accessibilityLabel?: string }) {
|
|
89
89
|
const c = theme.colors;
|
|
90
90
|
const hue = color ?? c.foregroundMuted;
|
|
91
|
-
return <View style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 7, paddingVertical: 1, borderRadius: 999, backgroundColor: tint(hue, c), alignSelf: "center" }}>
|
|
91
|
+
return <View accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 7, paddingVertical: 1, borderRadius: 999, backgroundColor: tint(hue, c), alignSelf: "center" }}>
|
|
92
92
|
{icon ? <Icon name={icon} size={11} color={hue} /> : null}
|
|
93
93
|
<Text numberOfLines={1} style={{ color: hue, fontSize: 11, lineHeight: 16, fontWeight: "600", ...tabular }}>{label}</Text>
|
|
94
94
|
</View>;
|
package/client/live.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useSyncExternalStore } from "react";
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
|
|
4
|
-
import type { budgetStatus } from "../shared/smart-session";
|
|
4
|
+
import type { budgetStatus, subscriptionsStatus } from "../shared/smart-session";
|
|
5
5
|
|
|
6
6
|
type Budget = z.infer<(typeof budgetStatus)["output"]>;
|
|
7
|
+
type Subscriptions = z.infer<(typeof subscriptionsStatus)["output"]>;
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* What the sidebar row shows, fed by the polls that already run: the budget from
|
|
@@ -15,13 +16,17 @@ export interface Live {
|
|
|
15
16
|
version(): number;
|
|
16
17
|
budget(): Budget | null;
|
|
17
18
|
paused(): { waiting: number; attention: number };
|
|
19
|
+
/** ZeroSub's accounts, when it runs here; the row then speaks for the pool. */
|
|
20
|
+
subscriptions(): Subscriptions | null;
|
|
18
21
|
setBudget(budget: Budget): void;
|
|
22
|
+
setSubscriptions(next: Subscriptions): void;
|
|
19
23
|
setPaused(counts: { waiting: number; attention: number }): void;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export function createLive(): Live {
|
|
23
27
|
let budget: Budget | null = null;
|
|
24
28
|
let paused = { waiting: 0, attention: 0 };
|
|
29
|
+
let subscriptions: Subscriptions | null = null;
|
|
25
30
|
let version = 0;
|
|
26
31
|
const listeners = new Set<() => void>();
|
|
27
32
|
const notify = () => {
|
|
@@ -36,7 +41,9 @@ export function createLive(): Live {
|
|
|
36
41
|
version: () => version,
|
|
37
42
|
budget: () => budget,
|
|
38
43
|
paused: () => paused,
|
|
44
|
+
subscriptions: () => subscriptions,
|
|
39
45
|
setBudget(next) { budget = next; notify(); },
|
|
46
|
+
setSubscriptions(next) { subscriptions = next; notify(); },
|
|
40
47
|
setPaused(next) {
|
|
41
48
|
if (next.waiting === paused.waiting && next.attention === paused.attention) return;
|
|
42
49
|
paused = next; notify();
|
package/client/overview.tsx
CHANGED
|
@@ -16,6 +16,9 @@ import { budgetStatus, contextStatus, getSettings, installStatus, setSettings, s
|
|
|
16
16
|
import { DailyBars, HourHeatmap, Meter, PastWindows, RankedTotals, Ring, WindowChart, ink, toneFill } from "./charts";
|
|
17
17
|
import { Exceptions, PausedSection } from "./guard/paused";
|
|
18
18
|
import { guardSnapshot, refreshGuard, subscribeGuard } from "./guard/status";
|
|
19
|
+
import { hasSubscriptions, pools, subscribeSubscriptions, subscriptionsSnapshot } from "./subscriptions";
|
|
20
|
+
import { familyName, leadPool, poolHeadline, poolTrailing, subscriptionState } from "../shared/subscriptions";
|
|
21
|
+
import { SubscriptionsSection } from "./subscriptions-view";
|
|
19
22
|
import { Card, CardRow, Chip, IconButton, SectionHead, StatusGlyph, Switch, TextLink, errorText, text, useNow } from "./kit";
|
|
20
23
|
import type { Live } from "./live";
|
|
21
24
|
import { Reveal, Skeleton } from "./motion";
|
|
@@ -36,11 +39,12 @@ function toneIcon(tone: Tone, state: WindowForecast["state"] | null): string {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** A meter row for one plan window: label and percentage, meter, reset and pace. */
|
|
39
|
-
function WindowRow({ theme, f, now, first, children }: { theme: Theme; f: WindowForecast; now: number; first: boolean; children?: ReactNode }) {
|
|
42
|
+
function WindowRow({ theme, f, now, first, children, quiet = false, note = null }: { theme: Theme; f: WindowForecast; now: number; first: boolean; children?: ReactNode; quiet?: boolean; note?: string | null }) {
|
|
40
43
|
const c = theme.colors;
|
|
41
44
|
const t = text(c);
|
|
42
|
-
|
|
43
|
-
const
|
|
45
|
+
// Quiet: the Subscriptions section already says when this window runs out.
|
|
46
|
+
const pace = quiet ? "" : paceLine(f, now);
|
|
47
|
+
const budget = f.id === "five_hour" && !quiet ? budgetLine(f) : null;
|
|
44
48
|
return <CardRow theme={theme} first={first} style={{ gap: 6 }}>
|
|
45
49
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
46
50
|
<Text style={{ ...t.label, flex: 1 }}>{f.label}</Text>
|
|
@@ -53,6 +57,7 @@ function WindowRow({ theme, f, now, first, children }: { theme: Theme; f: Window
|
|
|
53
57
|
{pace ? <Text style={{ ...t.meta, color: f.state === "short" || f.state === "full" ? toneColor(c, f.tone) : c.foregroundMuted, fontWeight: f.state === "short" ? "600" : "400" }}>{pace}</Text> : null}
|
|
54
58
|
{budget ? <Text style={t.meta}>{budget}</Text> : null}
|
|
55
59
|
</View>
|
|
60
|
+
{note ? <Text style={{ ...t.meta, color: c.statusDanger }}>{note}</Text> : null}
|
|
56
61
|
{children}
|
|
57
62
|
</CardRow>;
|
|
58
63
|
}
|
|
@@ -136,6 +141,16 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
136
141
|
const installQuery = useQuery({ queryKey: ["smart-session", "install"], queryFn: () => readInstall({}), refetchInterval: 5 * 60_000 });
|
|
137
142
|
// The pill's own status poll; this screen only reads it.
|
|
138
143
|
const guard = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot) ?? undefined;
|
|
144
|
+
// ZeroSub's subscriptions, from the bundle's own poll; null or empty without ZeroSub.
|
|
145
|
+
const subs = useSyncExternalStore(subscribeSubscriptions, subscriptionsSnapshot, subscriptionsSnapshot);
|
|
146
|
+
const multi = hasSubscriptions(subs);
|
|
147
|
+
const subPools = multi ? pools(subs, now) : [];
|
|
148
|
+
const poolLead = leadPool(subPools.filter((p) => p.total > 1), now);
|
|
149
|
+
// The CLI's newer limit notice outranks the recorder's last reading; the history row says so.
|
|
150
|
+
const cliOut = multi ? subs.accounts.find((a) => a.id === "claude-main") : undefined;
|
|
151
|
+
const cliState = cliOut ? subscriptionState(cliOut, now) : null;
|
|
152
|
+
const cliOutNote = cliState?.kind === "limit" ? `ZeroSub reports this login ${cliState.label.charAt(0).toLowerCase()}${cliState.label.slice(1)}` : null;
|
|
153
|
+
const poolHead = poolLead === null || !multi ? null : poolHeadline(poolLead, subs.accounts.filter((a) => a.family === poolLead.family), now);
|
|
139
154
|
const focus = params?.focus === "paused" ? "paused" : null;
|
|
140
155
|
const scroller = useRef<ScrollViewType>(null);
|
|
141
156
|
const pausedY = useRef<number | null>(null);
|
|
@@ -151,7 +166,10 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
151
166
|
useEffect(() => { if (guardValues !== undefined) refreshGuard(); }, [guardValues]);
|
|
152
167
|
|
|
153
168
|
const settings = settingsQuery.data?.settings;
|
|
154
|
-
|
|
169
|
+
// A limit ZeroSub recorded on the CLI login is newer than the recorder's last reading: the session window is full until then.
|
|
170
|
+
const forecasts = (budgetQuery.data?.windows ?? []).filter(isInteresting)
|
|
171
|
+
.map((w) => w.id === "five_hour" && cliState?.kind === "limit" && cliState.until !== null ? { ...w, pct: 100, resetsAt: new Date(cliState.until).toISOString(), burnPctPerHour: null } : w)
|
|
172
|
+
.map((w) => forecastWindow(w, now)).sort((a, b) => ORDER(a.id) - ORDER(b.id) || b.pct - a.pct);
|
|
155
173
|
const lead = leadWindow(forecasts);
|
|
156
174
|
const head = budgetQuery.data === undefined ? null : headline(lead, now);
|
|
157
175
|
const session = budgetQuery.data?.windows.find((w) => w.id === "five_hour");
|
|
@@ -209,6 +227,13 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
209
227
|
const auto = guard?.policy.autoResume !== false;
|
|
210
228
|
// Paused sessions have their own section and the lead; this line keeps to the rule.
|
|
211
229
|
if (paused.length > 0) return { line: `${paused.length} paused · listed above`, tone: "default" };
|
|
230
|
+
// Under ZeroSub the pause comes only once no subscription has room: say when that is.
|
|
231
|
+
const claudePool = subPools.find((p) => p.family === "claude" && p.total > 1);
|
|
232
|
+
if (claudePool && subs?.autoSwitch) {
|
|
233
|
+
if (claudePool.runsOutAt !== null && claudePool.runsOutAt <= now) return { line: `No Claude subscription has room · a running session pauses${claudePool.backAt !== null ? ` until ${formatMoment(new Date(claudePool.backAt).toISOString(), now)}` : ""}`, tone: "warning" };
|
|
234
|
+
if (claudePool.runsOutAt !== null) return { line: `ZeroSub switches first · pauses sessions at ~${formatMoment(new Date(claudePool.runsOutAt).toISOString(), now)}, when no subscription has room`, tone: "default" };
|
|
235
|
+
return { line: "ZeroSub switches first · pauses only when no subscription has room", tone: "default" };
|
|
236
|
+
}
|
|
212
237
|
const threshold = guard?.policy.thresholdPct ?? 100;
|
|
213
238
|
const s5 = lead?.id === "five_hour" ? lead : null;
|
|
214
239
|
if (s5 && s5.pct >= threshold) {
|
|
@@ -224,11 +249,14 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
224
249
|
})();
|
|
225
250
|
|
|
226
251
|
// While something is paused here, that is the news; the plan forecast moves to the line below.
|
|
227
|
-
|
|
252
|
+
// Under ZeroSub the pool is the plan: how long all the subscriptions last, not one login.
|
|
253
|
+
const planHead = poolHead ?? head;
|
|
254
|
+
const title = pauseLead !== null ? pauseLead.title : planHead?.title ?? null;
|
|
228
255
|
const autoResume = guard?.policy.enabled === true && guard.policy.autoResume;
|
|
229
|
-
const detail = pauseLead !== null ? [
|
|
256
|
+
const detail = pauseLead !== null ? [planHead?.title, pauseLead.shared !== null ? `in ${formatDuration(pauseLead.shared - now)}` : null, autoResume ? "each continues in its own conversation" : "continue each by hand"].filter(Boolean).join(" · ") : planHead?.detail ?? null;
|
|
230
257
|
const glyph = pauseLead !== null ? { icon: pauseLead.tone === "danger" ? "TriangleAlert" : "Pause", color: toneColor(c, pauseLead.tone) }
|
|
231
|
-
:
|
|
258
|
+
: poolHead ? { icon: poolHead.tone === "danger" ? "OctagonAlert" : poolHead.tone === "warning" ? "Hourglass" : "Layers", color: poolHead.tone === "default" ? c.foregroundMuted : toneColor(c, poolHead.tone) }
|
|
259
|
+
: head ? { icon: toneIcon(head.tone, lead?.state ?? null), color: head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone) } : null;
|
|
232
260
|
const header = <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
|
|
233
261
|
{glyph ? <View style={{ paddingTop: compact ? 3 : 5 }}><Icon name={glyph.icon} size={compact ? 17 : 19} color={glyph.color} /></View> : null}
|
|
234
262
|
<View style={{ flex: 1, gap: 4 }}>
|
|
@@ -252,13 +280,15 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
252
280
|
{header}
|
|
253
281
|
{error ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{error}</Text> : null}
|
|
254
282
|
{pausedSection}
|
|
283
|
+
{multi ? <SubscriptionsSection theme={theme} compact={compact} accounts={subs.accounts} pools={subPools} now={now} autoSwitch={subs.autoSwitch} leadFamily={pauseLead === null && poolHead !== null ? poolLead?.family ?? null : null} /> : null}
|
|
255
284
|
|
|
256
285
|
<View style={{ gap: 8 }}>
|
|
257
|
-
|
|
286
|
+
{/* Under ZeroSub these are the CLI login's windows, with the recorder's full history. */}
|
|
287
|
+
<SectionHead theme={theme} title={multi ? "CLI login · history" : "Plan limits"} />
|
|
258
288
|
{budgetQuery.data === undefined ? <LoadingCard theme={theme} />
|
|
259
289
|
: forecasts.length === 0 ? <Empty theme={theme} title="No reading yet" detail="Smart Session records Claude's plan usage every minute while Paseo runs. The first reading usually arrives within a few minutes of signing in to Claude on this host." />
|
|
260
290
|
: <Reveal><Card theme={theme}>
|
|
261
|
-
{forecasts.map((f, i) => <WindowRow key={f.id} theme={theme} f={f} now={now} first={i === 0}>
|
|
291
|
+
{forecasts.map((f, i) => <WindowRow key={f.id} theme={theme} f={f} now={now} first={i === 0} quiet={multi && (f.id === "five_hour" || f.id === "seven_day")} note={f.id === "five_hour" ? cliOutNote : null}>
|
|
262
292
|
{f.id === "five_hour" && session?.history && session.history.length > 0 ? <View style={{ gap: 14, paddingTop: 10 }}>
|
|
263
293
|
<WindowChart theme={theme} forecast={f} history={session.history} now={now} compact={compact} guardPct={guardPct} />
|
|
264
294
|
{budgetQuery.data?.past && budgetQuery.data.past.length > 0 ? <View style={{ gap: 2 }}>
|
|
@@ -311,7 +341,7 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
311
341
|
<Text style={{ ...t.meta, flex: 1, color: c.statusWarning }}>{hooksProblem}</Text>
|
|
312
342
|
</View> : null}
|
|
313
343
|
</FeatureRow>
|
|
314
|
-
<FeatureRow theme={theme} first={false} icon="CirclePause" title="Pause at the limit" line={guardState.line} tone={guardState.tone} on={guardOn} compact={compact} disabled={guardSettings.saving} onToggle={toggleGuard}
|
|
344
|
+
<FeatureRow theme={theme} first={false} icon="CirclePause" title="Pause at the limit (Session Guard)" line={guardState.line} tone={guardState.tone} on={guardOn} compact={compact} disabled={guardSettings.saving} onToggle={toggleGuard}
|
|
315
345
|
links={<TextLink theme={theme} compact={compact} label="Settings" onPress={() => client.openSettings(SMART_COMPACT_SETTINGS)} />}>
|
|
316
346
|
{guard !== undefined && guardOn ? <Exceptions theme={theme} layout={layout} status={guard} titleOf={titleOf} /> : null}
|
|
317
347
|
</FeatureRow>
|
|
@@ -363,18 +393,29 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
363
393
|
};
|
|
364
394
|
}
|
|
365
395
|
|
|
366
|
-
/** The sidebar row's right slot: what is paused, else the session window. */
|
|
367
|
-
export function sidebarTrailing(live: Live, now: number): { label: string; tone: Tone | "muted" } | null {
|
|
396
|
+
/** The sidebar row's right slot: what is paused, else the pool of subscriptions, else the session window. */
|
|
397
|
+
export function sidebarTrailing(live: Live, now: number): { label: string; tone: Tone | "muted"; a11y: string } | null {
|
|
368
398
|
const paused = live.paused();
|
|
369
|
-
if (paused.attention > 0) return { label: `${paused.attention + paused.waiting} paused`, tone: "danger" };
|
|
370
|
-
if (paused.waiting > 0) return { label: `${paused.waiting} paused`, tone: "warning" };
|
|
399
|
+
if (paused.attention > 0) return { label: `${paused.attention + paused.waiting} paused`, tone: "danger", a11y: `${paused.attention + paused.waiting} sessions paused` };
|
|
400
|
+
if (paused.waiting > 0) return { label: `${paused.waiting} paused`, tone: "warning", a11y: `${paused.waiting} sessions paused` };
|
|
401
|
+
// Under ZeroSub one login is not the story: the row speaks for the pool, and is
|
|
402
|
+
// amber or red only when the pool, not a single account, runs short.
|
|
403
|
+
const subs = live.subscriptions();
|
|
404
|
+
if (hasSubscriptions(subs)) {
|
|
405
|
+
const pool = leadPool(pools(subs, now), now);
|
|
406
|
+
if (pool !== null && pool.total > 1) {
|
|
407
|
+
const slot = poolTrailing(pool, now);
|
|
408
|
+
const fam = familyName(pool.family);
|
|
409
|
+
const a11y = slot.tone === "muted" ? `${fam}: ${pool.withRoom} of ${pool.total} subscriptions with room` : `${fam} subscriptions: ${slot.label}`;
|
|
410
|
+
return { ...slot, a11y };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
371
413
|
const windows = live.budget()?.windows ?? [];
|
|
372
414
|
const session = windows.find((w) => w.id === "five_hour");
|
|
373
415
|
if (session === undefined) return null;
|
|
374
416
|
const f = forecastWindow(session, now);
|
|
375
417
|
// Usage is a time: when the window runs out first, the row says when.
|
|
376
|
-
if (f.state === "short" && f.runsOutAt) return { label: `out ${formatMoment(f.runsOutAt, now)}`, tone: f.tone };
|
|
377
|
-
if (f.state === "full") return { label: "at limit", tone: "danger" };
|
|
378
|
-
return { label: `${Math.round(f.pct)}%`, tone: "muted" };
|
|
418
|
+
if (f.state === "short" && f.runsOutAt) return { label: `out ${formatMoment(f.runsOutAt, now)}`, tone: f.tone, a11y: `Session window runs out ${formatMoment(f.runsOutAt, now)}` };
|
|
419
|
+
if (f.state === "full") return { label: "at limit", tone: "danger", a11y: "Session window at the limit" };
|
|
420
|
+
return { label: `${Math.round(f.pct)}%`, tone: "muted", a11y: `Session window ${Math.round(f.pct)}% used` };
|
|
379
421
|
}
|
|
380
|
-
|
package/client/popover.tsx
CHANGED
|
@@ -18,6 +18,8 @@ import { Button, Switch, TextLink, errorText, text, useNow } from "./kit";
|
|
|
18
18
|
import { noteEnrolment, pillSnapshot, subscribePills } from "./pill-store";
|
|
19
19
|
import { tabular, toneColor } from "./theme";
|
|
20
20
|
import { SETTINGS_SCREEN } from "./screens";
|
|
21
|
+
import { hasSubscriptions, subscribeSubscriptions, subscriptionsSnapshot } from "./subscriptions";
|
|
22
|
+
import { atTheLimit, sessionWindow, subscriptionState, weeklyWindow } from "../shared/subscriptions";
|
|
21
23
|
|
|
22
24
|
/** What the last compaction did, in one line. */
|
|
23
25
|
function compactionLine(item: CompactionRequest | undefined, now: number): string {
|
|
@@ -38,6 +40,7 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
38
40
|
const now = useNow(15_000);
|
|
39
41
|
const pills = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
|
|
40
42
|
const guard = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot);
|
|
43
|
+
const subs = useSyncExternalStore(subscribeSubscriptions, subscriptionsSnapshot, subscriptionsSnapshot);
|
|
41
44
|
useSyncExternalStore(agents.subscribe, agents.version, agents.version);
|
|
42
45
|
const readContext = useRpc(contextStatus);
|
|
43
46
|
const readCompactions = useRpc(listCompactions);
|
|
@@ -112,6 +115,21 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
112
115
|
["Task state", state === null ? (row === undefined ? "…" : "Not written yet") : `Saved ${formatRelative(state, now)}`],
|
|
113
116
|
["Last compaction", compactions.data === undefined ? "…" : compactionLine(latest, now)],
|
|
114
117
|
];
|
|
118
|
+
// Under ZeroSub with several subscriptions: which one this session runs on, and what happens at its limit.
|
|
119
|
+
const account = (() => {
|
|
120
|
+
if (!hasSubscriptions(subs)) return null;
|
|
121
|
+
const own = subs.accounts.find((a) => a.id === subs.agents[agentId]);
|
|
122
|
+
if (!own || subs.accounts.filter((a) => a.family === own.family).length < 2) return null;
|
|
123
|
+
const pct = (w: { pct: number; resetsAt: string | null } | null) => w === null ? null : w.resetsAt !== null && Date.parse(w.resetsAt) <= now ? 0 : Math.round(w.pct);
|
|
124
|
+
const s5 = pct(sessionWindow(own));
|
|
125
|
+
const week = pct(weeklyWindow(own));
|
|
126
|
+
const state = subscriptionState(own, now);
|
|
127
|
+
const windows = [s5 === null ? null : `5h ${s5}%`, week === null ? null : `week ${week}%`].filter(Boolean).join(" · ");
|
|
128
|
+
const value = [own.name, state.kind === "room" ? windows : state.label].filter(Boolean).join(" · ");
|
|
129
|
+
const next = atTheLimit(subs.accounts, own.id, now, { autoSwitch: subs.autoSwitch, protectedHere: protection.kind === "on" && protection.protected });
|
|
130
|
+
return { value, next, tone: state.kind === "limit" ? "danger" as const : null };
|
|
131
|
+
})();
|
|
132
|
+
if (account) facts.push(["Subscription", account.value]);
|
|
115
133
|
const ringColor = head === null ? c.foregroundMuted : head.outlook.tone !== "default" ? toneFill(c, head.outlook.tone) : enrolled ? c.foreground : c.foregroundMuted;
|
|
116
134
|
const part = { gap: 6, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border } as const;
|
|
117
135
|
|
|
@@ -157,12 +175,12 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
157
175
|
if (protection.kind === "unknown") return null;
|
|
158
176
|
if (protection.kind === "on") {
|
|
159
177
|
const meta = protection.protected
|
|
160
|
-
? `Pauses at ${protection.thresholdPct}
|
|
178
|
+
? `Pauses at ${protection.thresholdPct}%, ${protection.autoResume ? "continues after the reset" : "you continue after the reset"}`
|
|
161
179
|
: "Off for this session · at the limit the provider refuses work and nothing continues it";
|
|
162
180
|
const source = protection.override === null ? (protection.byDefault ? "Your default" : "Your default is off") : "Changed for this session";
|
|
163
181
|
return <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
164
182
|
<View style={{ flex: 1, gap: 2 }}>
|
|
165
|
-
<Text style={t.label}>Pause at the limit</Text>
|
|
183
|
+
<Text style={t.label}>Pause at the limit (Session Guard)</Text>
|
|
166
184
|
<Text style={t.meta}>{meta}</Text>
|
|
167
185
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
|
168
186
|
<Text style={t.small}>{source}</Text>
|
|
@@ -173,9 +191,9 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
173
191
|
</View>;
|
|
174
192
|
}
|
|
175
193
|
// Not running here: one calm line, and where to change it.
|
|
176
|
-
const line = protection.kind === "off" ? "Pause at the limit is off on this host"
|
|
177
|
-
: protection.kind === "elsewhere" ? (protection.standalone ? "The standalone Session Guard plugin handles pausing at the limit here" : "Another process
|
|
178
|
-
:
|
|
194
|
+
const line = protection.kind === "off" ? "Pause at the limit (Session Guard) is off on this host"
|
|
195
|
+
: protection.kind === "elsewhere" ? (protection.standalone ? "The standalone Session Guard plugin handles pausing at the limit here" : "Another process runs Session Guard here")
|
|
196
|
+
: `${governorNotice(guard!)?.title ?? "Pause at the limit isn't running"} (Session Guard)`;
|
|
179
197
|
return <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
180
198
|
<Icon name="CirclePause" size={13} color={c.foregroundMuted} />
|
|
181
199
|
<Text style={{ ...t.meta, flex: 1 }}>{line}</Text>
|
|
@@ -202,14 +220,15 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
202
220
|
{pausePart !== null ? null : <View style={part}>
|
|
203
221
|
{facts.map(([label, value]) => <View key={label} style={{ flexDirection: "row", gap: 12 }}>
|
|
204
222
|
<Text style={{ ...t.meta, width: 112 }}>{label}</Text>
|
|
205
|
-
<Text style={{ ...t.meta, flex: 1, color: c.foreground, ...tabular }}>{value}</Text>
|
|
223
|
+
<Text style={{ ...t.meta, flex: 1, color: label === "Subscription" && account?.tone ? toneColor(c, account.tone) : c.foreground, ...tabular }}>{value}</Text>
|
|
206
224
|
</View>)}
|
|
225
|
+
{account && account.next.text ? <Text style={{ ...t.meta, paddingLeft: 124, color: account.next.tone === "default" ? c.foregroundMuted : toneColor(c, account.next.tone) }}>{account.next.text}</Text> : null}
|
|
207
226
|
</View>}
|
|
208
227
|
<View style={{ ...part, gap: 12 }}>
|
|
209
228
|
{compactOn ? <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
210
229
|
<View style={{ flex: 1, gap: 2 }}>
|
|
211
230
|
<Text style={t.label}>Smart Compact</Text>
|
|
212
|
-
<Text style={t.meta}>{enrolled ? `Asked
|
|
231
|
+
<Text style={t.meta}>{enrolled ? `Asked once past ${head?.compactAtPct ?? "its"}%${head === null ? " line" : ""}. Never forced.` : "Never asked to compact. The agent can still request it."}</Text>
|
|
213
232
|
</View>
|
|
214
233
|
<Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving !== null} compact={compact} onChange={(next) => void toggleCompact(next)} />
|
|
215
234
|
</View> : <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
package/client/settings.tsx
CHANGED
|
@@ -152,7 +152,7 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
152
152
|
</SettingsCard>
|
|
153
153
|
</SettingsSection>
|
|
154
154
|
|
|
155
|
-
<SettingsSection title="Pause at the limit" info="When the provider's session window is spent, a running session is paused instead of failing, then continued in the same conversation once the window resets.
|
|
155
|
+
<SettingsSection title="Pause at the limit (Session Guard)" info="When the provider's session window is spent, a running session is paused instead of failing, then continued in the same conversation once the window resets. Choices and waiting pauses from the standalone Session Guard plugin carry over.">
|
|
156
156
|
{guard.status === "loading" ? <ActivityIndicator accessibilityLabel="Loading Pause at the limit settings" color={c.foregroundMuted} style={{ padding: 16 }} />
|
|
157
157
|
: guard.status !== "ready" ? <View style={{ gap: 8 }}>
|
|
158
158
|
<Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{guard.error}</Text>
|
|
@@ -163,7 +163,7 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
163
163
|
</View>
|
|
164
164
|
: <SettingsCard>
|
|
165
165
|
{elsewhere ? <SettingsRow label={elsewhere.title} hint={elsewhere.hint} /> : null}
|
|
166
|
-
<SettingsSwitch label="Pause at the limit" hint={guard.values.enabled ? `On. Sessions pause at ${guard.values.thresholdPct}% of the session window. Works while the app is closed.` : "Off until you turn it on. Turning it off lets frozen workers go; waiting continuations hold until you turn it back on or cancel them."}
|
|
166
|
+
<SettingsSwitch label="Pause at the limit (Session Guard)" hint={guard.values.enabled ? `On. Sessions pause at ${guard.values.thresholdPct}% of the session window. Works while the app is closed.` : "Off until you turn it on. Turning it off lets frozen workers go; waiting continuations hold until you turn it back on or cancel them."}
|
|
167
167
|
value={guard.values.enabled} disabled={guardBusy} onValueChange={(enabled) => changeGuard({ enabled }, enabled ? "Sessions pause at the limit" : "Pause at the limit off")} />
|
|
168
168
|
<SettingsSelect label="Sessions" hint="Change any one session from its pill."
|
|
169
169
|
value={guard.values.protectByDefault ? "all" : "opt-in"} options={[{ label: "Every session", value: "all" }, { label: "Only ones I turn on", value: "opt-in" }]}
|
|
@@ -188,7 +188,7 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
188
188
|
</SettingsCard>
|
|
189
189
|
</View>
|
|
190
190
|
: <SettingsCard>
|
|
191
|
-
<SettingsSwitch label="Sidebar row" hint="Smart Session in the sidebar, with the session window's use, or how many sessions are paused."
|
|
191
|
+
<SettingsSwitch label="Sidebar row" hint="Smart Session in the sidebar, with the session window's use (with ZeroSub, how your subscriptions last), or how many sessions are paused."
|
|
192
192
|
value={display.values.showSidebar} disabled={display.saving} onValueChange={(showSidebar) => void display.save({ ...display.values, showSidebar }, display.revision)} />
|
|
193
193
|
<SettingsSwitch label="Pill on every session" hint="The context ring above each message box. A paused session keeps its pill either way."
|
|
194
194
|
value={pillOn} disabled={saving || display.saving} onValueChange={(next) => void setPill(next)} />
|