paseo-smart-session 3.0.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.
Files changed (42) hide show
  1. package/README.md +34 -23
  2. package/client/chat.tsx +2 -2
  3. package/client/commands.ts +62 -0
  4. package/client/display.tsx +9 -62
  5. package/client/guard/index.tsx +11 -42
  6. package/client/guard/paused.tsx +91 -120
  7. package/client/guard/status.ts +113 -0
  8. package/client/guard/timeline.tsx +61 -56
  9. package/client/overview.tsx +59 -52
  10. package/client/pill-store.ts +8 -2
  11. package/client/pill-view.ts +61 -0
  12. package/client/pill.tsx +50 -47
  13. package/client/popover.tsx +147 -31
  14. package/client/screens.ts +6 -0
  15. package/client/settings.tsx +223 -83
  16. package/docs/media/before-after.png +0 -0
  17. package/docs/media/smart-session-guard.png +0 -0
  18. package/docs/media/smart-session-mobile.png +0 -0
  19. package/docs/media/smart-session-pill.png +0 -0
  20. package/docs/media/smart-session-settings.png +0 -0
  21. package/docs/navigation-verification.md +4 -4
  22. package/docs/session-guard.md +27 -16
  23. package/index.client.tsx +19 -34
  24. package/index.server.ts +6 -2
  25. package/mcp.mjs +32 -3
  26. package/package.json +1 -1
  27. package/paseo-plugin.json +1 -0
  28. package/server/guard/engine.ts +37 -5
  29. package/server/guard/index.ts +27 -14
  30. package/server/guard/zerosub.ts +82 -0
  31. package/shared/guard/contracts.ts +2 -0
  32. package/shared/guard/presentation.ts +71 -11
  33. package/shared/guard/timeline.ts +3 -3
  34. package/shared/guard/timing.ts +37 -0
  35. package/shared/guard/zerosub.ts +276 -0
  36. package/shared/smart-session.ts +16 -1
  37. package/client/guard/pill-icons.tsx +0 -26
  38. package/client/guard/pills.ts +0 -132
  39. package/client/guard/settings.tsx +0 -114
  40. package/client/guard/surfaces.ts +0 -3
  41. package/docs/screenshots/display-settings-desktop.png +0 -0
  42. package/docs/screenshots/display-settings-mobile.png +0 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Working alongside ZeroSub, which runs Claude and Codex agents on several
3
+ * subscription accounts and moves an agent to another account when the CLI reports a
4
+ * limit.
5
+ *
6
+ * Without this, Session Guard measures only the CLI login's usage (all Paseo and the
7
+ * recorder can read) and applies it to every agent of that provider. Under ZeroSub
8
+ * that pauses agents on accounts with room, and its interrupt or SIGSTOP stops the CLI
9
+ * from ever printing the limit notice ZeroSub fails over on. So, when ZeroSub is
10
+ * enabled, the guard reads each agent's account and that account's usage from
11
+ * ZeroSub's own files, and leaves an agent alone while ZeroSub can move it to another
12
+ * account with room.
13
+ *
14
+ * ZeroSub's files are an internal format, so everything here is parsed defensively:
15
+ * anything unexpected reads as "no ZeroSub view", which is exactly the guard's
16
+ * behaviour without ZeroSub. Only ids, flags, timestamps and percentages are kept —
17
+ * never emails, and never anything from a credential file (none is opened).
18
+ */
19
+ import type { ProviderUsage, Usage, UsageWindow } from "./policy.ts";
20
+
21
+ export type ZeroSubFamily = "claude" | "codex";
22
+
23
+ export interface ZeroSubAccount {
24
+ readonly id: string;
25
+ readonly family: ZeroSubFamily;
26
+ readonly kind: "main" | "managed";
27
+ /** A name the person chose, or null when ZeroSub derived it from the email. */
28
+ readonly label: string | null;
29
+ readonly signedIn: boolean;
30
+ readonly disabled: boolean;
31
+ readonly limitedUntil: string | null;
32
+ readonly limitKind: "window" | "budget" | null;
33
+ }
34
+
35
+ export interface ZeroSubWindow {
36
+ readonly id: string;
37
+ readonly usedPct: number;
38
+ readonly resetsAt: string | null;
39
+ readonly minutes: number | null;
40
+ }
41
+
42
+ export interface ZeroSubReading {
43
+ readonly fetchedAt: string;
44
+ readonly cached: boolean;
45
+ readonly windows: readonly ZeroSubWindow[];
46
+ }
47
+
48
+ export interface ZeroSubSnapshot {
49
+ readonly accounts: readonly ZeroSubAccount[];
50
+ /** Which account each agent's live session was opened on. */
51
+ readonly sessions: Readonly<Record<string, string>>;
52
+ /** Agents whose Codex task carries on in another tab (ZeroSub continuation chains). */
53
+ readonly continuedElsewhere: ReadonlySet<string>;
54
+ readonly readings: Readonly<Record<string, ZeroSubReading>>;
55
+ /** ZeroSub's "Automatic switching" preference. */
56
+ readonly autoSwitch: boolean;
57
+ }
58
+
59
+ /** ZeroSub's ids for the CLI's own login (`server/state.ts` `MAIN_ACCOUNT_ID`). */
60
+ const MAIN_ACCOUNT: Record<ZeroSubFamily, string> = { claude: "claude-main", codex: "codex-main" };
61
+
62
+ /** ZeroSub reads usage at least every half hour for an account in use. Older is not "room". */
63
+ export const FRESH_READING_MS = 30 * 60_000;
64
+
65
+ const record = (value: unknown): Record<string, unknown> | null =>
66
+ typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
67
+ const text = (value: unknown): string | null => (typeof value === "string" && value !== "" ? value : null);
68
+ const finite = (value: unknown): number | null => (typeof value === "number" && Number.isFinite(value) ? value : null);
69
+ const family = (value: unknown): ZeroSubFamily | null => (value === "claude" || value === "codex" ? value : null);
70
+ const time = (value: string | null | undefined): number | null => {
71
+ if (!value) return null;
72
+ const ms = Date.parse(value);
73
+ return Number.isFinite(ms) ? ms : null;
74
+ };
75
+
76
+ function parseAccount(value: unknown): ZeroSubAccount | null {
77
+ const raw = record(value);
78
+ const id = text(raw?.id);
79
+ const fam = family(raw?.family);
80
+ if (!raw || !id || !fam) return null;
81
+ const label = text(raw.label);
82
+ return {
83
+ id,
84
+ family: fam,
85
+ kind: raw.kind === "managed" ? "managed" : "main",
86
+ // A generated label is the email, or part of it: never repeat it.
87
+ label: raw.autoLabel === false && label !== null && !label.includes("@") ? label : null,
88
+ signedIn: raw.signedIn !== false,
89
+ disabled: raw.disabled === true,
90
+ limitedUntil: text(raw.limitedUntil),
91
+ limitKind: raw.limitKind === "window" || raw.limitKind === "budget" ? raw.limitKind : null,
92
+ };
93
+ }
94
+
95
+ function parseReading(value: unknown): ZeroSubReading | null {
96
+ const raw = record(value);
97
+ const fetchedAt = text(raw?.fetchedAt);
98
+ if (!raw || !fetchedAt || time(fetchedAt) === null || !Array.isArray(raw.windows)) return null;
99
+ const windows: ZeroSubWindow[] = [];
100
+ for (const entry of raw.windows) {
101
+ const window = record(entry);
102
+ const id = text(window?.id);
103
+ const usedPct = finite(window?.usedPercent);
104
+ if (!window || !id || usedPct === null) continue;
105
+ windows.push({ id, usedPct, resetsAt: text(window.resetsAt), minutes: finite(window.minutes) });
106
+ }
107
+ return { fetchedAt, cached: raw.cached === true, windows };
108
+ }
109
+
110
+ /**
111
+ * ZeroSub's registry (`state.json`), usage readings (`usage.json`) and preferences, as
112
+ * read from disk. Null when the registry is unusable; a missing usage file or
113
+ * preferences file only means "no readings" and "defaults".
114
+ */
115
+ export function parseZeroSub(state: unknown, usage: unknown, preferences: unknown): ZeroSubSnapshot | null {
116
+ const raw = record(state);
117
+ if (!raw || !Array.isArray(raw.accounts)) return null;
118
+ const accounts = raw.accounts.map(parseAccount).filter((account): account is ZeroSubAccount => account !== null);
119
+ if (accounts.length === 0) return null;
120
+
121
+ const sessions: Record<string, string> = {};
122
+ for (const [agentId, entry] of Object.entries(record(raw.sessions) ?? {})) {
123
+ const accountId = text(record(entry)?.accountId);
124
+ if (accountId) sessions[agentId] = accountId;
125
+ }
126
+
127
+ // A Codex task that moved on to another tab: every member but the active one.
128
+ const continuedElsewhere = new Set<string>();
129
+ for (const entry of Object.values(record(raw.continuations) ?? {})) {
130
+ const chain = record(entry);
131
+ const active = text(chain?.activeAgentId);
132
+ if (!chain || !active) continue;
133
+ for (const member of Object.keys(record(chain.members) ?? {})) if (member !== active) continuedElsewhere.add(member);
134
+ }
135
+
136
+ const readings: Record<string, ZeroSubReading> = {};
137
+ for (const [accountId, entry] of Object.entries(record(usage) ?? {})) {
138
+ const reading = parseReading(entry);
139
+ if (reading) readings[accountId] = reading;
140
+ }
141
+
142
+ // Paseo stores plugin settings as `{ version, values }`; missing means ZeroSub's default (on).
143
+ const values = record(record(preferences)?.values);
144
+ return { accounts, sessions, continuedElsewhere, readings, autoSwitch: values?.autoSwitch !== false };
145
+ }
146
+
147
+ /** The ZeroSub family a Paseo provider belongs to, if ZeroSub manages it. */
148
+ export function familyOf(provider: string): ZeroSubFamily | null {
149
+ return provider === "claude" || provider === "codex" ? provider : null;
150
+ }
151
+
152
+ /** The account an agent runs on: its live session's, else the CLI login, as ZeroSub decides. */
153
+ export function accountOf(snapshot: ZeroSubSnapshot, agentId: string, provider: string): ZeroSubAccount | null {
154
+ const fam = familyOf(provider);
155
+ if (!fam) return null;
156
+ const id = snapshot.sessions[agentId] ?? MAIN_ACCOUNT[fam];
157
+ return snapshot.accounts.find((account) => account.id === id && account.family === fam) ?? null;
158
+ }
159
+
160
+ /**
161
+ * A ZeroSub window under the id Paseo uses for the same window, which is what the
162
+ * guard's `windowIds` and pause anchors name. Codex windows are told apart by length.
163
+ */
164
+ export function paseoWindowId(fam: ZeroSubFamily, window: ZeroSubWindow): string {
165
+ if (fam === "claude") return window.id === "seven_day" ? "weekly" : window.id;
166
+ if (window.minutes !== null) return window.minutes <= 24 * 60 ? "session" : "weekly";
167
+ return window.id === "primary" ? "session" : window.id === "secondary" ? "weekly" : window.id;
168
+ }
169
+
170
+ const limited = (account: ZeroSubAccount, now: number) => {
171
+ const until = time(account.limitedUntil);
172
+ return until !== null && until > now;
173
+ };
174
+
175
+ /**
176
+ * One account's usage as a provider row the guard reads, or an unavailable row when
177
+ * there is no trustworthy reading. A limit the CLI reported is the freshest word on a
178
+ * window, so it shows as full until it lifts.
179
+ */
180
+ export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount, now: number): ProviderUsage {
181
+ const reading = snapshot.readings[account.id];
182
+ if (!reading || reading.windows.length === 0) return { providerId: account.family, status: "unavailable", fetchedAt: null, windows: [] };
183
+ const full = limited(account, now) && account.limitKind === "window";
184
+ const windows: UsageWindow[] = reading.windows.map((window) => ({
185
+ id: paseoWindowId(account.family, window),
186
+ usedPct: full ? Math.max(100, window.usedPct) : window.usedPct,
187
+ remainingPct: null,
188
+ resetsAt: window.resetsAt,
189
+ }));
190
+ return { providerId: account.family, status: "available", fetchedAt: reading.fetchedAt, windows };
191
+ }
192
+
193
+ /**
194
+ * Whether an account could take an agent right now: usable by ZeroSub's own rules
195
+ * (signed in, not set aside, no recorded limit) and, stricter than ZeroSub, with a
196
+ * fresh reading that shows room in every window. An account with no reading is not
197
+ * counted: deferring to a switch onto an account that may be just as full would only
198
+ * trade a pause for a failed turn.
199
+ */
200
+ export function hasRoom(snapshot: ZeroSubSnapshot, account: ZeroSubAccount, thresholdPct: number, sessionWindowIds: readonly string[], now: number): boolean {
201
+ if (!account.signedIn || account.disabled || limited(account, now)) return false;
202
+ const reading = snapshot.readings[account.id];
203
+ const fetched = time(reading?.fetchedAt);
204
+ if (!reading || fetched === null || now - fetched > FRESH_READING_MS || reading.windows.length === 0) return false;
205
+ return reading.windows.every((window) => {
206
+ const limit = sessionWindowIds.includes(paseoWindowId(account.family, window)) ? thresholdPct : 100;
207
+ return window.usedPct < limit;
208
+ });
209
+ }
210
+
211
+ /** Other accounts of the agent's provider that ZeroSub could move it to now. */
212
+ export function accountsWithRoom(snapshot: ZeroSubSnapshot, current: ZeroSubAccount, thresholdPct: number, sessionWindowIds: readonly string[], now: number): number {
213
+ return snapshot.accounts.filter((account) =>
214
+ account.family === current.family && account.id !== current.id && hasRoom(snapshot, account, thresholdPct, sessionWindowIds, now)).length;
215
+ }
216
+
217
+ /** The view Session Guard's engine asks, once per pass. */
218
+ export interface AccountView {
219
+ /** Usage as the agent's own account sees it. */
220
+ usageFor(agentId: string, provider: string): Usage;
221
+ /** ZeroSub will move this agent to another account with room when its account hits the limit. */
222
+ failsOver(agentId: string, provider: string): boolean;
223
+ /** ZeroSub already carries this agent's Codex task on in another tab. */
224
+ continuedElsewhere(agentId: string): boolean;
225
+ }
226
+
227
+ export function accountView(
228
+ snapshot: ZeroSubSnapshot,
229
+ usage: Usage,
230
+ policy: { thresholdPct: number; windowIds: readonly string[] },
231
+ now: number,
232
+ ): AccountView {
233
+ return {
234
+ usageFor(agentId, provider) {
235
+ const account = accountOf(snapshot, agentId, provider);
236
+ // The CLI login is what Paseo and the recorder already measure, and measure more often.
237
+ if (!account || account.kind === "main") return usage;
238
+ const own = accountUsage(snapshot, account, now);
239
+ return { ...usage, providers: [...usage.providers.filter((p) => p.providerId !== provider), { ...own, providerId: provider }] };
240
+ },
241
+ failsOver(agentId, provider) {
242
+ if (!snapshot.autoSwitch) return false;
243
+ const account = accountOf(snapshot, agentId, provider);
244
+ return account !== null && accountsWithRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now) > 0;
245
+ },
246
+ continuedElsewhere: (agentId) => snapshot.continuedElsewhere.has(agentId),
247
+ };
248
+ }
249
+
250
+ /** What `budget_status` says about the account an agent's numbers describe. */
251
+ export interface BudgetAccount {
252
+ /** `main` is the CLI's own login, which the recorder measures; `managed` is one ZeroSub added. */
253
+ readonly kind: "main" | "managed";
254
+ readonly label: string | null;
255
+ /** Other accounts of the same provider ZeroSub could move the agent to now. */
256
+ readonly otherAccountsWithRoom: number;
257
+ readonly autoSwitch: boolean;
258
+ /** For a managed account: ZeroSub's reading of it, which replaces the recorder's numbers. */
259
+ readonly reading: { readonly fetchedAt: string; readonly windows: readonly { readonly id: string; readonly pct: number; readonly resetsAt: string | null }[] } | null;
260
+ }
261
+
262
+ /** The Claude account behind an agent's budget, as ZeroSub routes it. */
263
+ export function budgetAccount(snapshot: ZeroSubSnapshot, agentId: string, now: number): BudgetAccount | null {
264
+ const account = accountOf(snapshot, agentId, "claude");
265
+ if (!account) return null;
266
+ const reading = account.kind === "managed" ? snapshot.readings[account.id] : undefined;
267
+ return {
268
+ kind: account.kind,
269
+ label: account.label,
270
+ otherAccountsWithRoom: accountsWithRoom(snapshot, account, 100, [], now),
271
+ autoSwitch: snapshot.autoSwitch,
272
+ reading: reading
273
+ ? { fetchedAt: reading.fetchedAt, windows: reading.windows.map((window) => ({ id: window.id, pct: window.usedPct, resetsAt: window.resetsAt })) }
274
+ : null,
275
+ };
276
+ }
@@ -48,9 +48,24 @@ export const RecorderStatusSchema = z.object({
48
48
  */
49
49
  export const budgetStatus = defineRpc({
50
50
  name: "smart-session.budget",
51
- input: z.object({}),
51
+ /** The asking agent, so the answer can describe the account it runs on. Optional: panels omit it. */
52
+ input: z.object({ agentId: z.string().min(1).optional() }),
52
53
  output: z.object({
53
54
  windows: z.array(WindowStatusSchema),
55
+ /**
56
+ * Which Claude account `windows` describe, when ZeroSub runs agents on several. Absent
57
+ * without ZeroSub, or without an agent: then it is always the CLI's own login.
58
+ */
59
+ account: z
60
+ .object({
61
+ kind: z.enum(["main", "managed"]),
62
+ label: z.string().nullable(),
63
+ otherAccountsWithRoom: z.number(),
64
+ autoSwitch: z.boolean(),
65
+ /** When ZeroSub took the reading `windows` come from; null when they are the recorder's. */
66
+ readAt: z.string().nullable(),
67
+ })
68
+ .optional(),
54
69
  /** Finished session windows from the last 14 days, oldest first. */
55
70
  past: z.array(PastWindowSchema).optional(),
56
71
  recorder: RecorderStatusSchema,
@@ -1,26 +0,0 @@
1
- import type { PluginButtonIcon, PluginButtonIconProps } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
3
-
4
- // Like Smart Compact, keep the host's pill chrome and color the icon slot.
5
- function GuardOnIcon({ theme, size }: PluginButtonIconProps) {
6
- return <Icon name="ShieldCheck" size={size} color={theme.colors.foreground} />;
7
- }
8
-
9
- function GuardOffIcon({ theme, size }: PluginButtonIconProps) {
10
- return <Icon name="ShieldOff" size={size} color={theme.colors.foregroundMuted} />;
11
- }
12
-
13
- function GuardPausedIcon({ theme, size }: PluginButtonIconProps) {
14
- return <Icon name="Pause" size={size} color={theme.colors.statusWarning} />;
15
- }
16
-
17
- function GuardAttentionIcon({ theme, size }: PluginButtonIconProps) {
18
- return <Icon name="TriangleAlert" size={size} color={theme.colors.statusDanger} />;
19
- }
20
-
21
- export const guardPillIcons: Readonly<Record<string, PluginButtonIcon>> = {
22
- ShieldCheck: GuardOnIcon,
23
- ShieldOff: GuardOffIcon,
24
- Pause: GuardPausedIcon,
25
- TriangleAlert: GuardAttentionIcon,
26
- };
@@ -1,132 +0,0 @@
1
- /**
2
- * The Session Guard pill: one per live session, beside Smart Compact's.
3
- *
4
- * Drawn only while this plugin is the host's Session Guard governor. Off, there is
5
- * nothing for it to say; deferring to the standalone plugin, that plugin draws its own,
6
- * and two shields on one composer would contradict each other. While the feature is
7
- * being turned off, a session that still has a pause keeps its pill so the pause can
8
- * be released from where it is.
9
- */
10
- import type { PluginButtonIcon, PluginClientContext } from "@getpaseo/plugin/client";
11
- import type { AgentDirectory } from "../agents.ts";
12
- import { GUARD_SCREENS, GUARD_SETTINGS } from "./surfaces.ts";
13
- import type { Live } from "../live.ts";
14
- import { cancelResumeRpc, setProtectionRpc, statusRpc } from "../../shared/guard/contracts.ts";
15
- import { isPending, protects, type Pause, type Policy } from "../../shared/guard/policy.ts";
16
-
17
- export function pillPresentation(policy: Policy, enabled: boolean, pause: Pause | undefined) {
18
- if (!policy.enabled) return { label: "Guard off", title: "Session Guard is disabled on this host", icon: "ShieldOff" };
19
- if (!enabled) return { label: "Guard off", title: "This session is excluded from automatic quota protection", icon: "ShieldOff" };
20
- if (pause?.phase === "attention") return { label: "Check session", title: pause.note ?? "Check this session before continuing", icon: "TriangleAlert" };
21
- if (pause) {
22
- if (pause.demo) return { label: "Paused · demo", title: "This test session is paused. Try Resume now in its timeline; no provider quota was changed.", icon: "Pause" };
23
- const date = pause.anchorResetsAt ? new Date(pause.anchorResetsAt) : null;
24
- const time = date && Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null;
25
- const resetDetail = date ? ` Expected reset ${date.toLocaleString([], { dateStyle: "short", timeStyle: "short" })}.` : " Reset time is not available yet.";
26
- const due = date !== null && Number.isFinite(date.getTime()) && date.getTime() <= Date.now();
27
- return { label: due ? "Paused · reset due" : time ? `Paused · ${time}` : "Paused · awaiting reset", title: (policy.autoResume
28
- ? `Paused at the session limit${pause.suspension ? ", including native subagents" : ""}. Automatic continuation is scheduled after reset.`
29
- : pause.suspension ? "Session and native subagents are paused. Release and turn off to continue manually; automatic continuation is off."
30
- : "Paused at the session limit. Continue manually after reset; automatic continuation is off.") + resetDetail, icon: "Pause" };
31
- }
32
- return { label: "Session Guard", title: policy.autoResume
33
- ? `Pause at ${policy.thresholdPct}% session usage and continue after reset`
34
- : `Pause at ${policy.thresholdPct}% session usage; continue manually after reset`, icon: "ShieldCheck" };
35
- }
36
-
37
- /** How often a client re-reads Session Guard status while it is on, and while it is off. */
38
- const ACTIVE_POLL_MS = 5_000;
39
- const IDLE_POLL_MS = 60_000;
40
-
41
- const wakers = new Set<() => void>();
42
- /**
43
- * Re-reads status now. The settings screens call it whenever the Session Guard
44
- * document changes — their `useSettings` hears changes from every client — so pills
45
- * follow a switch without polling hard while the feature is off.
46
- */
47
- export function refreshGuardPills(): void {
48
- for (const wake of [...wakers]) wake();
49
- }
50
-
51
- export function contributePills(client: PluginClientContext, agents: AgentDirectory, icons: Readonly<Record<string, PluginButtonIcon>> = {}, live?: Live) {
52
- let stopped = false;
53
- let running = false;
54
- let status: Awaited<ReturnType<typeof readStatus>> | null = null;
55
- const handles = new Map<string, { workspaceId: string; signature: string; registration: ReturnType<PluginClientContext["addComposerPill"]> }>();
56
- const readStatus = () => client.rpc(statusRpc, {});
57
- let timer: ReturnType<typeof setTimeout> | null = null;
58
- const refresh = async () => {
59
- if (stopped || running) return;
60
- running = true;
61
- if (timer !== null) clearTimeout(timer);
62
- timer = null;
63
- try {
64
- const next = await readStatus();
65
- if (!stopped) {
66
- status = next; reconcile();
67
- // The sidebar row counts pauses only this process can act on.
68
- const pending = next.governor === "active" ? next.pauses.filter(isPending) : [];
69
- live?.setPaused({ waiting: pending.filter(p => p.phase !== "attention").length, attention: pending.filter(p => p.phase === "attention").length });
70
- }
71
- }
72
- catch { /* Keep the last confirmed presentation across a brief disconnect. */ }
73
- finally {
74
- running = false;
75
- // Off here and nobody governing from here: nothing to draw, so check rarely.
76
- const idle = status !== null && !status.policy.enabled && status.governor === "off";
77
- if (!stopped) timer = setTimeout(() => void refresh(), idle ? IDLE_POLL_MS : ACTIVE_POLL_MS);
78
- }
79
- };
80
- function reconcile() {
81
- if (stopped || !status) return;
82
- const sessions = new Map<string, string>();
83
- if (status.governor === "active") {
84
- for (const [agentId, agent] of agents.current()) {
85
- if (agent.closed) continue;
86
- if (status.policy.enabled || status.pauses.some(p => p.agentId === agentId && isPending(p))) sessions.set(agentId, agent.workspaceId);
87
- }
88
- }
89
- for (const [id, handle] of handles) {
90
- if (sessions.get(id) !== handle.workspaceId) { handle.registration.remove(); handles.delete(id); }
91
- }
92
- for (const [agentId, workspaceId] of sessions) {
93
- const enabled = protects(status.policy, status.overrides, agentId);
94
- const pause = status.pauses.find(p => p.agentId === agentId && isPending(p));
95
- const presentation = pillPresentation(status.policy, enabled, pause);
96
- const signature = JSON.stringify([presentation, enabled, agentId in status.overrides, pause?.id]);
97
- const handle = handles.get(agentId);
98
- // Preserve the host menu while polling or receiving agent updates.
99
- // Replacing behavior on every read remounts an open adaptive menu.
100
- if (handle?.signature === signature) continue;
101
- const button = {
102
- ...presentation,
103
- icon: icons[presentation.icon] ?? presentation.icon,
104
- behavior: { kind: "menu" as const, items: [
105
- { kind: "item" as const, id: "toggle", title: enabled ? "Turn off for this session" : "Protect this session", icon: enabled ? "ShieldOff" : "ShieldCheck",
106
- behavior: { kind: "action" as const, onPress: async () => { await client.rpc(setProtectionRpc, { agentId, enabled: !enabled }); await refresh(); } } },
107
- { kind: "item" as const, id: "default", title: "Use host default", visible: agentId in status.overrides,
108
- behavior: { kind: "action" as const, onPress: async () => { await client.rpc(setProtectionRpc, { agentId, enabled: null }); await refresh(); } } },
109
- { kind: "item" as const, id: "cancel", title: pause?.suspension ? "Release and turn off for this session" : "Cancel automatic continuation", visible: !!pause,
110
- behavior: { kind: "action" as const, onPress: async () => { await client.rpc(cancelResumeRpc, { agentId }); await refresh(); } } },
111
- { kind: "separator" as const, id: "separator" },
112
- { kind: "item" as const, id: "paused-sessions", title: "All paused sessions", icon: "Pause",
113
- behavior: { kind: "action" as const, onPress: () => { client.openScreen({ screenId: GUARD_SCREENS.paused }); } } },
114
- { kind: "item" as const, id: "settings", title: "Session Guard settings", icon: "Settings",
115
- behavior: { kind: "action" as const, onPress: () => { client.openSettings(GUARD_SETTINGS); } } },
116
- ] },
117
- };
118
- if (handle) { handle.registration.update(button); handle.signature = signature; }
119
- else handles.set(agentId, { workspaceId, signature, registration: client.addComposerPill({ id: "session-guard", workspaceId, agentId, button }) });
120
- }
121
- }
122
- const unsubscribe = agents.subscribe(reconcile);
123
- const wake = () => void refresh();
124
- wakers.add(wake);
125
- void refresh();
126
- return () => {
127
- stopped = true; wakers.delete(wake); unsubscribe();
128
- if (timer !== null) clearTimeout(timer);
129
- for (const handle of handles.values()) handle.registration.remove();
130
- handles.clear();
131
- };
132
- }
@@ -1,114 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { ActivityIndicator, Text, View } from "react-native";
3
- import { useRpc, useSettings, type PluginSurfaceProps } from "@getpaseo/plugin/client";
4
- import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSelect, SettingsSwitch } from "@getpaseo/plugin/client/ui";
5
- import { cancelResumeRpc, guardPreferences, setProtectionRpc, statusRpc } from "../../shared/guard/contracts.ts";
6
- import { isPending, PolicySchema, type Policy } from "../../shared/guard/policy.ts";
7
- import { governorNotice, pauseOwnerNote } from "../../shared/guard/presentation.ts";
8
- import { refreshGuardPills } from "./pills.ts";
9
-
10
- type Draft = { thresholdPct: string; pollSeconds: string; resetGraceSeconds: string; resumeSpacingSeconds: string; continuation: string; providerIds: string; windowIds: string };
11
- function draftOf(policy: Policy): Draft {
12
- return { thresholdPct: String(policy.thresholdPct), pollSeconds: String(policy.pollSeconds), resetGraceSeconds: String(policy.resetGraceSeconds),
13
- resumeSpacingSeconds: String(policy.resumeSpacingSeconds), continuation: policy.continuation, providerIds: policy.providerIds.join(", "), windowIds: policy.windowIds.join(", ") };
14
- }
15
- const list = (value: string) => [...new Set(value.split(",").map(s => s.trim()).filter(Boolean))];
16
-
17
- export function GuardSettings({ theme, openOverview }: PluginSurfaceProps & { openOverview(): void }) {
18
- const settings = useSettings(guardPreferences);
19
- const values = settings.status === "ready" ? settings.values : undefined;
20
- // Pills poll slowly while the feature is off; tell them as soon as it changes.
21
- useEffect(() => {
22
- if (values !== undefined) refreshGuardPills();
23
- }, [values]);
24
- const readStatus = useRpc(statusRpc);
25
- const cancelResume = useRpc(cancelResumeRpc);
26
- const setProtection = useRpc(setProtectionRpc);
27
- const [status, setStatus] = useState<Awaited<ReturnType<typeof readStatus>> | null>(null);
28
- const [error, setError] = useState<string | null>(null);
29
- const [statusError, setStatusError] = useState<string | null>(null);
30
- const [draft, setDraft] = useState<Draft | null>(null);
31
- const [revision, setRevision] = useState<string | null>(null);
32
- useEffect(() => {
33
- let stopped = false;
34
- let pending = false;
35
- const refresh = async () => {
36
- if (pending || stopped) return;
37
- pending = true;
38
- try { const next = await readStatus({}); if (!stopped) { setStatus(next); setStatusError(null); } }
39
- catch { if (!stopped) setStatusError("Could not load current session status. Retrying automatically."); }
40
- finally { pending = false; }
41
- };
42
- void refresh(); const timer = setInterval(() => void refresh(), 5000);
43
- return () => { stopped = true; clearInterval(timer); };
44
- }, [readStatus]);
45
- if (settings.status === "loading") return <ActivityIndicator accessibilityLabel="Loading Session Guard settings" color={theme.colors.foregroundMuted} />;
46
- if (settings.status !== "ready") return <View style={{ gap: 12 }}>
47
- <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.error}</Text>
48
- <SettingsCard><SettingsAction label="Load saved settings again" actionLabel="Retry" onPress={() => void settings.reload()} />
49
- {settings.status === "invalid" && <SettingsAction label="Replace invalid settings with defaults" actionLabel="Reset" onPress={() => void settings.reset()} disabled={settings.saving} />}</SettingsCard>
50
- </View>;
51
- const change = (patch: Partial<Policy>) => void settings.save({ ...settings.values, ...patch }, settings.revision);
52
- const action = async (work: () => Promise<unknown>) => {
53
- try { await work(); setStatus(await readStatus({})); setError(null); }
54
- catch { setError("The change could not be saved. Try again."); }
55
- };
56
- const saveAdvanced = async () => {
57
- if (!draft || !revision) return;
58
- const parsed = PolicySchema.safeParse({ ...settings.values, ...draft,
59
- thresholdPct: Number(draft.thresholdPct), pollSeconds: Number(draft.pollSeconds), resetGraceSeconds: Number(draft.resetGraceSeconds), resumeSpacingSeconds: Number(draft.resumeSpacingSeconds),
60
- providerIds: list(draft.providerIds), windowIds: list(draft.windowIds),
61
- });
62
- if (!parsed.success) { setError(parsed.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join("\n")); return; }
63
- if (await settings.save(parsed.data, revision)) { setDraft(null); setRevision(null); setError(null); }
64
- };
65
- const text = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 };
66
- const waiting = status?.pauses.filter(isPending) ?? [];
67
- // A non-governing plugin can show the shared pauses but cannot act on them.
68
- const notice = status ? governorNotice(status) : null;
69
- const owner = status ? pauseOwnerNote(status) : null;
70
- return <View style={{ gap: 24 }}>
71
- <Text style={text}>Pause work at the provider’s session limit, then continue the same conversation after reset. Your workspace and conversation stay in place.</Text>
72
- {notice && status?.governor !== "off" && <SettingsCard><SettingsRow label={notice.title} hint={notice.hint} /></SettingsCard>}
73
- <SettingsSection title="Protection"><SettingsCard>
74
- <SettingsSwitch label="Enable Session Guard" hint="Off until you turn it on. Runs even when Paseo’s app is closed. Turning it off releases suspended workers; message continuations wait until you turn it back on or cancel them." value={settings.values.enabled} disabled={settings.saving || !!draft} onValueChange={enabled => change({ enabled })} />
75
- <SettingsSelect label="Default for sessions" hint="Each session’s composer pill can override this default. Existing explicit overrides stay in place." value={settings.values.protectByDefault ? "all" : "opt-in"} options={[{ label: "Protect all sessions", value: "all" }, { label: "Opt in per session", value: "opt-in" }]} disabled={settings.saving || !!draft} onValueChange={value => change({ protectByDefault: value === "all" })} />
76
- <SettingsSwitch label="Continue automatically after reset" hint="Waits for a fresh usage report below your threshold. Turn this off to continue manually." value={settings.values.autoResume} disabled={settings.saving || !!draft} onValueChange={autoResume => change({ autoResume })} />
77
- <SettingsSwitch label="Include native subagents" hint="On Linux, pause Claude and Codex sessions together with their nested workers. Turn this off to interrupt only the foreground turn." value={settings.values.includeNativeSubagents} disabled={settings.saving || !!draft} onValueChange={includeNativeSubagents => change({ includeNativeSubagents })} />
78
- <SettingsSwitch label="Label paused workspaces" hint="Show an amber Paused label in the left sidebar until every paused session in that workspace is released." value={settings.values.workspaceLabels} disabled={settings.saving || !!draft} onValueChange={workspaceLabels => change({ workspaceLabels })} />
79
- </SettingsCard></SettingsSection>
80
- <SettingsSection title="Waiting for reset"><SettingsCard>
81
- {!status && <SettingsRow label="Loading session status…" />}
82
- {status && waiting.length === 0 && <SettingsRow label="No sessions are paused" hint="Protected work will appear here when the session limit is reached." />}
83
- {waiting.map(pause => {
84
- const hint = pause.note ?? `${pause.provider}${pause.suspension ? " · Includes native subagents" : ""} · ${pause.anchorResetsAt ? `Reset ${new Date(pause.anchorResetsAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}` : "Waiting for usage to become available"}`;
85
- return owner ? <SettingsRow key={pause.id} label={pause.title ?? "Untitled session"} hint={`${hint}\n${owner}`} />
86
- : <SettingsAction key={pause.id} label={pause.title ?? "Untitled session"} hint={hint} actionLabel={pause.suspension ? "Release and turn off" : "Cancel continuation"} disabled={settings.saving} onPress={() => void action(() => cancelResume({ agentId: pause.agentId }))} />;
87
- })}
88
- <SettingsAction label="All paused sessions" hint="Open sessions, check reset times, and manage each pause in one place." actionLabel="Open list" onPress={openOverview} />
89
- </SettingsCard></SettingsSection>
90
- {status && Object.keys(status.overrides).length > 0 && <SettingsSection title="Session overrides"><SettingsCard>
91
- {Object.entries(status.overrides).map(([agentId, enabled]) => {
92
- const label = status.pauses.find(p => p.agentId === agentId)?.title ?? `Session ${agentId.slice(0, 8)}`;
93
- const hint = enabled ? "Explicitly protected" : "Excluded from protection";
94
- return status.governor === "active" ? <SettingsAction key={agentId} label={label} hint={hint} actionLabel="Use default" onPress={() => void action(() => setProtection({ agentId, enabled: null }))} />
95
- : <SettingsRow key={agentId} label={label} hint={hint} />;
96
- })}
97
- </SettingsCard></SettingsSection>}
98
- <SettingsSection title="Timing and continuation"><SettingsCard>
99
- {!draft ? <SettingsAction label={`Pause at ${settings.values.thresholdPct}% session usage`} hint={`Check every ${settings.values.pollSeconds}s · wait ${settings.values.resetGraceSeconds}s after reset · space continuations by ${settings.values.resumeSpacingSeconds}s`} actionLabel="Edit" onPress={() => { setDraft(draftOf(settings.values)); setRevision(settings.revision); }} /> : <>
100
- <SettingsInput label="Pause threshold (%)" hint="1–100. Default: 100." initialValue={draft.thresholdPct} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, thresholdPct: value })} />
101
- <SettingsInput label="Check interval (seconds)" hint="5–300. Upstream usage may be cached by Paseo; this cannot force a provider refresh." initialValue={draft.pollSeconds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, pollSeconds: value })} />
102
- <SettingsInput label="Wait after reset (seconds)" hint="0–600. Default: 30." initialValue={draft.resetGraceSeconds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, resetGraceSeconds: value })} />
103
- <SettingsInput label="Space continuations (seconds)" hint="0–300. Avoid sending every paused session at once." initialValue={draft.resumeSpacingSeconds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, resumeSpacingSeconds: value })} />
104
- <SettingsInput label="Continuation message" hint="Sent once into the same conversation after the reset is confirmed." initialValue={draft.continuation} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, continuation: value })} />
105
- <SettingsInput label="Provider IDs" hint="Comma separated. Leave empty to protect all providers reporting session usage." initialValue={draft.providerIds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, providerIds: value })} />
106
- <SettingsInput label="Session window IDs" hint="Comma separated, in preference order. Default: five_hour, session." initialValue={draft.windowIds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, windowIds: value })} />
107
- <SettingsAction label="Apply timing and continuation" actionLabel="Save changes" disabled={settings.saving} onPress={() => void saveAdvanced()} />
108
- <SettingsAction label="Keep saved values" actionLabel="Discard changes" disabled={settings.saving} onPress={() => { setDraft(null); setRevision(null); setError(null); }} />
109
- </>}
110
- </SettingsCard></SettingsSection>
111
- {(error || settings.saveError) && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{error ?? settings.saveError}</Text>}
112
- {(statusError || status?.error) && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{statusError ?? status?.error}</Text>}
113
- </View>;
114
- }
@@ -1,3 +0,0 @@
1
- /** Session Guard's screen and settings page, opened from its pill, the overview and the timeline. */
2
- export const GUARD_SCREENS = { paused: "paused-sessions" } as const;
3
- export const GUARD_SETTINGS = "session-guard";