paseo-smart-session 3.1.0 → 3.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.
@@ -1,18 +1,23 @@
1
1
  import { useState, useSyncExternalStore } from "react";
2
2
  import { Text, View } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
4
  import { useRpc, type PluginButtonContentProps, type PluginClientContext } from "@getpaseo/plugin/client";
4
5
  import { useQuery } from "@tanstack/react-query";
5
6
 
6
- import { formatCompactTokens, formatDuration, formatRelative } from "../shared/format";
7
+ import { formatClock, formatCompactTokens, formatDuration, formatRelative } from "../shared/format";
7
8
  import { contextOutlook } from "../shared/forecast";
8
9
  import { listCompactions, setEnrolment, type CompactionRequest } from "../shared/governor";
10
+ import { cancelResumeRpc, dismissPauseRpc, resumeNowRpc, setProtectionRpc, statusRpc } from "../shared/guard/contracts";
11
+ import { PAUSE_ACTIONS, governorNotice, pausePresentation } from "../shared/guard/presentation";
9
12
  import { contextStatus } from "../shared/smart-session";
10
13
  import { profileFor } from "../shared/thresholds";
11
14
  import type { AgentDirectory } from "./agents";
12
15
  import { Meter, Ring, toneFill } from "./charts";
13
- import { Switch, TextLink, errorText, text, useNow } from "./kit";
16
+ import { governedPause, guardSnapshot, publishGuard, resumeMoment, sessionProtection, subscribeGuard } from "./guard/status";
17
+ import { Button, Switch, TextLink, errorText, text, useNow } from "./kit";
14
18
  import { noteEnrolment, pillSnapshot, subscribePills } from "./pill-store";
15
19
  import { tabular, toneColor } from "./theme";
20
+ import { SETTINGS_SCREEN } from "./screens";
16
21
 
17
22
  /** What the last compaction did, in one line. */
18
23
  function compactionLine(item: CompactionRequest | undefined, now: number): string {
@@ -24,19 +29,25 @@ function compactionLine(item: CompactionRequest | undefined, now: number): strin
24
29
  return item.preTokens !== null && item.postTokens !== null ? `${when} · ${formatCompactTokens(item.preTokens)} → ${formatCompactTokens(item.postTokens)}` : when;
25
30
  }
26
31
 
27
- /** The pill's popover: this session's context, its compact line, task state and enrolment. */
32
+ /** The pill's popover: a pause when there is one, this session's context and both of its switches. */
28
33
  export function makeContextPopover(client: PluginClientContext, agents: AgentDirectory) {
29
34
  return function ContextPopover({ theme, layout, close, ...rest }: PluginButtonContentProps) {
30
35
  const agentId = rest.context === "agent" ? rest.agentId : "";
31
36
  const c = theme.colors;
32
37
  const t = text(c);
33
- const now = useNow(30_000);
38
+ const now = useNow(15_000);
34
39
  const pills = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
40
+ const guard = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot);
35
41
  useSyncExternalStore(agents.subscribe, agents.version, agents.version);
36
42
  const readContext = useRpc(contextStatus);
37
43
  const readCompactions = useRpc(listCompactions);
38
44
  const writeEnrolment = useRpc(setEnrolment);
39
- const [saving, setSaving] = useState(false);
45
+ const readGuard = useRpc(statusRpc);
46
+ const writeProtection = useRpc(setProtectionRpc);
47
+ const cancelResume = useRpc(cancelResumeRpc);
48
+ const resumeNow = useRpc(resumeNowRpc);
49
+ const dismissPause = useRpc(dismissPauseRpc);
50
+ const [saving, setSaving] = useState<"compact" | "protect" | "release" | "resume" | "dismiss" | null>(null);
40
51
  const [problem, setProblem] = useState<string | null>(null);
41
52
  const detail = useQuery({ queryKey: ["smart-session", "context", agentId], queryFn: () => readContext({ agentId }), refetchInterval: 30_000 });
42
53
  const compactions = useQuery({ queryKey: ["smart-session", "compactions", agentId], queryFn: () => readCompactions({ agentId }), refetchInterval: 30_000 });
@@ -45,20 +56,43 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
45
56
  const row = detail.data?.agents[0];
46
57
  const used = agent?.usedTokens ?? row?.usedTokens ?? null;
47
58
  const max = agent?.maxTokens ?? row?.maxTokens ?? null;
48
- const enrolled = pills.enrolled.has(agentId);
59
+ const compactOn = pills.compactOn;
60
+ const enrolled = compactOn && pills.enrolled.has(agentId);
49
61
  const compact = layout.compact;
62
+ const pause = governedPause(guard, agentId);
63
+ const protection = sessionProtection(guard, agentId);
50
64
 
51
- async function toggle(next: boolean) {
52
- setSaving(true); setProblem(null);
65
+ async function run(kind: "compact" | "protect" | "release" | "resume" | "dismiss", work: () => Promise<void>) {
66
+ setSaving(kind); setProblem(null);
53
67
  try {
54
- const { agent: saved } = await writeEnrolment({ agentId, enrolled: next });
55
- noteEnrolment(saved.agentId, saved.enrolled);
68
+ await work();
56
69
  } catch (error) {
57
70
  setProblem(errorText(error));
58
71
  } finally {
59
- setSaving(false);
72
+ setSaving(null);
60
73
  }
61
74
  }
75
+ const toggleCompact = (next: boolean) => run("compact", async () => {
76
+ const { agent: saved } = await writeEnrolment({ agentId, enrolled: next });
77
+ noteEnrolment(saved.agentId, saved.enrolled);
78
+ });
79
+ // Back to the default is the same as no override, so the switch never leaves a redundant one.
80
+ const setProtected = (next: boolean | null) => run("protect", async () => {
81
+ const enabled = protection.kind === "on" && next === protection.byDefault ? null : next;
82
+ await writeProtection({ agentId, enabled });
83
+ publishGuard(await readGuard({}));
84
+ });
85
+ // A stuck pause can only be released; a waiting one gets the same two actions as its chat card.
86
+ const release = () => run("release", async () => {
87
+ await cancelResume({ agentId });
88
+ publishGuard(await readGuard({}));
89
+ });
90
+ const settle = (kind: "resume" | "dismiss", pauseId: string) => run(kind, async () => {
91
+ await (kind === "resume" ? resumeNow : dismissPause)({ agentId, pauseId });
92
+ publishGuard(await readGuard({}));
93
+ });
94
+ const openOverview = (focus?: string) => { close(); client.openScreen({ screenId: "overview", params: focus ? { focus } : undefined }); };
95
+ const openSettings = () => { close(); client.openSettings(SETTINGS_SCREEN); };
62
96
 
63
97
  const head = (() => {
64
98
  if (used === null || max === null) return null;
@@ -68,7 +102,7 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
68
102
  const sentence = head === null
69
103
  ? { line: "No context reading yet. A session reports one after its first turn.", tone: "default" as const }
70
104
  : head.outlook.past
71
- ? { line: enrolled ? "Past its compact line. Smart Compact asks it at the end of a turn whether to compact." : "Past its compact line, but not enrolled, so nothing will ask it.", tone: head.outlook.tone }
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 }
72
106
  : head.outlook.untilCompactMs !== null
73
107
  ? { line: `Reaches its compact line in about ${formatDuration(head.outlook.untilCompactMs)} at +${formatCompactTokens(row!.growthTokensPerHour!)}/h.`, tone: "default" as const }
74
108
  : { line: "Below its compact line and not growing right now.", tone: "default" as const };
@@ -79,35 +113,117 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
79
113
  ["Last compaction", compactions.data === undefined ? "…" : compactionLine(latest, now)],
80
114
  ];
81
115
  const ringColor = head === null ? c.foregroundMuted : head.outlook.tone !== "default" ? toneFill(c, head.outlook.tone) : enrolled ? c.foreground : c.foregroundMuted;
116
+ const part = { gap: 6, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border } as const;
82
117
 
83
- return <View style={{ width: compact ? "100%" : 340, padding: compact ? 16 : 14, gap: 12 }}>
84
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
85
- <Ring size={40} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null ? null : head.compactAtPct / 100} />
86
- <View style={{ flex: 1, gap: 1 }}>
87
- <View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
88
- <Text style={{ color: c.foreground, fontSize: 22, lineHeight: 28, fontWeight: "600" }}>{head === null ? "—" : `${Math.round(head.outlook.pct)}%`}</Text>
89
- <Text style={t.meta}>{max === null ? "context" : `of ${formatCompactTokens(max)} context`}</Text>
118
+ // The loud state: what happened, when it comes back, and the one way to let it go.
119
+ const pausePart = (() => {
120
+ if (pause === undefined || guard === null) return null;
121
+ const look = pausePresentation(pause, guard.policy, now);
122
+ const stuck = pause.phase === "attention";
123
+ const reset = resumeMoment(pause);
124
+ const auto = guard.policy.enabled && guard.policy.autoResume;
125
+ const moment = stuck ? "Needs a look" : pause.demo ? "Test pause" : reset === null ? "Reset time unknown" : reset <= now ? "Reset due" : `${auto ? "Resumes" : "Resets"} ${formatClock(pause.anchorResetsAt)}`;
126
+ const after = stuck ? (pause.note ?? "It stopped before the pause was confirmed. Check it before continuing by hand.")
127
+ : pause.demo ? "a test pause that waits for you"
128
+ : reset === null ? "Waiting for the provider to report the reset"
129
+ : reset <= now ? "Waiting for a fresh usage reading"
130
+ : `in ${formatDuration(reset - now)} · ${auto ? "continues in this conversation" : "continue it by hand"}`;
131
+ const tone = stuck ? c.statusDanger : c.statusWarning;
132
+ const meta = [stuck ? null : "Paused at the session limit", after, look.coverage.toLowerCase()].filter(Boolean).join(" · ");
133
+ const waiting = pause.phase === "waiting";
134
+ return <View style={{ gap: 8 }}>
135
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
136
+ <Icon name={stuck ? "TriangleAlert" : "Pause"} size={18} color={tone} />
137
+ <Text style={{ color: c.foreground, fontSize: 20, lineHeight: 26, fontWeight: "600", flexShrink: 1, ...tabular }}>{moment}</Text>
138
+ </View>
139
+ <Text style={{ ...t.meta, color: stuck ? c.statusDanger : c.foregroundMuted }}>{meta}</Text>
140
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 6 }}>
141
+ {waiting ? <>
142
+ <Button theme={theme} small compact={compact} icon={PAUSE_ACTIONS.resume.icon} title={saving === "resume" ? PAUSE_ACTIONS.resume.busy : PAUSE_ACTIONS.resume.label} disabled={saving !== null}
143
+ accessibilityHint={PAUSE_ACTIONS.resume.hint} onPress={() => void settle("resume", pause.id)} />
144
+ <Button theme={theme} small compact={compact} kind="ghost" title={saving === "dismiss" ? PAUSE_ACTIONS.dismiss.busy : PAUSE_ACTIONS.dismiss.label} disabled={saving !== null}
145
+ accessibilityHint={PAUSE_ACTIONS.dismiss.hint} onPress={() => void settle("dismiss", pause.id)} />
146
+ </> : stuck ? <Button theme={theme} small compact={compact} title={saving === "release" ? "Updating…" : look.actionLabel} disabled={saving !== null}
147
+ accessibilityHint={look.actionHint} onPress={() => void release()} /> : null}
148
+ <View style={{ flex: 1 }} />
149
+ <TextLink theme={theme} compact={compact} label="All paused sessions" onPress={() => openOverview("paused")} />
150
+ </View>
151
+ {waiting ? <Text style={t.small}>{PAUSE_ACTIONS.note}</Text> : null}
152
+ </View>;
153
+ })();
154
+
155
+ const protectionPart = (() => {
156
+ if (pause !== undefined) return null;
157
+ if (protection.kind === "unknown") return null;
158
+ if (protection.kind === "on") {
159
+ 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"}`
161
+ : "Off for this session · at the limit the provider refuses work and nothing continues it";
162
+ const source = protection.override === null ? (protection.byDefault ? "Your default" : "Your default is off") : "Changed for this session";
163
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
164
+ <View style={{ flex: 1, gap: 2 }}>
165
+ <Text style={t.label}>Pause at the limit</Text>
166
+ <Text style={t.meta}>{meta}</Text>
167
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
168
+ <Text style={t.small}>{source}</Text>
169
+ {protection.override !== null ? <TextLink theme={theme} compact={compact} icon="RotateCcw" label="Use default" onPress={() => void setProtected(null)} /> : null}
170
+ </View>
171
+ </View>
172
+ <Switch theme={theme} label="Pause this session at the limit" hideLabel value={protection.protected} disabled={saving !== null} compact={compact} onChange={(next) => void setProtected(next)} />
173
+ </View>;
174
+ }
175
+ // 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");
179
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
180
+ <Icon name="CirclePause" size={13} color={c.foregroundMuted} />
181
+ <Text style={{ ...t.meta, flex: 1 }}>{line}</Text>
182
+ {protection.kind === "off" ? <TextLink theme={theme} compact={compact} icon="Settings" label="Turn on" onPress={openSettings} /> : null}
183
+ </View>;
184
+ })();
185
+
186
+ return <View style={{ width: compact ? "100%" : 340, padding: compact ? 16 : 14, paddingTop: compact ? 4 : 14, gap: 12 }}>
187
+ {pausePart}
188
+ <View style={[{ gap: 12 }, pausePart === null ? null : part]}>
189
+ <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} />
191
+ <View style={{ flex: 1, gap: 1 }}>
192
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
193
+ <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
+ <Text style={t.meta}>{max === null ? "context" : `of ${formatCompactTokens(max)} context`}</Text>
195
+ </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>
90
197
  </View>
91
- <Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens · compacts at ${head.compactAtPct}% (${formatCompactTokens(head.outlook.compactTokens)})`}</Text>
92
198
  </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}%`} />}
200
+ {pausePart !== null ? null : <Text style={{ ...t.body, color: sentence.tone === "default" ? c.foreground : toneColor(c, sentence.tone) }}>{sentence.line}</Text>}
93
201
  </View>
94
- {head === 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}%`} />}
95
- <Text style={{ ...t.body, color: sentence.tone === "default" ? c.foreground : toneColor(c, sentence.tone) }}>{sentence.line}</Text>
96
- <View style={{ gap: 6, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
202
+ {pausePart !== null ? null : <View style={part}>
97
203
  {facts.map(([label, value]) => <View key={label} style={{ flexDirection: "row", gap: 12 }}>
98
204
  <Text style={{ ...t.meta, width: 112 }}>{label}</Text>
99
205
  <Text style={{ ...t.meta, flex: 1, color: c.foreground, ...tabular }}>{value}</Text>
100
206
  </View>)}
101
- </View>
102
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
103
- <View style={{ flex: 1, gap: 2 }}>
104
- <Text style={t.label}>Smart Compact for this session</Text>
105
- <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>
106
- </View>
107
- <Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving} compact={compact} onChange={(next) => void toggle(next)} />
207
+ </View>}
208
+ <View style={{ ...part, gap: 12 }}>
209
+ {compactOn ? <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
210
+ <View style={{ flex: 1, gap: 2 }}>
211
+ <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>
213
+ </View>
214
+ <Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving !== null} compact={compact} onChange={(next) => void toggleCompact(next)} />
215
+ </View> : <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
216
+ <Icon name="FoldVertical" size={13} color={c.foregroundMuted} />
217
+ <Text style={{ ...t.meta, flex: 1 }}>Smart Compact is off on this host</Text>
218
+ </View>}
219
+ {protectionPart}
108
220
  </View>
109
221
  {problem ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{problem}</Text> : null}
110
- <TextLink theme={theme} compact={compact} label="Plan usage and every session" onPress={() => { close(); client.openScreen({ screenId: "overview" }); }} />
222
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
223
+ <TextLink theme={theme} compact={compact} label="Plan usage and every session" onPress={() => openOverview()} />
224
+ <View style={{ flex: 1 }} />
225
+ <TextLink theme={theme} compact={compact} icon="Settings" label="Settings" onPress={openSettings} />
226
+ </View>
111
227
  </View>;
112
228
  };
113
229
  }
@@ -0,0 +1,6 @@
1
+ /** Where Smart Session's screens live, in one place for the pill, the views and the Command Center. */
2
+ export const OVERVIEW_SCREEN = "overview";
3
+ /** The old Paused sessions screen id, kept as the overview opened at its paused list. */
4
+ export const PAUSED_SCREEN = "paused-sessions";
5
+ /** The one settings screen. It kept Smart Compact's id so links to the old first page still land. */
6
+ export const SETTINGS_SCREEN = "smart-compact";