paseo-smart-session 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,11 +31,11 @@ On first load it registers its Claude Code hooks, touching only entries that poi
31
31
  - **Smart Compact and Pause at the limit.** One switch each, what they will do next, and the sessions you changed from your default.
32
32
  - **Tokens, last 30 days.** Billable tokens, the cache hit rate and the subagent share, per day, by hour of the week, by workspace and by model, reconstructed from Claude Code's transcripts.
33
33
 
34
- Each session has one pill above its message box: a ring of its context, with a tick at its compact line. Press it to see the detail, when it last saved its task state and compacted, and to switch Smart Compact and Pause at the limit for that session. When the session is paused at the limit the same pill turns amber and says when it resumes, and it stays up even if you hide the pill.
34
+ Each session has one pill above its message box: a ring of its context, with a tick at each of its two compact lines. Press it to see the detail, when it last saved its task state and compacted, and to switch Smart Compact and Pause at the limit for that session. When the session is paused at the limit the same pill turns amber and says when it resumes, and it stays up even if you hide the pill.
35
35
 
36
36
  ![Pressing the context ring above the message box, then following it to the Smart Session screen and sweeping the window chart: recorded usage up to now, the projection at this pace, and the stretch after it runs out](docs/media/smart-session-flow.gif)
37
37
 
38
- ![Two popovers of the one pill. Left: 34% of a 1M context, past its compact line, task state and last compaction, then the Smart Compact and Pause at the limit switches for this session. Right: the same pill while paused, reading Resumes 6:27 AM, with Cancel continuation and a link to all paused sessions](docs/media/smart-session-pill.png)
38
+ ![Two popovers of the one pill. Left: 13% of a 1M context, task state and last compaction, the subscription the session runs on (CLI login, 5h 76%, week 46%: runs out around 2:14 AM, then ZeroSub moves it to Account 3), then the Smart Compact and Pause at the limit (Session Guard) switches for this session. Right: the same pill while paused, reading Resumes 6:27 AM, with Resume now, Dismiss pause and a link to all paused sessions](docs/media/smart-session-pill.png)
39
39
 
40
40
  Agents get these tools:
41
41
 
@@ -53,13 +53,29 @@ The usual flow is `context_status → checkpoint → request_compaction`, then s
53
53
 
54
54
  ## How Smart Compact works
55
55
 
56
- As a session fills, hooks send context notices. At the compact line, the agent is asked at the end of its turn whether it wants to compact. Nothing is forced: the agent can compact, defer with a reason, or keep working. A request is delivered only when the agent is idle and its state file is current; `request_compaction` refuses a missing or stale one.
56
+ As a session fills, hooks send context notices. Each window size has two lines. Past the first (the end-of-turn line), the agent is asked at the end of its turn whether it wants to compact. Past the second (the mid-turn line), it is asked straight away, to finish only the step it is on, checkpoint, request the compaction and end its turn so the compaction can land. Nothing is forced: the agent can compact, defer with a reason, or keep working. A request is delivered only when the agent is idle and its state file is current; `request_compaction` refuses a missing or stale one.
57
57
 
58
58
  After compaction, the agent chooses to stop (`continue_after_compaction: false`), continue with the default prompt to re-read its state, or continue with its own `continue_message`. A `/compact` typed by a person never gets a plugin continuation.
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
- ## Pause at the limit
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
- ![Smart Session at phone width: the overview leading with paused sessions, the pill's sheet while paused, and the settings screen](docs/media/smart-session-mobile.png)
90
+ ![Smart Session at phone width: the overview with every subscription and its runway, the pill's sheet while paused, and the settings screen with Pause at the limit (Session Guard)](docs/media/smart-session-mobile.png)
75
91
 
76
92
  ## Settings
77
93
 
@@ -81,9 +97,11 @@ Everything is on one screen, **Settings → Plugins → Smart Session**, in the
81
97
  | --- | --- | --- | --- |
82
98
  | Smart Compact | Smart Compact | On | Context notices and agent-requested compaction |
83
99
  | | Enrol sessions automatically | On | Enrol a session once it writes task state |
84
- | When to ask | Windows of 400k or more | 30% | Where large windows are asked to compact |
85
- | | 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 |
100
+ | When to ask | 400k+ windows · end of turn | 30% | Where large windows are asked when a turn ends |
101
+ | | 400k+ windows · mid-turn | 40% | Where large windows are asked mid-turn; must be above the end-of-turn line |
102
+ | | Smaller windows · end of turn | 85% | Where smaller windows are asked when a turn ends |
103
+ | | Smaller windows · mid-turn | 90% | Where smaller windows are asked mid-turn |
104
+ | Pause at the limit (Session Guard) | Pause at the limit (Session Guard) | Off | Pause sessions at the session limit |
87
105
  | | Sessions | Every session | Or only the ones you turn on from their pill |
88
106
  | | Continue after the reset | On | Off, you continue each one by hand |
89
107
  | | Include native subagents | On | Freeze nested workers too (Linux) |
@@ -95,13 +113,13 @@ Everything is on one screen, **Settings → Plugins → Smart Session**, in the
95
113
 
96
114
  Hiding the row or the pill never turns a feature off.
97
115
 
98
- ![The Smart Session settings screen: Smart Compact, When to ask, Pause at the limit, Display, Claude Code and Limit timing](docs/media/smart-session-settings.png)
116
+ ![The Smart Session settings screen: Smart Compact, When to ask, Pause at the limit (Session Guard), Display, Claude Code and Limit timing](docs/media/smart-session-settings.png)
99
117
 
100
118
  ## Data and access
101
119
 
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.
120
+ 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
121
 
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.
122
+ 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
123
 
106
124
  ## Development
107
125
 
package/client/charts.tsx CHANGED
@@ -36,11 +36,11 @@ function hover(set: (value: number | null) => void, index: number) {
36
36
 
37
37
  /**
38
38
  * A meter: the used share, an optional faint projection of where it lands at the
39
- * reset, an optional muted pace tick (where an even spend would be) and an optional
40
- * mark (the compact line on a context bar).
39
+ * reset, an optional muted pace tick (where an even spend would be) and optional
40
+ * marks (the two compact lines on a context bar).
41
41
  */
42
- export function Meter({ theme, pct, tone = "default", projected, pace, mark, height = 8, label }: {
43
- theme: Theme; pct: number; tone?: Tone; projected?: number | null; pace?: number | null; mark?: number | null; height?: number; label: string;
42
+ export function Meter({ theme, pct, tone = "default", projected, pace, mark, upper, height = 8, label }: {
43
+ theme: Theme; pct: number; tone?: Tone; projected?: number | null; pace?: number | null; mark?: number | null; upper?: number | null; height?: number; label: string;
44
44
  }) {
45
45
  const c = theme.colors;
46
46
  const used = Math.max(0, Math.min(100, pct));
@@ -57,13 +57,14 @@ export function Meter({ theme, pct, tone = "default", projected, pace, mark, hei
57
57
  </View>
58
58
  {pace !== null && pace !== undefined ? tick(pace, c.foregroundMuted, "pace") : null}
59
59
  {mark !== null && mark !== undefined ? tick(mark, c.foreground, "mark") : null}
60
+ {upper !== null && upper !== undefined && upper !== mark ? tick(upper, c.foreground, "upper") : null}
60
61
  </View>;
61
62
  }
62
63
 
63
64
  /** The context ring glyph, the same object as the composer pill's icon. */
64
- export function Ring({ size, color, fraction, mark }: { size: number; color: string; fraction: number; mark: number | null }) {
65
+ export function Ring({ size, color, fraction, mark, upper = null }: { size: number; color: string; fraction: number; mark: number | null; upper?: number | null }) {
65
66
  // A stronger track at pill size, so the ring reads as a gauge and not as the host's spinner.
66
- return <Icon icon={{ svg: contextRing(fraction, mark, size <= 18 ? 0.5 : 0.28) }} size={size} color={color} />;
67
+ return <Icon icon={{ svg: contextRing(fraction, mark, size <= 18 ? 0.5 : 0.28, mark === null ? null : upper) }} size={size} color={color} />;
67
68
  }
68
69
 
69
70
  /** The percentage held at `t`: readings only climb within a window, so the last one before it stands. */
@@ -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={`Session window ${trailing.label} used`} 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)} />}
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();
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
5
5
  import { Platform, ScrollView, Text, View, type ScrollView as ScrollViewType } from "react-native";
6
6
 
7
+ import { outlookShort } from "../shared/compact-lines";
7
8
  import { formatCompactTokens, formatDay, formatDuration, formatRelative, isInteresting } from "../shared/format";
8
9
  import {
9
10
  budgetLine, contextOutlook, forecastWindow, formatMoment, headline, leadWindow, paceLine, resetLine,
@@ -16,6 +17,9 @@ import { budgetStatus, contextStatus, getSettings, installStatus, setSettings, s
16
17
  import { DailyBars, HourHeatmap, Meter, PastWindows, RankedTotals, Ring, WindowChart, ink, toneFill } from "./charts";
17
18
  import { Exceptions, PausedSection } from "./guard/paused";
18
19
  import { guardSnapshot, refreshGuard, subscribeGuard } from "./guard/status";
20
+ import { hasSubscriptions, pools, subscribeSubscriptions, subscriptionsSnapshot } from "./subscriptions";
21
+ import { familyName, leadPool, poolHeadline, poolTrailing, subscriptionState } from "../shared/subscriptions";
22
+ import { SubscriptionsSection } from "./subscriptions-view";
19
23
  import { Card, CardRow, Chip, IconButton, SectionHead, StatusGlyph, Switch, TextLink, errorText, text, useNow } from "./kit";
20
24
  import type { Live } from "./live";
21
25
  import { Reveal, Skeleton } from "./motion";
@@ -36,11 +40,12 @@ function toneIcon(tone: Tone, state: WindowForecast["state"] | null): string {
36
40
  }
37
41
 
38
42
  /** 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 }) {
43
+ 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
44
  const c = theme.colors;
41
45
  const t = text(c);
42
- const pace = paceLine(f, now);
43
- const budget = f.id === "five_hour" ? budgetLine(f) : null;
46
+ // Quiet: the Subscriptions section already says when this window runs out.
47
+ const pace = quiet ? "" : paceLine(f, now);
48
+ const budget = f.id === "five_hour" && !quiet ? budgetLine(f) : null;
44
49
  return <CardRow theme={theme} first={first} style={{ gap: 6 }}>
45
50
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
46
51
  <Text style={{ ...t.label, flex: 1 }}>{f.label}</Text>
@@ -53,6 +58,7 @@ function WindowRow({ theme, f, now, first, children }: { theme: Theme; f: Window
53
58
  {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
59
  {budget ? <Text style={t.meta}>{budget}</Text> : null}
55
60
  </View>
61
+ {note ? <Text style={{ ...t.meta, color: c.statusDanger }}>{note}</Text> : null}
56
62
  {children}
57
63
  </CardRow>;
58
64
  }
@@ -136,6 +142,16 @@ export function makeOverview(client: PluginClientContext, live: Live) {
136
142
  const installQuery = useQuery({ queryKey: ["smart-session", "install"], queryFn: () => readInstall({}), refetchInterval: 5 * 60_000 });
137
143
  // The pill's own status poll; this screen only reads it.
138
144
  const guard = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot) ?? undefined;
145
+ // ZeroSub's subscriptions, from the bundle's own poll; null or empty without ZeroSub.
146
+ const subs = useSyncExternalStore(subscribeSubscriptions, subscriptionsSnapshot, subscriptionsSnapshot);
147
+ const multi = hasSubscriptions(subs);
148
+ const subPools = multi ? pools(subs, now) : [];
149
+ const poolLead = leadPool(subPools.filter((p) => p.total > 1), now);
150
+ // The CLI's newer limit notice outranks the recorder's last reading; the history row says so.
151
+ const cliOut = multi ? subs.accounts.find((a) => a.id === "claude-main") : undefined;
152
+ const cliState = cliOut ? subscriptionState(cliOut, now) : null;
153
+ const cliOutNote = cliState?.kind === "limit" ? `ZeroSub reports this login ${cliState.label.charAt(0).toLowerCase()}${cliState.label.slice(1)}` : null;
154
+ const poolHead = poolLead === null || !multi ? null : poolHeadline(poolLead, subs.accounts.filter((a) => a.family === poolLead.family), now);
139
155
  const focus = params?.focus === "paused" ? "paused" : null;
140
156
  const scroller = useRef<ScrollViewType>(null);
141
157
  const pausedY = useRef<number | null>(null);
@@ -151,7 +167,10 @@ export function makeOverview(client: PluginClientContext, live: Live) {
151
167
  useEffect(() => { if (guardValues !== undefined) refreshGuard(); }, [guardValues]);
152
168
 
153
169
  const settings = settingsQuery.data?.settings;
154
- const forecasts = (budgetQuery.data?.windows ?? []).filter(isInteresting).map((w) => forecastWindow(w, now)).sort((a, b) => ORDER(a.id) - ORDER(b.id) || b.pct - a.pct);
170
+ // A limit ZeroSub recorded on the CLI login is newer than the recorder's last reading: the session window is full until then.
171
+ const forecasts = (budgetQuery.data?.windows ?? []).filter(isInteresting)
172
+ .map((w) => w.id === "five_hour" && cliState?.kind === "limit" && cliState.until !== null ? { ...w, pct: 100, resetsAt: new Date(cliState.until).toISOString(), burnPctPerHour: null } : w)
173
+ .map((w) => forecastWindow(w, now)).sort((a, b) => ORDER(a.id) - ORDER(b.id) || b.pct - a.pct);
155
174
  const lead = leadWindow(forecasts);
156
175
  const head = budgetQuery.data === undefined ? null : headline(lead, now);
157
176
  const session = budgetQuery.data?.windows.find((w) => w.id === "five_hour");
@@ -197,11 +216,12 @@ export function makeOverview(client: PluginClientContext, live: Live) {
197
216
  : null;
198
217
  const notice = guard === undefined ? null : governorNotice(guard);
199
218
  // One line each, paired with its moment where there is one.
200
- const pastLine = agents.filter((agent) => enrolled.has(agent.agentId) && contextOutlook(agent).past).length;
219
+ const pastLine = agents.filter((agent) => enrolled.has(agent.agentId) && contextOutlook(agent).past && !contextOutlook(agent).pastNow).length;
220
+ const pastUpper = agents.filter((agent) => enrolled.has(agent.agentId) && contextOutlook(agent).pastNow).length;
201
221
  const compactLine: ReactNode = settings === undefined ? "…" : !settings.enabled ? "Off · nothing is asked or compacted"
202
222
  // Only the clause that is state wears the state colour.
203
- : pastLine > 0 ? <>{`${enrolled.size} enrolled · `}<Text style={{ color: c.statusWarning }}>{`${pastLine} past ${pastLine === 1 ? "its" : "their"} compact line, asked at the end of a turn`}</Text></>
204
- : `${enrolled.size} enrolled · asked past ${settings.thresholds.large.compact}% of a ${formatCompactTokens(settings.thresholds.largeWindowFrom)}+ window, ${settings.thresholds.small.compact}% of a smaller one`;
223
+ : pastUpper + pastLine > 0 ? <>{`${enrolled.size} enrolled`}{pastUpper > 0 ? <Text style={{ color: c.statusDanger }}>{` · ${pastUpper} asked mid-turn`}</Text> : null}{pastLine > 0 ? <Text style={{ color: c.statusWarning }}>{` · ${pastLine} asked at the end of a turn`}</Text> : null}</>
224
+ : `${enrolled.size} enrolled · asked at ${settings.thresholds.large.compact}% / ${settings.thresholds.large.now}% of a ${formatCompactTokens(settings.thresholds.largeWindowFrom)}+ window, ${settings.thresholds.small.compact}% / ${settings.thresholds.small.now}% of a smaller one`;
205
225
  const guardState: { line: string; tone: Tone } = (() => {
206
226
  if (guardOn === null) return { line: "…", tone: "default" };
207
227
  if (!guardOn) return { line: paused.length > 0 ? `Off · ${paused.length} paused still ${paused.length === 1 ? "waits" : "wait"} for the reset` : "Off · sessions run into the limit", tone: "default" };
@@ -209,6 +229,13 @@ export function makeOverview(client: PluginClientContext, live: Live) {
209
229
  const auto = guard?.policy.autoResume !== false;
210
230
  // Paused sessions have their own section and the lead; this line keeps to the rule.
211
231
  if (paused.length > 0) return { line: `${paused.length} paused · listed above`, tone: "default" };
232
+ // Under ZeroSub the pause comes only once no subscription has room: say when that is.
233
+ const claudePool = subPools.find((p) => p.family === "claude" && p.total > 1);
234
+ if (claudePool && subs?.autoSwitch) {
235
+ 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" };
236
+ 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" };
237
+ return { line: "ZeroSub switches first · pauses only when no subscription has room", tone: "default" };
238
+ }
212
239
  const threshold = guard?.policy.thresholdPct ?? 100;
213
240
  const s5 = lead?.id === "five_hour" ? lead : null;
214
241
  if (s5 && s5.pct >= threshold) {
@@ -224,11 +251,14 @@ export function makeOverview(client: PluginClientContext, live: Live) {
224
251
  })();
225
252
 
226
253
  // While something is paused here, that is the news; the plan forecast moves to the line below.
227
- const title = pauseLead !== null ? pauseLead.title : head?.title ?? null;
254
+ // Under ZeroSub the pool is the plan: how long all the subscriptions last, not one login.
255
+ const planHead = poolHead ?? head;
256
+ const title = pauseLead !== null ? pauseLead.title : planHead?.title ?? null;
228
257
  const autoResume = guard?.policy.enabled === true && guard.policy.autoResume;
229
- const detail = pauseLead !== null ? [head?.title, pauseLead.shared !== null ? `in ${formatDuration(pauseLead.shared - now)}` : null, autoResume ? "each continues in its own conversation" : "continue each by hand"].filter(Boolean).join(" · ") : head?.detail ?? null;
258
+ 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
259
  const glyph = pauseLead !== null ? { icon: pauseLead.tone === "danger" ? "TriangleAlert" : "Pause", color: toneColor(c, pauseLead.tone) }
231
- : head ? { icon: toneIcon(head.tone, lead?.state ?? null), color: head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone) } : null;
260
+ : poolHead ? { icon: poolHead.tone === "danger" ? "OctagonAlert" : poolHead.tone === "warning" ? "Hourglass" : "Layers", color: poolHead.tone === "default" ? c.foregroundMuted : toneColor(c, poolHead.tone) }
261
+ : head ? { icon: toneIcon(head.tone, lead?.state ?? null), color: head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone) } : null;
232
262
  const header = <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
233
263
  {glyph ? <View style={{ paddingTop: compact ? 3 : 5 }}><Icon name={glyph.icon} size={compact ? 17 : 19} color={glyph.color} /></View> : null}
234
264
  <View style={{ flex: 1, gap: 4 }}>
@@ -252,13 +282,15 @@ export function makeOverview(client: PluginClientContext, live: Live) {
252
282
  {header}
253
283
  {error ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{error}</Text> : null}
254
284
  {pausedSection}
285
+ {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
286
 
256
287
  <View style={{ gap: 8 }}>
257
- <SectionHead theme={theme} title="Plan limits" />
288
+ {/* Under ZeroSub these are the CLI login's windows, with the recorder's full history. */}
289
+ <SectionHead theme={theme} title={multi ? "CLI login · history" : "Plan limits"} />
258
290
  {budgetQuery.data === undefined ? <LoadingCard theme={theme} />
259
291
  : 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
292
  : <Reveal><Card theme={theme}>
261
- {forecasts.map((f, i) => <WindowRow key={f.id} theme={theme} f={f} now={now} first={i === 0}>
293
+ {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
294
  {f.id === "five_hour" && session?.history && session.history.length > 0 ? <View style={{ gap: 14, paddingTop: 10 }}>
263
295
  <WindowChart theme={theme} forecast={f} history={session.history} now={now} compact={compact} guardPct={guardPct} />
264
296
  {budgetQuery.data?.past && budgetQuery.data.past.length > 0 ? <View style={{ gap: 2 }}>
@@ -278,10 +310,13 @@ export function makeOverview(client: PluginClientContext, live: Live) {
278
310
  : <Card theme={theme}>{agents.map((agent, i) => {
279
311
  const outlook = contextOutlook(agent);
280
312
  const on = enrolled.has(agent.agentId);
281
- const parts = [`${formatCompactTokens(agent.usedTokens)} of ${formatCompactTokens(agent.maxTokens)}`, `compacts at ${agent.compactAtPct}%`];
313
+ const upper = agent.compactNowAtPct ?? null;
314
+ // A session nobody enrolled is never asked, so it gets no lines and no ticks.
315
+ const parts = [`${formatCompactTokens(agent.usedTokens)} of ${formatCompactTokens(agent.maxTokens)}`];
316
+ if (!on) { if (compact) parts.push("not enrolled"); }
317
+ else parts.push(upper === null ? `compacts at ${agent.compactAtPct}%` : `asks at ${agent.compactAtPct}%, mid-turn at ${upper}%`);
282
318
  if (agent.growthTokensPerHour !== null && agent.growthTokensPerHour > 0) parts.push(`+${formatCompactTokens(agent.growthTokensPerHour)}/h`);
283
- const outlookLine = outlook.past ? (on ? "Past its compact line · asked at the end of a turn" : "Past its compact line · not enrolled")
284
- : outlook.untilCompactMs !== null ? `Reaches it in ~${formatDuration(outlook.untilCompactMs)}` : null;
319
+ const outlookLine = outlookShort(outlook, on);
285
320
  return <CardRow key={agent.agentId} theme={theme} first={i === 0} style={{ gap: 6 }}
286
321
  onPress={navigation ? () => navigation.openAgent({ agentId: agent.agentId }) : undefined} accessibilityLabel={`Open ${agent.title ?? "session"}`}>
287
322
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -290,10 +325,10 @@ export function makeOverview(client: PluginClientContext, live: Live) {
290
325
  <View style={{ flex: 1 }} />
291
326
  {governed.some((p) => p.agentId === agent.agentId) ? <Chip theme={theme} icon="Pause" label="Paused" color={c.statusWarning} /> : null}
292
327
  {on ? <Chip theme={theme} label="Smart Compact" /> : compact ? null : <Text style={t.small}>Not enrolled</Text>}
293
- <Ring size={16} color={outlook.tone !== "default" ? toneFill(c, outlook.tone) : on ? c.foreground : c.foregroundMuted} fraction={outlook.pct / 100} mark={on ? agent.compactAtPct / 100 : null} />
328
+ <Ring size={16} color={outlook.tone !== "default" ? toneFill(c, outlook.tone) : on ? c.foreground : c.foregroundMuted} fraction={outlook.pct / 100} mark={on ? agent.compactAtPct / 100 : null} upper={upper === null ? null : upper / 100} />
294
329
  <Text style={{ color: c.foreground, fontSize: 15, lineHeight: 20, fontWeight: "600", minWidth: 36, textAlign: "right", ...tabular }}>{`${Math.round(outlook.pct)}%`}</Text>
295
330
  </View>
296
- <Meter theme={theme} pct={outlook.pct} tone={outlook.tone} mark={agent.compactAtPct} height={6} label={`${agent.title ?? "Session"}: ${Math.round(outlook.pct)}% of its context, compacts at ${agent.compactAtPct}%`} />
331
+ <Meter theme={theme} pct={outlook.pct} tone={outlook.tone} mark={on ? agent.compactAtPct : null} upper={on ? upper : null} height={6} label={`${agent.title ?? "Session"}: ${Math.round(outlook.pct)}% of its context${on ? `, asked at ${agent.compactAtPct}%${upper === null ? "" : ` and mid-turn at ${upper}%`}` : ", not enrolled"}`} />
297
332
  <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 10, rowGap: 2 }}>
298
333
  <Text style={t.meta}>{parts.join(" · ")}</Text>
299
334
  {outlookLine ? <Text style={{ ...t.meta, color: outlook.past ? toneColor(c, outlook.tone) : c.foregroundMuted }}>{outlookLine}</Text> : null}
@@ -311,7 +346,7 @@ export function makeOverview(client: PluginClientContext, live: Live) {
311
346
  <Text style={{ ...t.meta, flex: 1, color: c.statusWarning }}>{hooksProblem}</Text>
312
347
  </View> : null}
313
348
  </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}
349
+ <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
350
  links={<TextLink theme={theme} compact={compact} label="Settings" onPress={() => client.openSettings(SMART_COMPACT_SETTINGS)} />}>
316
351
  {guard !== undefined && guardOn ? <Exceptions theme={theme} layout={layout} status={guard} titleOf={titleOf} /> : null}
317
352
  </FeatureRow>
@@ -363,18 +398,29 @@ export function makeOverview(client: PluginClientContext, live: Live) {
363
398
  };
364
399
  }
365
400
 
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 {
401
+ /** The sidebar row's right slot: what is paused, else the pool of subscriptions, else the session window. */
402
+ export function sidebarTrailing(live: Live, now: number): { label: string; tone: Tone | "muted"; a11y: string } | null {
368
403
  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" };
404
+ if (paused.attention > 0) return { label: `${paused.attention + paused.waiting} paused`, tone: "danger", a11y: `${paused.attention + paused.waiting} sessions paused` };
405
+ if (paused.waiting > 0) return { label: `${paused.waiting} paused`, tone: "warning", a11y: `${paused.waiting} sessions paused` };
406
+ // Under ZeroSub one login is not the story: the row speaks for the pool, and is
407
+ // amber or red only when the pool, not a single account, runs short.
408
+ const subs = live.subscriptions();
409
+ if (hasSubscriptions(subs)) {
410
+ const pool = leadPool(pools(subs, now), now);
411
+ if (pool !== null && pool.total > 1) {
412
+ const slot = poolTrailing(pool, now);
413
+ const fam = familyName(pool.family);
414
+ const a11y = slot.tone === "muted" ? `${fam}: ${pool.withRoom} of ${pool.total} subscriptions with room` : `${fam} subscriptions: ${slot.label}`;
415
+ return { ...slot, a11y };
416
+ }
417
+ }
371
418
  const windows = live.budget()?.windows ?? [];
372
419
  const session = windows.find((w) => w.id === "five_hour");
373
420
  if (session === undefined) return null;
374
421
  const f = forecastWindow(session, now);
375
422
  // 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" };
423
+ 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)}` };
424
+ if (f.state === "full") return { label: "at limit", tone: "danger", a11y: "Session window at the limit" };
425
+ return { label: `${Math.round(f.pct)}%`, tone: "muted", a11y: `Session window ${Math.round(f.pct)}% used` };
379
426
  }
380
-
@@ -18,16 +18,17 @@ export function pillPresentation(agent: Pick<DirectoryAgent, "usedTokens" | "max
18
18
  return {
19
19
  label: compactOn ? "Smart Compact" : "Context",
20
20
  title: `${compactWords ?? "Smart Compact on"} · no context reading yet`,
21
- fraction: 0, mark: null as number | null, tone: "default" as const, pct: null as number | null,
21
+ fraction: 0, mark: null as number | null, upper: null as number | null, tone: "default" as const, pct: null as number | null,
22
22
  };
23
23
  }
24
- const compactAtPct = profileFor(agent.maxTokens!, thresholds ?? undefined).compact;
25
- const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct });
24
+ const profile = profileFor(agent.maxTokens!, thresholds ?? undefined);
25
+ const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct: profile.compact, compactNowAtPct: profile.now });
26
26
  const pct = Math.round(outlook.pct);
27
+ // Short on purpose: the phone sheet uses the title as its one-line header.
27
28
  return {
28
29
  label: `${pct}%`,
29
- title: `Context ${pct}% full · ${compactWords ?? `Smart Compact asks at ${compactAtPct}%`}`,
30
- fraction: outlook.pct / 100, mark: compactAtPct / 100, tone: outlook.tone, pct,
30
+ title: `Context ${pct}% full · ${compactWords ?? `asks at ${profile.compact}%, mid-turn at ${profile.now}%`}`,
31
+ fraction: outlook.pct / 100, mark: profile.compact / 100, upper: profile.now / 100 as number | null, tone: outlook.tone, pct,
31
32
  };
32
33
  }
33
34
 
package/client/pill.tsx CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Normally it answers how full this session's context is and whether Smart Compact
6
6
  * will ask it to compact itself: the icon is a ring that fills with the context and
7
- * carries a tick at the compact line, the label is the percentage. Protection adds
7
+ * carries a tick at each compact line (end of turn, then mid-turn), the label is the percentage. Protection adds
8
8
  * nothing while it simply protects; the quiet case stays quiet. When this session is
9
9
  * paused at the session limit the pill turns into the pause itself, an amber pause
10
10
  * glyph and the moment it resumes, and it stays on the composer even when the pill
@@ -56,7 +56,7 @@ function makeIcon(agents: AgentDirectory) {
56
56
  const enrolled = current.compactOn && current.enrolled.has(agentId);
57
57
  const view = pillPresentation(agents.current().get(agentId), enrolled, current.thresholds, current.compactOn);
58
58
  const color = view.tone !== "default" ? toneFill(c, view.tone) : enrolled ? c.foreground : c.foregroundMuted;
59
- return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} />;
59
+ return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} upper={view.upper} />;
60
60
  };
61
61
  }
62
62
 
@@ -5,6 +5,7 @@ import { useRpc, type PluginButtonContentProps, type PluginClientContext } from
5
5
  import { useQuery } from "@tanstack/react-query";
6
6
 
7
7
  import { formatClock, formatCompactTokens, formatDuration, formatRelative } from "../shared/format";
8
+ import { linesSummary, outlookSentence } from "../shared/compact-lines";
8
9
  import { contextOutlook } from "../shared/forecast";
9
10
  import { listCompactions, setEnrolment, type CompactionRequest } from "../shared/governor";
10
11
  import { cancelResumeRpc, dismissPauseRpc, resumeNowRpc, setProtectionRpc, statusRpc } from "../shared/guard/contracts";
@@ -18,6 +19,8 @@ import { Button, Switch, TextLink, errorText, text, useNow } from "./kit";
18
19
  import { noteEnrolment, pillSnapshot, subscribePills } from "./pill-store";
19
20
  import { tabular, toneColor } from "./theme";
20
21
  import { SETTINGS_SCREEN } from "./screens";
22
+ import { hasSubscriptions, subscribeSubscriptions, subscriptionsSnapshot } from "./subscriptions";
23
+ import { atTheLimit, sessionWindow, subscriptionState, weeklyWindow } from "../shared/subscriptions";
21
24
 
22
25
  /** What the last compaction did, in one line. */
23
26
  function compactionLine(item: CompactionRequest | undefined, now: number): string {
@@ -38,6 +41,7 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
38
41
  const now = useNow(15_000);
39
42
  const pills = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
40
43
  const guard = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot);
44
+ const subs = useSyncExternalStore(subscribeSubscriptions, subscriptionsSnapshot, subscriptionsSnapshot);
41
45
  useSyncExternalStore(agents.subscribe, agents.version, agents.version);
42
46
  const readContext = useRpc(contextStatus);
43
47
  const readCompactions = useRpc(listCompactions);
@@ -96,22 +100,35 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
96
100
 
97
101
  const head = (() => {
98
102
  if (used === null || max === null) return null;
99
- const compactAtPct = row?.compactAtPct ?? profileFor(max, pills.thresholds ?? undefined).compact;
100
- return { compactAtPct, outlook: contextOutlook({ usedTokens: used, maxTokens: max, compactAtPct, growthTokensPerHour: row?.growthTokensPerHour ?? null }) };
103
+ const profile = profileFor(max, pills.thresholds ?? undefined);
104
+ const compactAtPct = row?.compactAtPct ?? profile.compact;
105
+ const nowAtPct = row?.compactNowAtPct ?? profile.now;
106
+ return { compactAtPct, nowAtPct, outlook: contextOutlook({ usedTokens: used, maxTokens: max, compactAtPct, compactNowAtPct: nowAtPct, growthTokensPerHour: row?.growthTokensPerHour ?? null }) };
101
107
  })();
102
108
  const sentence = head === null
103
109
  ? { line: "No context reading yet. A session reports one after its first turn.", tone: "default" as const }
104
- : head.outlook.past
105
- ? { line: enrolled ? "Past its compact line. Smart Compact asks it at the end of a turn whether to compact." : compactOn ? "Past its compact line, but not enrolled, so nothing will ask it." : "Past its compact line. Smart Compact is off, so nothing will ask it.", tone: head.outlook.tone }
106
- : head.outlook.untilCompactMs !== null
107
- ? { line: `Reaches its compact line in about ${formatDuration(head.outlook.untilCompactMs)} at +${formatCompactTokens(row!.growthTokensPerHour!)}/h.`, tone: "default" as const }
108
- : { line: "Below its compact line and not growing right now.", tone: "default" as const };
110
+ : outlookSentence(head.outlook, { enrolled, compactOn, growthPerHour: row?.growthTokensPerHour ?? null });
109
111
  const latest = compactions.data?.items[0];
110
112
  const state = row?.stateWrittenAt ?? null;
111
113
  const facts: [string, string][] = [
112
114
  ["Task state", state === null ? (row === undefined ? "…" : "Not written yet") : `Saved ${formatRelative(state, now)}`],
113
115
  ["Last compaction", compactions.data === undefined ? "…" : compactionLine(latest, now)],
114
116
  ];
117
+ // Under ZeroSub with several subscriptions: which one this session runs on, and what happens at its limit.
118
+ const account = (() => {
119
+ if (!hasSubscriptions(subs)) return null;
120
+ const own = subs.accounts.find((a) => a.id === subs.agents[agentId]);
121
+ if (!own || subs.accounts.filter((a) => a.family === own.family).length < 2) return null;
122
+ 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);
123
+ const s5 = pct(sessionWindow(own));
124
+ const week = pct(weeklyWindow(own));
125
+ const state = subscriptionState(own, now);
126
+ const windows = [s5 === null ? null : `5h ${s5}%`, week === null ? null : `week ${week}%`].filter(Boolean).join(" · ");
127
+ const value = [own.name, state.kind === "room" ? windows : state.label].filter(Boolean).join(" · ");
128
+ const next = atTheLimit(subs.accounts, own.id, now, { autoSwitch: subs.autoSwitch, protectedHere: protection.kind === "on" && protection.protected });
129
+ return { value, next, tone: state.kind === "limit" ? "danger" as const : null };
130
+ })();
131
+ if (account) facts.push(["Subscription", account.value]);
115
132
  const ringColor = head === null ? c.foregroundMuted : head.outlook.tone !== "default" ? toneFill(c, head.outlook.tone) : enrolled ? c.foreground : c.foregroundMuted;
116
133
  const part = { gap: 6, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border } as const;
117
134
 
@@ -157,12 +174,12 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
157
174
  if (protection.kind === "unknown") return null;
158
175
  if (protection.kind === "on") {
159
176
  const meta = protection.protected
160
- ? `Pauses at ${protection.thresholdPct}% of the session window, ${protection.autoResume ? "continues after the reset" : "you continue it after the reset"}`
177
+ ? `Pauses at ${protection.thresholdPct}%, ${protection.autoResume ? "continues after the reset" : "you continue after the reset"}`
161
178
  : "Off for this session · at the limit the provider refuses work and nothing continues it";
162
179
  const source = protection.override === null ? (protection.byDefault ? "Your default" : "Your default is off") : "Changed for this session";
163
180
  return <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
164
181
  <View style={{ flex: 1, gap: 2 }}>
165
- <Text style={t.label}>Pause at the limit</Text>
182
+ <Text style={t.label}>Pause at the limit (Session Guard)</Text>
166
183
  <Text style={t.meta}>{meta}</Text>
167
184
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
168
185
  <Text style={t.small}>{source}</Text>
@@ -173,9 +190,9 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
173
190
  </View>;
174
191
  }
175
192
  // 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 handles pausing at the limit here")
178
- : (governorNotice(guard!)?.title ?? "Pause at the limit isn't running");
193
+ const line = protection.kind === "off" ? "Pause at the limit (Session Guard) is off on this host"
194
+ : protection.kind === "elsewhere" ? (protection.standalone ? "The standalone Session Guard plugin handles pausing at the limit here" : "Another process runs Session Guard here")
195
+ : `${governorNotice(guard!)?.title ?? "Pause at the limit isn't running"} (Session Guard)`;
179
196
  return <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
180
197
  <Icon name="CirclePause" size={13} color={c.foregroundMuted} />
181
198
  <Text style={{ ...t.meta, flex: 1 }}>{line}</Text>
@@ -187,29 +204,30 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
187
204
  {pausePart}
188
205
  <View style={[{ gap: 12 }, pausePart === null ? null : part]}>
189
206
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
190
- <Ring size={pausePart === null ? 40 : 28} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null || !enrolled ? null : head.compactAtPct / 100} />
207
+ <Ring size={pausePart === null ? 40 : 28} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null || !enrolled ? null : head.compactAtPct / 100} upper={head === null ? null : head.nowAtPct / 100} />
191
208
  <View style={{ flex: 1, gap: 1 }}>
192
209
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
193
210
  <Text style={{ color: c.foreground, fontSize: pausePart === null ? 22 : 15, lineHeight: pausePart === null ? 28 : 20, fontWeight: "600", ...tabular }}>{head === null ? "—" : `${Math.round(head.outlook.pct)}%`}</Text>
194
211
  <Text style={t.meta}>{max === null ? "context" : `of ${formatCompactTokens(max)} context`}</Text>
195
212
  </View>
196
- <Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens · compacts at ${head.compactAtPct}% (${formatCompactTokens(head.outlook.compactTokens)})`}</Text>
213
+ <Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens · ${linesSummary(head.compactAtPct, head.nowAtPct)}`}</Text>
197
214
  </View>
198
215
  </View>
199
- {head === null || pausePart !== null ? null : <Meter theme={theme} pct={head.outlook.pct} tone={head.outlook.tone} mark={head.compactAtPct} height={6} label={`Context ${Math.round(head.outlook.pct)}% full, compacts at ${head.compactAtPct}%`} />}
216
+ {head === null || pausePart !== null ? null : <Meter theme={theme} pct={head.outlook.pct} tone={head.outlook.tone} mark={head.compactAtPct} upper={head.nowAtPct} height={6} label={`Context ${Math.round(head.outlook.pct)}% full. Smart Compact ${linesSummary(head.compactAtPct, head.nowAtPct)}`} />}
200
217
  {pausePart !== null ? null : <Text style={{ ...t.body, color: sentence.tone === "default" ? c.foreground : toneColor(c, sentence.tone) }}>{sentence.line}</Text>}
201
218
  </View>
202
219
  {pausePart !== null ? null : <View style={part}>
203
220
  {facts.map(([label, value]) => <View key={label} style={{ flexDirection: "row", gap: 12 }}>
204
221
  <Text style={{ ...t.meta, width: 112 }}>{label}</Text>
205
- <Text style={{ ...t.meta, flex: 1, color: c.foreground, ...tabular }}>{value}</Text>
222
+ <Text style={{ ...t.meta, flex: 1, color: label === "Subscription" && account?.tone ? toneColor(c, account.tone) : c.foreground, ...tabular }}>{value}</Text>
206
223
  </View>)}
224
+ {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
225
  </View>}
208
226
  <View style={{ ...part, gap: 12 }}>
209
227
  {compactOn ? <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
210
228
  <View style={{ flex: 1, gap: 2 }}>
211
229
  <Text style={t.label}>Smart Compact</Text>
212
- <Text style={t.meta}>{enrolled ? `Asked to compact itself once past ${head?.compactAtPct ?? "its"}%${head === null ? " line" : ""}. Never forced.` : "Never asked to compact. The agent can still request it."}</Text>
230
+ <Text style={t.meta}>{enrolled ? "Asked at the end of a turn, then mid-turn. Never forced." : "Never asked to compact. The agent can still request it."}</Text>
213
231
  </View>
214
232
  <Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving !== null} compact={compact} onChange={(next) => void toggleCompact(next)} />
215
233
  </View> : <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>