paseo-smart-session 2.0.0 → 2.2.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.
Files changed (66) hide show
  1. package/README.md +55 -130
  2. package/assets/LICENSE.lucide +43 -0
  3. package/assets/icon.png +0 -0
  4. package/assets/icon.svg +1 -0
  5. package/client/agents.ts +200 -0
  6. package/client/charts.tsx +277 -152
  7. package/client/chat.tsx +122 -0
  8. package/client/display.tsx +55 -33
  9. package/client/guard/index.tsx +48 -0
  10. package/client/guard/paused.tsx +164 -0
  11. package/client/guard/pill-icons.tsx +26 -0
  12. package/client/guard/pills.ts +132 -0
  13. package/client/guard/settings.tsx +114 -0
  14. package/client/guard/surfaces.ts +3 -0
  15. package/client/guard/timeline.tsx +154 -0
  16. package/client/kit.tsx +144 -0
  17. package/client/live.ts +49 -0
  18. package/client/motion.tsx +94 -0
  19. package/client/overview.tsx +373 -0
  20. package/client/pill-store.ts +77 -0
  21. package/client/pill.tsx +91 -380
  22. package/client/popover.tsx +113 -0
  23. package/client/settings.tsx +105 -0
  24. package/client/theme.ts +56 -0
  25. package/docs/media/smart-session-chat.png +0 -0
  26. package/docs/media/smart-session-flow.gif +0 -0
  27. package/docs/media/smart-session-guard.png +0 -0
  28. package/docs/media/smart-session-hero-light.png +0 -0
  29. package/docs/media/smart-session-hero.png +0 -0
  30. package/docs/media/smart-session-mobile.png +0 -0
  31. package/docs/media/smart-session-pill.png +0 -0
  32. package/docs/navigation-verification.md +6 -6
  33. package/docs/screenshots/display-settings-desktop.png +0 -0
  34. package/docs/screenshots/display-settings-mobile.png +0 -0
  35. package/docs/session-guard.md +88 -0
  36. package/index.client.tsx +52 -35
  37. package/index.server.ts +19 -11
  38. package/mcp.mjs +22 -3
  39. package/package.json +2 -1
  40. package/paseo-plugin.json +11 -0
  41. package/server/guard/client.ts +27 -0
  42. package/server/guard/engine.ts +554 -0
  43. package/server/guard/index.ts +395 -0
  44. package/server/guard/labels.ts +77 -0
  45. package/server/guard/processes.ts +147 -0
  46. package/server/guard/standalone.ts +61 -0
  47. package/server/guard/store.ts +113 -0
  48. package/server/guard/timeline.ts +24 -0
  49. package/server/guard/usage.ts +57 -0
  50. package/server/recorder.ts +2 -0
  51. package/server/settings.ts +10 -0
  52. package/server/store.ts +25 -1
  53. package/shared/blocks.ts +15 -0
  54. package/shared/chat.ts +174 -0
  55. package/shared/forecast.ts +233 -0
  56. package/shared/format.ts +47 -1
  57. package/shared/governor.ts +3 -0
  58. package/shared/guard/contracts.ts +65 -0
  59. package/shared/guard/policy.ts +128 -0
  60. package/shared/guard/presentation.ts +85 -0
  61. package/shared/guard/timeline.ts +34 -0
  62. package/shared/lifecycle.ts +18 -0
  63. package/shared/smart-session.ts +16 -0
  64. package/client/navigation.tsx +0 -14
  65. package/client/surface.tsx +0 -574
  66. package/docs/screenshots/smart-compact-handoff.png +0 -0
@@ -0,0 +1,373 @@
1
+ import { useRpc, useSettings, type PluginClientContext, type PluginScreenProps } from "@getpaseo/plugin/client";
2
+ import { Icon, useToast } from "@getpaseo/plugin/client/ui";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { useEffect, type ReactNode } from "react";
5
+ import { Platform, ScrollView, Text, View } from "react-native";
6
+
7
+ import { formatCompactTokens, formatDay, formatDuration, formatRelative, isInteresting } from "../shared/format";
8
+ import {
9
+ budgetLine, contextOutlook, forecastWindow, formatMoment, headline, leadWindow, paceLine, resetLine,
10
+ type Tone, type WindowForecast,
11
+ } from "../shared/forecast";
12
+ import { enrolmentState } from "../shared/governor";
13
+ import { guardPreferences, statusRpc as guardStatus } from "../shared/guard/contracts";
14
+ import { governorNotice, pausedSessions } from "../shared/guard/presentation";
15
+ import { budgetStatus, contextStatus, getSettings, installStatus, setSettings, spendSummary } from "../shared/smart-session";
16
+ import { DailyBars, HourHeatmap, Meter, PastWindows, RankedTotals, Ring, WindowChart, ink, toneFill } from "./charts";
17
+ import { refreshGuardPills } from "./guard/pills";
18
+ import { GUARD_SCREENS } from "./guard/surfaces";
19
+ import { Card, CardRow, Chip, IconButton, SectionHead, StatusGlyph, Switch, TextLink, errorText, text, useNow } from "./kit";
20
+ import type { Live } from "./live";
21
+ import { Reveal, Skeleton } from "./motion";
22
+ import { refreshPills } from "./pill-store";
23
+ import { tabular, toneColor, type Theme } from "./theme";
24
+
25
+ export const OVERVIEW_SCREEN = "overview";
26
+ export const SMART_COMPACT_SETTINGS = "smart-compact";
27
+
28
+ const ORDER = (id: string) => (id === "five_hour" ? 0 : id === "seven_day" ? 1 : 2);
29
+
30
+ function toneIcon(tone: Tone, state: WindowForecast["state"] | null): string {
31
+ if (state === "full") return "OctagonAlert";
32
+ if (tone !== "default") return "Hourglass";
33
+ return "Gauge";
34
+ }
35
+
36
+ /** A meter row for one plan window: label and percentage, meter, reset and pace. */
37
+ function WindowRow({ theme, f, now, first, children }: { theme: Theme; f: WindowForecast; now: number; first: boolean; children?: ReactNode }) {
38
+ const c = theme.colors;
39
+ const t = text(c);
40
+ const pace = paceLine(f, now);
41
+ const budget = f.id === "five_hour" ? budgetLine(f) : null;
42
+ return <CardRow theme={theme} first={first} style={{ gap: 6 }}>
43
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
44
+ <Text style={{ ...t.label, flex: 1 }}>{f.label}</Text>
45
+ <Text style={{ color: c.foreground, fontSize: 17, lineHeight: 22, fontWeight: "600" }}>{`${Math.round(f.pct)}%`}</Text>
46
+ </View>
47
+ <Meter theme={theme} pct={f.pct} tone={f.tone} projected={f.atResetPct} pace={f.elapsed === null ? null : f.elapsed * 100}
48
+ label={`${f.label}: ${Math.round(f.pct)}% used${f.state === "short" ? `, ${pace.toLowerCase()}` : ""}`} />
49
+ <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 10, rowGap: 2 }}>
50
+ <Text style={t.meta}>{resetLine(f, now)}</Text>
51
+ {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}
52
+ {budget ? <Text style={t.meta}>{budget}</Text> : null}
53
+ </View>
54
+ {children}
55
+ </CardRow>;
56
+ }
57
+
58
+ /** What the meter's three marks mean, once, under the card. */
59
+ function MeterKey({ theme }: { theme: Theme }) {
60
+ const c = theme.colors;
61
+ const swatch = (color: string) => <View style={{ width: 14, height: 6, borderRadius: 3, backgroundColor: color }} />;
62
+ return <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 14, rowGap: 4, paddingHorizontal: 14, paddingVertical: 10, borderTopWidth: 1, borderTopColor: c.border }}>
63
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>{swatch(ink(c, 0.82))}<Text style={text(c).small}>Used</Text></View>
64
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>{swatch(ink(c, 0.16))}<Text style={text(c).small}>By the reset at this pace</Text></View>
65
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}><View style={{ width: 2, height: 12, borderRadius: 1, backgroundColor: c.foregroundMuted }} /><Text style={text(c).small}>Even pace</Text></View>
66
+ </View>;
67
+ }
68
+
69
+ function LoadingCard({ theme, rows = 3 }: { theme: Theme; rows?: number }) {
70
+ const c = theme.colors;
71
+ return <Card theme={theme}>
72
+ {Array.from({ length: rows }, (unused, i) => <CardRow key={i} theme={theme} first={i === 0} style={{ gap: 8 }}>
73
+ <Skeleton width="38%" height={11} color={c.surface2} />
74
+ <Skeleton width="100%" height={8} color={c.surface2} />
75
+ </CardRow>)}
76
+ </Card>;
77
+ }
78
+
79
+ function Empty({ theme, title, detail }: { theme: Theme; title: string; detail: string }) {
80
+ const t = text(theme.colors);
81
+ return <Card theme={theme}><CardRow theme={theme} first style={{ gap: 4, paddingVertical: 16 }}>
82
+ <Text style={t.label}>{title}</Text>
83
+ <Text style={t.meta}>{detail}</Text>
84
+ </CardRow></Card>;
85
+ }
86
+
87
+ /** A feature row: icon, name and one line of state paired with its moment, then its links and switch. */
88
+ function FeatureRow({ theme, icon, title, line, tone = "default", on, onToggle, disabled, compact, first, links, children }: {
89
+ theme: Theme; icon: string; title: string; line: ReactNode; tone?: Tone; on: boolean | null; onToggle: (next: boolean) => void; disabled?: boolean; compact: boolean; first: boolean; links: ReactNode; children?: ReactNode;
90
+ }) {
91
+ const c = theme.colors;
92
+ const t = text(c);
93
+ return <CardRow theme={theme} first={first} style={{ gap: 6 }}>
94
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
95
+ <Icon name={icon} size={15} color={on ? c.foreground : c.foregroundMuted} />
96
+ <View style={{ flex: 1, gap: 2 }}>
97
+ <Text style={t.label}>{title}</Text>
98
+ <Text style={{ ...t.meta, color: tone === "default" ? c.foregroundMuted : toneColor(c, tone) }}>{line}</Text>
99
+ </View>
100
+ {compact ? null : links}
101
+ <Switch theme={theme} label={title} hideLabel value={on} onChange={onToggle} disabled={disabled} compact={compact} />
102
+ </View>
103
+ {children}
104
+ {compact ? <View style={{ flexDirection: "row", gap: 16, paddingLeft: 25 }}>{links}</View> : null}
105
+ </CardRow>;
106
+ }
107
+
108
+ export function makeOverview(client: PluginClientContext, live: Live) {
109
+ return function SmartSessionOverview({ theme, layout, navigation }: PluginScreenProps) {
110
+ const c = theme.colors;
111
+ const t = text(c);
112
+ const compact = layout.compact;
113
+ const now = useNow(30_000);
114
+ const toast = useToast();
115
+ const queryClient = useQueryClient();
116
+ const budget = useRpc(budgetStatus);
117
+ const context = useRpc(contextStatus);
118
+ const spend = useRpc(spendSummary);
119
+ const readSettings = useRpc(getSettings);
120
+ const writeSettings = useRpc(setSettings);
121
+ const readEnrolment = useRpc(enrolmentState);
122
+ const readInstall = useRpc(installStatus);
123
+ const readGuard = useRpc(guardStatus);
124
+ const guardSettings = useSettings(guardPreferences);
125
+
126
+ // The recorder writes once a minute and the daemon caches usage for five, so
127
+ // polling harder than this would only redraw the same numbers.
128
+ const budgetQuery = useQuery({ queryKey: ["smart-session", "budget"], queryFn: () => budget({}), refetchInterval: 30_000 });
129
+ const contextQuery = useQuery({ queryKey: ["smart-session", "context"], queryFn: () => context({}), refetchInterval: 15_000 });
130
+ // Reconstructed from transcripts, which only change when an agent writes a turn.
131
+ const spendQuery = useQuery({ queryKey: ["smart-session", "spend"], queryFn: () => spend({ days: 30 }), refetchInterval: 5 * 60_000 });
132
+ const settingsQuery = useQuery({ queryKey: ["smart-session", "settings"], queryFn: () => readSettings({}), refetchInterval: 60_000 });
133
+ const enrolmentQuery = useQuery({ queryKey: ["smart-session", "enrolment"], queryFn: () => readEnrolment({}), refetchInterval: 30_000 });
134
+ // Cheap after the first call: with nothing to reconcile it reads two files and writes none.
135
+ const installQuery = useQuery({ queryKey: ["smart-session", "install"], queryFn: () => readInstall({}), refetchInterval: 5 * 60_000 });
136
+ const guardQuery = useQuery({ queryKey: ["smart-session", "guard"], queryFn: () => readGuard({}), refetchInterval: 15_000 });
137
+
138
+ useEffect(() => { if (budgetQuery.data) live.setBudget(budgetQuery.data); }, [budgetQuery.data]);
139
+ const guardValues = guardSettings.status === "ready" ? guardSettings.values : undefined;
140
+ // A change made here or on another client moves the pills without waiting out their poll.
141
+ useEffect(() => { if (guardValues !== undefined) refreshGuardPills(); }, [guardValues]);
142
+
143
+ const settings = settingsQuery.data?.settings;
144
+ 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);
145
+ const lead = leadWindow(forecasts);
146
+ const head = budgetQuery.data === undefined ? null : headline(lead, now);
147
+ const session = budgetQuery.data?.windows.find((w) => w.id === "five_hour");
148
+ const guard = guardQuery.data;
149
+ const paused = guard === undefined ? [] : pausedSessions(guard.pauses);
150
+ const enrolled = new Set((enrolmentQuery.data?.agents ?? []).filter((a) => a.enrolled).map((a) => a.agentId));
151
+ const agents = (contextQuery.data?.agents ?? []).filter((a) => a.status !== "closed");
152
+ const recorder = budgetQuery.data?.recorder;
153
+ const summary = spendQuery.data?.summary;
154
+ const error = budgetQuery.data?.error ?? contextQuery.data?.error ?? (budgetQuery.error ? errorText(budgetQuery.error) : null);
155
+ const guardOn = guardSettings.status === "ready" ? guardSettings.values.enabled : null;
156
+ const guardPct = guardOn && guard?.governor === "active" ? guard.policy.thresholdPct : null;
157
+ const openPaused = () => client.openScreen({ screenId: GUARD_SCREENS.paused });
158
+
159
+ async function saveSmartCompact(patch: Parameters<typeof writeSettings>[0], message: string) {
160
+ try {
161
+ await writeSettings(patch);
162
+ // The pills live in this same client bundle, so they are told directly.
163
+ await refreshPills(() => readEnrolment({}));
164
+ toast.show(message);
165
+ await queryClient.invalidateQueries({ queryKey: ["smart-session"] });
166
+ } catch (problem) {
167
+ toast.error(errorText(problem));
168
+ }
169
+ }
170
+
171
+ function toggleGuard(next: boolean) {
172
+ if (guardSettings.status !== "ready") return;
173
+ void guardSettings.save({ ...guardSettings.values, enabled: next }, guardSettings.revision).then((saved) => {
174
+ if (!saved) return toast.error("Session Guard could not be changed. Try again.");
175
+ toast.show(next ? "Session Guard on" : "Session Guard off");
176
+ void guardQuery.refetch();
177
+ });
178
+ }
179
+
180
+ const install = installQuery.data?.report;
181
+ // Only a problem earns a line: a working install is the default.
182
+ const hooksProblem = install === undefined ? null
183
+ : install.error !== null ? install.error
184
+ : install.pluginDir === null || install.hooks.length === 0 ? "Hooks not registered with Claude Code, so nothing will ask a session to compact."
185
+ : null;
186
+ const notice = guard === undefined ? null : governorNotice(guard);
187
+ // One line each, paired with its moment where there is one.
188
+ const pastLine = agents.filter((agent) => enrolled.has(agent.agentId) && contextOutlook(agent).past).length;
189
+ const compactLine: ReactNode = settings === undefined ? "…" : !settings.enabled ? "Off · nothing is asked or compacted"
190
+ // Only the clause that is state wears the state colour.
191
+ : 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></>
192
+ : `${enrolled.size} enrolled · asked past ${settings.thresholds.large.compact}% of a ${formatCompactTokens(settings.thresholds.largeWindowFrom)}+ window, ${settings.thresholds.small.compact}% of a smaller one`;
193
+ const waitingResets = paused.filter((p) => p.phase !== "attention").map((p) => (p.anchorResetsAt ? Date.parse(p.anchorResetsAt) : NaN)).filter((t) => Number.isFinite(t) && t > now).sort((a, b) => a - b);
194
+ const guardState: { line: string; tone: Tone } = (() => {
195
+ if (guardOn === null) return { line: "…", tone: "default" };
196
+ if (!guardOn) return { line: paused.length > 0 ? `Off · ${paused.length} paused still ${paused.length === 1 ? "waits" : "wait"} for the reset` : "Off · nothing pauses at the session limit", tone: "default" };
197
+ if (notice !== null && guard?.governor !== "off") return { line: notice.title, tone: "default" };
198
+ const auto = guard?.policy.autoResume !== false;
199
+ if (paused.some((p) => p.phase === "attention")) return { line: `${paused.length} paused · ${paused.filter((p) => p.phase === "attention").length} need${paused.filter((p) => p.phase === "attention").length === 1 ? "s" : ""} a look`, tone: "danger" };
200
+ if (paused.length > 0) {
201
+ const at = waitingResets[0];
202
+ return { line: at === undefined ? `${paused.length} paused · waiting for a fresh reading` : `${paused.length} paused · ${auto ? `resume${paused.length === 1 ? "s" : ""} at` : "continue by hand after"} ${formatMoment(new Date(at).toISOString(), now)}`, tone: "warning" };
203
+ }
204
+ const threshold = guard?.policy.thresholdPct ?? 100;
205
+ const s5 = lead?.id === "five_hour" ? lead : null;
206
+ if (s5 && s5.pct >= threshold) {
207
+ const reset = s5.resetsAt !== null && Date.parse(s5.resetsAt) > now ? ` until the ${formatMoment(s5.resetsAt, now)} reset` : " until the next reset";
208
+ return { line: `At the limit · a running session pauses${reset}`, tone: "warning" };
209
+ }
210
+ if (s5 && s5.resetsAt && s5.ratePerHour && s5.ratePerHour > 0 && s5.pct < threshold && s5.pct + s5.ratePerHour * ((Date.parse(s5.resetsAt) - now) / 3_600_000) >= threshold) {
211
+ const at = new Date(now + ((threshold - s5.pct) / s5.ratePerHour) * 3_600_000).toISOString();
212
+ return { line: `Pauses sessions at ~${formatMoment(at, now)} · ${auto ? "resumes" : "you continue"} after the ${formatMoment(s5.resetsAt, now)} reset`, tone: "default" };
213
+ }
214
+ if (s5 && s5.resetsAt && Date.parse(s5.resetsAt) > now) return { line: `Pauses at ${threshold}% · not expected before the ${formatMoment(s5.resetsAt, now)} reset`, tone: "default" };
215
+ return { line: `Pauses at ${threshold}% of the session window · ${auto ? "resumes by itself" : "you continue"} after the reset`, tone: "default" };
216
+ })();
217
+
218
+ const header = <View style={{ gap: 6 }}>
219
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
220
+ {head ? <View style={{ paddingTop: compact ? 3 : 5 }}><Icon name={toneIcon(head.tone, lead?.state ?? null)} size={compact ? 17 : 19} color={head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone)} /></View> : null}
221
+ <View style={{ flex: 1, gap: 4 }}>
222
+ {head ? <Text accessibilityRole="header" style={{ color: c.foreground, fontSize: compact ? 19 : 22, lineHeight: compact ? 25 : 29, fontWeight: "600" }}>{head.title}</Text>
223
+ : <Skeleton width="60%" height={22} color={c.surface2} />}
224
+ {head ? <Text style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 19, ...tabular }}>{head.detail}</Text> : null}
225
+ </View>
226
+ <IconButton theme={theme} icon="Settings" label="Smart Session settings" compact={compact} onPress={() => client.openSettings(SMART_COMPACT_SETTINGS)} />
227
+ </View>
228
+ {paused.length > 0 && guard?.governor === "active" ? <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8, paddingLeft: compact ? 27 : 29 }}>
229
+ <Chip theme={theme} icon="Pause" label={`${paused.length} paused`} color={paused.some((p) => p.phase === "attention") ? c.statusDanger : c.statusWarning} />
230
+ <TextLink theme={theme} label={paused.some((p) => p.phase === "attention") ? "Check the paused sessions" : "Session Guard resumes them after the reset"} onPress={openPaused} />
231
+ </View> : null}
232
+ </View>;
233
+
234
+ return <ScrollView style={{ flex: 1, backgroundColor: c.surface0 }} contentContainerStyle={{ padding: compact ? 16 : 32, paddingBottom: 48 }}>
235
+ <View style={{ width: "100%", maxWidth: 860, alignSelf: "center", gap: compact ? 20 : 26 }}>
236
+ {header}
237
+ {error ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{error}</Text> : null}
238
+
239
+ <View style={{ gap: 8 }}>
240
+ <SectionHead theme={theme} title="Plan limits" />
241
+ {budgetQuery.data === undefined ? <LoadingCard theme={theme} />
242
+ : 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." />
243
+ : <Reveal><Card theme={theme}>
244
+ {forecasts.map((f, i) => <WindowRow key={f.id} theme={theme} f={f} now={now} first={i === 0}>
245
+ {f.id === "five_hour" && session?.history && session.history.length > 0 ? <View style={{ gap: 14, paddingTop: 10 }}>
246
+ <WindowChart theme={theme} forecast={f} history={session.history} now={now} compact={compact} guardPct={guardPct} />
247
+ {budgetQuery.data?.past && budgetQuery.data.past.length > 0 ? <View style={{ gap: 2 }}>
248
+ <Text style={t.small}>Earlier session windows, last 14 days</Text>
249
+ <PastWindows theme={theme} past={budgetQuery.data.past} now={now} compact={compact} />
250
+ </View> : null}
251
+ </View> : null}
252
+ </WindowRow>)}
253
+ <MeterKey theme={theme} />
254
+ </Card></Reveal>}
255
+ </View>
256
+
257
+ <View style={{ gap: 8 }}>
258
+ <SectionHead theme={theme} title="Session context" count={agents.length || undefined} />
259
+ {contextQuery.data === undefined ? <LoadingCard theme={theme} rows={2} />
260
+ : agents.length === 0 ? <Empty theme={theme} title="No session has reported its context yet" detail="Each session reports how full its context is after its first turn." />
261
+ : <Card theme={theme}>{agents.map((agent, i) => {
262
+ const outlook = contextOutlook(agent);
263
+ const on = enrolled.has(agent.agentId);
264
+ const parts = [`${formatCompactTokens(agent.usedTokens)} of ${formatCompactTokens(agent.maxTokens)}`, `compacts at ${agent.compactAtPct}%`];
265
+ if (agent.growthTokensPerHour !== null && agent.growthTokensPerHour > 0) parts.push(`+${formatCompactTokens(agent.growthTokensPerHour)}/h`);
266
+ const outlookLine = outlook.past ? (on ? "Past its compact line · asked at the end of a turn" : "Past its compact line · not enrolled")
267
+ : outlook.untilCompactMs !== null ? `Reaches it in ~${formatDuration(outlook.untilCompactMs)}` : null;
268
+ return <CardRow key={agent.agentId} theme={theme} first={i === 0} style={{ gap: 6 }}
269
+ onPress={navigation ? () => navigation.openAgent({ agentId: agent.agentId }) : undefined} accessibilityLabel={`Open ${agent.title ?? "session"}`}>
270
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
271
+ <StatusGlyph theme={theme} status={agent.status} />
272
+ <Text numberOfLines={1} style={{ ...t.label, flexShrink: 1 }}>{agent.title ?? `Session ${agent.agentId.slice(0, 8)}`}</Text>
273
+ <View style={{ flex: 1 }} />
274
+ {on ? <Chip theme={theme} label="Smart Compact" /> : compact ? null : <Text style={t.small}>Not enrolled</Text>}
275
+ <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} />
276
+ <Text style={{ color: c.foreground, fontSize: 15, lineHeight: 20, fontWeight: "600", minWidth: 36, textAlign: "right", ...tabular }}>{`${Math.round(outlook.pct)}%`}</Text>
277
+ </View>
278
+ <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}%`} />
279
+ <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 10, rowGap: 2 }}>
280
+ <Text style={t.meta}>{parts.join(" · ")}</Text>
281
+ {outlookLine ? <Text style={{ ...t.meta, color: outlook.past ? toneColor(c, outlook.tone) : c.foregroundMuted }}>{outlookLine}</Text> : null}
282
+ </View>
283
+ </CardRow>;
284
+ })}</Card>}
285
+ </View>
286
+
287
+ <Card theme={theme}>
288
+ <FeatureRow theme={theme} first icon="FoldVertical" title="Smart Compact" line={compactLine} on={settings?.enabled ?? null} compact={compact}
289
+ onToggle={(next) => void saveSmartCompact({ enabled: next }, next ? "Smart Compact on" : "Smart Compact off")}
290
+ links={<TextLink theme={theme} compact={compact} label="Settings" onPress={() => client.openSettings(SMART_COMPACT_SETTINGS)} />}>
291
+ {settings?.enabled && hooksProblem ? <View style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingLeft: 25 }}>
292
+ <Icon name="TriangleAlert" size={13} color={c.statusWarning} />
293
+ <Text style={{ ...t.meta, flex: 1, color: c.statusWarning }}>{hooksProblem}</Text>
294
+ </View> : null}
295
+ </FeatureRow>
296
+ <FeatureRow theme={theme} first={false} icon={guardOn ? "ShieldCheck" : "ShieldOff"} title="Session Guard" line={guardState.line} tone={guardState.tone} on={guardOn} compact={compact} disabled={guardSettings.saving} onToggle={toggleGuard}
297
+ links={<View style={{ flexDirection: "row", gap: 14 }}>
298
+ {paused.length > 0 || guardOn ? <TextLink theme={theme} compact={compact} label="Paused sessions" onPress={openPaused} /> : null}
299
+ <TextLink theme={theme} compact={compact} label="Settings" onPress={() => client.openSettings("session-guard")} />
300
+ </View>}>
301
+ {paused.length > 0 ? <View style={{ gap: 2, paddingLeft: 25 }}>{paused.slice(0, 3).map((pause) => {
302
+ const at = pause.anchorResetsAt ? Date.parse(pause.anchorResetsAt) : NaN;
303
+ // The line above already names the first reset; a row repeats a time only when its own differs.
304
+ const own = pause.phase === "attention" ? "Check" : Number.isFinite(at) && at > now && Math.abs(at - (waitingResets[0] ?? at)) >= 60_000 ? formatMoment(pause.anchorResetsAt!, now) : null;
305
+ return <View key={pause.id} style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
306
+ <Text numberOfLines={1} style={{ ...t.meta, flex: 1, color: c.foreground }}>{pause.title?.trim() || "Untitled session"}</Text>
307
+ {own ? <Text style={{ ...t.meta, color: pause.phase === "attention" ? c.statusDanger : c.foreground, fontWeight: "600" }}>{own}</Text> : null}
308
+ </View>;
309
+ })}</View> : null}
310
+ </FeatureRow>
311
+ </Card>
312
+
313
+ {summary === undefined || summary.bucketCount === 0 ? null : <View style={{ gap: 8 }}>
314
+ <SectionHead theme={theme} title="Tokens, last 30 days" />
315
+ <Card theme={theme}>
316
+ <View style={{ padding: 14, gap: 18 }}>
317
+ <Text style={{ ...t.body, color: c.foregroundMuted, ...tabular }}>
318
+ <Text style={{ color: c.foreground, fontWeight: "600" }}>{formatCompactTokens(summary.totalTokens)}</Text>{" billable tokens · "}
319
+ <Text style={{ color: c.foreground, fontWeight: "600" }}>{`${Math.round(summary.cacheHitPct)}%`}</Text>{" of input from cache"}
320
+ {summary.subagentTokens === 0 ? null : <>{" · "}<Text style={{ color: c.foreground, fontWeight: "600" }}>{`${Math.round((summary.subagentTokens / summary.totalTokens) * 100)}%`}</Text>{" spent by subagents"}</>}
321
+ </Text>
322
+ <View style={{ gap: 4 }}>
323
+ <Text style={t.group}>Per day</Text>
324
+ <DailyBars theme={theme} days={summary.byDay.slice(-30)} compact={compact} />
325
+ </View>
326
+ <View style={{ gap: 4 }}>
327
+ <Text style={t.group}>By hour of the week</Text>
328
+ <HourHeatmap theme={theme} cells={summary.heatmap} compact={compact} />
329
+ </View>
330
+ <View style={{ flexDirection: compact ? "column" : "row", gap: compact ? 18 : 32 }}>
331
+ <View style={{ flex: compact ? undefined : 1, gap: 8 }}>
332
+ <Text style={t.group}>By workspace</Text>
333
+ <RankedTotals theme={theme} rows={summary.byProject} total={summary.totalTokens} compact={compact} />
334
+ </View>
335
+ <View style={{ flex: compact ? undefined : 1, gap: 8 }}>
336
+ <Text style={t.group}>By model</Text>
337
+ <RankedTotals theme={theme} rows={summary.byModel} total={summary.totalTokens} compact={compact} />
338
+ </View>
339
+ </View>
340
+ </View>
341
+ </Card>
342
+ </View>}
343
+
344
+ {recorder === undefined ? null : <View style={{ gap: 3 }}>
345
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
346
+ <Ring size={12} color={recorder.ageSeconds !== null && recorder.ageSeconds > 900 ? c.statusWarning : c.foregroundMuted} fraction={1} mark={null} />
347
+ <Text style={{ ...t.small, color: recorder.ageSeconds !== null && recorder.ageSeconds > 900 ? c.statusWarning : c.foregroundMuted }}>
348
+ {recorder.newestAt === null ? "Recorder running · nothing recorded yet"
349
+ : `Recording since ${recorder.firstSampleAt ? formatDay(recorder.firstSampleAt) : "—"} · ${recorder.samplesHeld.toLocaleString()} readings in the last 14 days · newest ${formatRelative(recorder.newestAt, now)}${recorder.ageSeconds !== null && recorder.ageSeconds > 900 ? " — treat these numbers as a floor" : ""}`}
350
+ </Text>
351
+ </View>
352
+ <Text selectable numberOfLines={1} style={{ ...t.small, paddingLeft: 18, fontFamily: Platform.OS === "ios" ? "Menlo" : Platform.OS === "android" ? "monospace" : "ui-monospace, SFMono-Regular, Menlo, monospace" }}>{recorder.dataDir}</Text>
353
+ </View>}
354
+ </View>
355
+ </ScrollView>;
356
+ };
357
+ }
358
+
359
+ /** The sidebar row's right slot: what is paused, else the session window. */
360
+ export function sidebarTrailing(live: Live, now: number): { label: string; tone: Tone | "muted" } | null {
361
+ const paused = live.paused();
362
+ if (paused.attention > 0) return { label: `${paused.attention + paused.waiting} paused`, tone: "danger" };
363
+ if (paused.waiting > 0) return { label: `${paused.waiting} paused`, tone: "warning" };
364
+ const windows = live.budget()?.windows ?? [];
365
+ const session = windows.find((w) => w.id === "five_hour");
366
+ if (session === undefined) return null;
367
+ const f = forecastWindow(session, now);
368
+ // Usage is a time: when the window runs out first, the row says when.
369
+ if (f.state === "short" && f.runsOutAt) return { label: `out ${formatMoment(f.runsOutAt, now)}`, tone: f.tone };
370
+ if (f.state === "full") return { label: "at limit", tone: "danger" };
371
+ return { label: `${Math.round(f.pct)}%`, tone: "muted" };
372
+ }
373
+
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The pill store: one module-level answer to "is there a pill, who is enrolled, and
3
+ * where are the compact lines", shared by every pill, its popover, the Command
4
+ * Center item and the screens in this client bundle, so a change redraws them all
5
+ * at once instead of waiting out a poll.
6
+ */
7
+
8
+ import type { EnrolmentState } from "../shared/governor";
9
+ import type { Thresholds } from "../shared/thresholds";
10
+
11
+ export interface PillState {
12
+ /** Both switches folded into one answer: is there a pill on the composer at all. */
13
+ readonly showPill: boolean;
14
+ readonly enrolled: ReadonlySet<string>;
15
+ /** The compact bands; null until the daemon has answered. */
16
+ readonly thresholds: Thresholds | null;
17
+ }
18
+
19
+ /**
20
+ * Hidden until the daemon says otherwise.
21
+ *
22
+ * The alternative — assume shown, then retract — flashes a pill at everyone who
23
+ * turned it off, every time the app loads.
24
+ */
25
+ let current: PillState = { showPill: false, enrolled: new Set(), thresholds: null };
26
+
27
+ /** The current answer, for code outside React. */
28
+ export function state(): PillState {
29
+ return current;
30
+ }
31
+
32
+ const listeners = new Set<() => void>();
33
+
34
+ export function subscribePills(listener: () => void): () => void {
35
+ listeners.add(listener);
36
+ return () => {
37
+ listeners.delete(listener);
38
+ };
39
+ }
40
+
41
+ export function pillSnapshot(): PillState {
42
+ return current;
43
+ }
44
+
45
+ function publish(next: PillState): void {
46
+ current = next;
47
+ for (const listener of [...listeners]) listener();
48
+ }
49
+
50
+ /** Marks one agent enrolled or not after a write, without waiting for the next read. */
51
+ export function noteEnrolment(agentId: string, enrolled: boolean): void {
52
+ const next = new Set(current.enrolled);
53
+ if (enrolled) next.add(agentId);
54
+ else next.delete(agentId);
55
+ publish({ ...current, enrolled: next });
56
+ }
57
+
58
+ function enrolmentSet(agents: EnrolmentState["agents"]): ReadonlySet<string> {
59
+ return new Set(agents.filter((agent) => agent.enrolled).map((agent) => agent.agentId));
60
+ }
61
+
62
+ /**
63
+ * Re-reads enrolment and redraws every pill.
64
+ *
65
+ * Takes the call rather than making it: the same fetch is reachable from the client
66
+ * entry, a Command Center item and the screens, and each of those holds a different
67
+ * flavour of RPC caller.
68
+ */
69
+ export async function refreshPills(fetch: () => Promise<EnrolmentState>): Promise<void> {
70
+ const next = await fetch();
71
+ publish({
72
+ showPill: next.enabled && next.showPill,
73
+ enrolled: enrolmentSet(next.agents),
74
+ thresholds: next.thresholds ?? current.thresholds,
75
+ });
76
+ }
77
+