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 +29 -11
- package/client/charts.tsx +7 -6
- package/client/display.tsx +2 -2
- package/client/kit.tsx +2 -2
- package/client/live.ts +8 -1
- package/client/overview.tsx +72 -26
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +35 -17
- package/client/settings.tsx +42 -20
- 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 +2 -2
- package/hooks/ask-compact.mjs +13 -119
- package/hooks/ask.mjs +193 -0
- package/hooks/asks.mjs +53 -6
- package/hooks/context-threshold.mjs +31 -10
- package/hooks/context.mjs +18 -4
- package/index.client.tsx +4 -0
- package/index.server.ts +15 -0
- package/mcp.mjs +33 -1
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/growth.ts +2 -1
- package/server/recorder.ts +12 -0
- package/server/store.ts +24 -1
- package/server/subscriptions.ts +208 -0
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/guard/zerosub.ts +18 -3
- package/shared/smart-session.ts +72 -0
- package/shared/subscriptions.ts +540 -0
- package/shared/thresholds.ts +41 -6
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
|
|
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
|

|
|
37
37
|
|
|
38
|
-

|
|
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.
|
|
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
|
-
##
|
|
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
|
|
|
@@ -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 |
|
|
85
|
-
| |
|
|
86
|
-
|
|
|
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
|
-

|
|
116
|
+

|
|
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
|
|
40
|
-
*
|
|
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. */
|
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
|
@@ -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
|
-
|
|
43
|
-
const
|
|
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
|
-
|
|
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
|
|
204
|
-
: `${enrolled.size} enrolled · asked
|
|
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
|
-
|
|
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 ? [
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
package/client/pill-view.ts
CHANGED
|
@@ -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
|
|
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 ?? `
|
|
30
|
-
fraction: outlook.pct / 100, mark:
|
|
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
|
|
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
|
|
package/client/popover.tsx
CHANGED
|
@@ -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
|
|
100
|
-
|
|
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
|
|
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}
|
|
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
|
|
178
|
-
:
|
|
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 ·
|
|
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
|
|
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 ?
|
|
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 }}>
|