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.
@@ -52,15 +52,15 @@ const PLUGIN_ID = "smart-session";
52
52
  const DEFERRED_RETRY_SECONDS = 15;
53
53
 
54
54
  const STANDALONE_MESSAGE =
55
- "The standalone Session Guard plugin is enabled on this host, so Smart Session leaves session protection to it and pauses nothing. " +
55
+ "The standalone Session Guard plugin is enabled on this host, so Smart Session leaves pausing at the limit to it. " +
56
56
  "Disable or uninstall Session Guard to hand over; its per-session choices and waiting pauses carry over.";
57
57
  const HANDED_BACK_MESSAGE =
58
- "The standalone Session Guard plugin was enabled while Smart Session was protecting this host, so Smart Session released its paused sessions and stopped. " +
58
+ "The standalone Session Guard plugin was enabled while Smart Session was pausing at the limit here, so Smart Session released its paused sessions and stopped. " +
59
59
  "Disable or uninstall Session Guard to hand back to Smart Session, or reload Session Guard to let it take over.";
60
60
  const LOCK_HELD_MESSAGE =
61
- "Another process is already running Session Guard on this host, most likely a second Smart Session install. Nothing is paused from here until it stops.";
62
- const UNCHECKED_MESSAGE = "Session Guard could not check which plugins are installed, so it is not pausing anything yet. It will retry.";
63
- const OFF_MESSAGE = "Session Guard is off on this host. Turn it on in Smart Session's Session Guard settings.";
61
+ "Another process already pauses sessions at the limit on this host, most likely a second Smart Session install. Nothing is paused from here until it stops.";
62
+ const OFF_MESSAGE = "Pause at the limit is off on this host. Turn it on in Smart Session settings.";
63
+ const UNCHECKED_MESSAGE = "Smart Session could not check which plugins are installed, so it is not pausing anything yet. It will retry.";
64
64
 
65
65
  /** The daemon connection and process control, replaceable only by tests. */
66
66
  export interface GuardDependencies {
@@ -134,7 +134,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
134
134
  release = await store.lock();
135
135
  } catch (error) {
136
136
  governor = error instanceof LockHeldError ? "deferred" : "error";
137
- lockError = governor === "deferred" ? LOCK_HELD_MESSAGE : "Session Guard could not take its lock. Check its saved state and try again.";
137
+ lockError = governor === "deferred" ? LOCK_HELD_MESSAGE : "Pause at the limit could not take its lock. Check its saved state and try again.";
138
138
  return;
139
139
  }
140
140
  try {
@@ -143,7 +143,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
143
143
  lockError = null;
144
144
  } catch {
145
145
  governor = "error";
146
- lockError = "Session Guard could not read its saved state. Restore state.json before continuing.";
146
+ lockError = "Pause at the limit could not read its saved state (session-guard/state.json). Restore it before continuing.";
147
147
  const unlock = release;
148
148
  release = null;
149
149
  await unlock?.();
@@ -197,7 +197,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
197
197
  policy = await readPolicy();
198
198
  settingsError = null;
199
199
  } catch {
200
- settingsError = "Session Guard settings are invalid. Reset them in Session Guard settings.";
200
+ settingsError = "The Pause at the limit settings are invalid. Reset them in Smart Session settings.";
201
201
  }
202
202
  if (!policy?.enabled && governor !== "active") {
203
203
  governor = "off";
@@ -15,7 +15,7 @@ export function pausePresentation(pause: Pause, policy: Policy, now = Date.now()
15
15
  ? "Reset due · waiting for fresh usage"
16
16
  : `Reset ${new Date(reset).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`;
17
17
  const state = pause.phase === "attention" ? "Needs attention" : pause.phase === "pausing" ? "Pausing"
18
- : pause.phase === "resuming" ? "Resuming" : pause.demo ? "Demo · resume when ready" : !policy.enabled ? "Guard off · continuation on hold"
18
+ : pause.phase === "resuming" ? "Resuming" : pause.demo ? "Demo · resume when ready" : !policy.enabled ? "Protection off · continuation on hold"
19
19
  : !policy.autoResume ? "Automatic resume off" : "Will resume automatically after reset";
20
20
  return {
21
21
  title: pause.title?.trim() || "Untitled session", state, resetLabel,
@@ -62,17 +62,20 @@ export function agentGuardSummary(status: {
62
62
  type GovernorView = { governor: "off" | "active" | "deferred" | "error"; standalone: boolean | null; error: string | null };
63
63
 
64
64
  /**
65
- * Why this plugin is not pausing anything, for the top of each Session Guard view.
66
- * Null while it is the host's governor.
65
+ * Why this plugin is not pausing anything, for the Protection part of each view.
66
+ * Null while it is the host's governor. The standalone plugin keeps its own name:
67
+ * it is a different plugin the person installed, not a part of this one.
67
68
  */
68
69
  export function governorNotice(status: GovernorView): { title: string; hint: string } | null {
69
70
  if (status.governor === "active") return null;
70
- if (status.governor === "off") return { title: "Session Guard is off", hint: "Nothing is paused from Smart Session. Turn it on in Session Guard settings to pause sessions at the provider's session limit." };
71
+ if (status.governor === "off") return { title: "Pause at the limit is off", hint: "Nothing pauses at the session limit. Turn it on in Smart Session settings." };
71
72
  if (status.governor === "deferred" && status.standalone !== false) {
72
- return { title: "The standalone Session Guard plugin is in charge", hint: status.error ?? "Disable or uninstall Session Guard to hand over; its per-session choices and waiting pauses carry over." };
73
+ // One calm line; the server's longer story only adds what needs a look.
74
+ const stuck = status.error?.includes("could not be released") ? " Some paused sessions could not be released; check them." : "";
75
+ return { title: "The standalone Session Guard plugin handles pausing here", hint: `Disable or uninstall it to hand back; its per-session choices and waiting pauses carry over.${stuck}` };
73
76
  }
74
- if (status.governor === "deferred") return { title: "Another process is running Session Guard", hint: status.error ?? "Nothing is paused from here until it stops." };
75
- return { title: "Session Guard is not running", hint: status.error ?? "It will retry automatically." };
77
+ if (status.governor === "deferred") return { title: "Another process handles pausing here", hint: status.error ?? "Nothing is paused from here until it stops." };
78
+ return { title: "Pause at the limit isn't running", hint: status.error ?? "It will retry automatically." };
76
79
  }
77
80
 
78
81
  /**
@@ -83,8 +86,60 @@ export function governorNotice(status: GovernorView): { title: string; hint: str
83
86
  */
84
87
  export function pauseOwnerNote(status: GovernorView): string | null {
85
88
  if (status.governor === "active") return null;
86
- if (status.governor === "off") return "Saved by the standalone Session Guard plugin. Smart Session takes it over once Session Guard is on here and the standalone plugin is disabled or uninstalled.";
87
- if (status.governor === "deferred" && status.standalone !== false) return "Managed by the standalone Session Guard plugin; change it there.";
88
- if (status.governor === "deferred") return "Managed by another Session Guard process; it can be changed only from there.";
89
- return "Session Guard is not running here, so this pause cannot be changed until it recovers.";
89
+ if (status.governor === "off") return "Saved by the standalone Session Guard plugin. Smart Session takes it over once Pause at the limit is on here and that plugin is disabled or uninstalled.";
90
+ if (status.governor === "deferred" && status.standalone !== false) return "Handled by the standalone Session Guard plugin; change it there.";
91
+ if (status.governor === "deferred") return "Handled by another process on this host; change it there.";
92
+ return "Pause at the limit isn't running here, so this pause can't be changed until it recovers.";
90
93
  }
94
+
95
+ /**
96
+ * A paused session's composer pill: the loud state, so the label is the moment it
97
+ * comes back. A clock rather than a countdown, so the label changes once instead of
98
+ * every minute (each change is a registration update).
99
+ */
100
+ export function pausePill(pause: Pause, policy: Policy, now = Date.now(), clock: (iso: string) => string = (iso) => new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })) {
101
+ const reset = timestamp(pause.anchorResetsAt);
102
+ // The title is also the phone sheet's title: one short sentence, the detail is in the sheet.
103
+ if (pause.phase === "attention") return { label: "Check session", title: "Paused, then something changed · check it", tone: "danger" as const };
104
+ if (pause.phase === "pausing") return { label: "Pausing…", title: "Pausing at the session limit", tone: "warning" as const };
105
+ if (pause.phase === "resuming") return { label: "Resuming…", title: "The window reset · continuing now", tone: "warning" as const };
106
+ if (pause.demo) return { label: "Paused · test", title: "Test pause · waits for you", tone: "warning" as const };
107
+ if (!policy.enabled) return { label: "Paused · on hold", title: "Paused · on hold while Pause at the limit is off", tone: "warning" as const };
108
+ if (reset === null) return { label: "Paused", title: "Paused at the limit · reset time not reported yet", tone: "warning" as const };
109
+ const at = clock(pause.anchorResetsAt!);
110
+ if (reset <= now) return { label: "Paused · reset due", title: `Paused at the limit · reset was ${at}`, tone: "warning" as const };
111
+ return policy.autoResume
112
+ ? { label: `Resumes ${at}`, title: `Paused at the limit · resumes ${at}`, tone: "warning" as const }
113
+ : { label: `Paused · resets ${at}`, title: `Paused at the limit · resets ${at}, continue by hand`, tone: "warning" as const };
114
+ }
115
+
116
+ /**
117
+ * The lead for a set of pending pauses: when they come back, or that one needs a
118
+ * look. Null when nothing is paused. `shared` is the one reset they all wait for,
119
+ * when they share it, so rows only repeat a time that differs.
120
+ */
121
+ export function pausedLead(pending: readonly Pause[], autoResume: boolean, now: number, clock: (ms: number) => string): { title: string; tone: "warning" | "danger"; shared: number | null; next: number | null } | null {
122
+ if (pending.length === 0) return null;
123
+ const waiting = pending.filter((p) => p.phase !== "attention");
124
+ const stuck = pending.length - waiting.length;
125
+ const resets = waiting.map((p) => timestamp(p.anchorResetsAt)).filter((v): v is number => v !== null && v > now).sort((a, b) => a - b);
126
+ const shared = resets.length === waiting.length && resets.length > 0 && resets[resets.length - 1]! - resets[0]! < 60_000 ? resets[0]! : null;
127
+ const n = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;
128
+ const tone = stuck > 0 ? "danger" as const : "warning" as const;
129
+ const next = resets[0] ?? null;
130
+ if (waiting.length === 0) return { title: `${n(stuck, "paused session needs", "paused sessions need")} a look`, tone, shared, next };
131
+ const look = stuck > 0 ? ` · ${stuck} ${stuck === 1 ? "needs" : "need"} a look` : "";
132
+ if (shared !== null) return { title: (autoResume ? `${n(waiting.length, "session resumes", "sessions resume")} at ${clock(shared)}` : `${n(waiting.length, "session waits", "sessions wait")} for the ${clock(shared)} reset`) + look, tone, shared, next };
133
+ if (next !== null) return { title: `${n(waiting.length, "session", "sessions")} paused · next ${autoResume ? "resumes" : "reset"} at ${clock(next)}${look}`, tone, shared, next };
134
+ return { title: `${n(waiting.length, "session waits", "sessions wait")} for a fresh reading${look}`, tone, shared, next };
135
+ }
136
+
137
+ /**
138
+ * The one action set for a waiting pause, wherever it shows: the chat card, the
139
+ * pill's popover and the overview's list. Same words, same order, same hint.
140
+ */
141
+ export const PAUSE_ACTIONS = {
142
+ resume: { label: "Resume now", busy: "Resuming…", icon: "Play", hint: "Continues before the reset and stops this session pausing at the limit. The provider may still refuse work until the reset." },
143
+ dismiss: { label: "Dismiss pause", busy: "Dismissing…", hint: "Lets the session go without sending a message and stops it pausing at the limit." },
144
+ note: "Resume now stops this session pausing at the limit · Dismiss sends nothing",
145
+ } as const;
@@ -17,15 +17,15 @@ export function timelinePresentation(data: TimelineData) {
17
17
  if (data.event === "released" && data.dismissed) return { title: "Pause dismissed", icon: "ShieldOff", tone: "muted" as const,
18
18
  detail: "No message was sent. Protection is off for this session." + (data.native ? " Existing workers were released; their current work may continue." : "") };
19
19
  if (data.event === "resumed") return { title: "Session resumed", icon: "Play", tone: "success" as const,
20
- detail: data.manualResume ? "Resumed by you. Protection is off for this session; restore it from the composer pill when ready."
20
+ detail: data.manualResume ? "Resumed by you. Pause at the limit is off for this session; turn it back on from the composer pill."
21
21
  : data.native ? "Session and native subagents resumed after the quota reset." : "A continuation was sent after the quota reset." };
22
22
  if (data.event === "released") return { title: data.native ? "Session released" : "Automatic resume cancelled", icon: "ShieldOff", tone: "muted" as const,
23
23
  detail: data.note ?? (data.native ? "Suspended workers were released." : "Continue this session manually when ready.") };
24
24
  if (data.event === "attention") return { title: "Pause needs attention", icon: "TriangleAlert", tone: "danger" as const,
25
25
  detail: data.note ?? "Check this session before continuing manually." };
26
26
  if (data.event === "recovery") return { title: "Waiting after Paseo restart", icon: "Pause", tone: "warning" as const,
27
- detail: `The original workers ended, but this pause is saved. ${data.autoResume ? "Session Guard will continue this conversation after the quota resets." : "Automatic resume is off; continue manually when ready."} Your workspace and conversation remain in place.` };
28
- if (data.demo) return { title: "Session Guard demo paused", icon: "Pause", tone: "warning" as const,
27
+ detail: `The original workers ended, but this pause is saved. ${data.autoResume ? "Smart Session continues this conversation after the window resets." : "Automatic resume is off; continue by hand when ready."} Your workspace and conversation remain in place.` };
28
+ if (data.demo) return { title: "Test pause", icon: "Pause", tone: "warning" as const,
29
29
  detail: "This empty test session is suspended so you can try Resume now. No provider quota was changed, and no task was sent. It will wait for you to resume it." };
30
30
  const reset = data.resetAt ? Date.parse(data.resetAt) : NaN;
31
31
  const resetLabel = Number.isFinite(reset) ? `Expected reset ${new Date(reset).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}.` : "The provider has not reported a reset time yet.";
@@ -0,0 +1,37 @@
1
+ import { PolicySchema, type Policy } from "./policy.ts";
2
+
3
+ /** The limit-timing fields, as typed. Nothing is saved until every one parses. */
4
+ export type Timing = { thresholdPct: string; pollSeconds: string; resetGraceSeconds: string; resumeSpacingSeconds: string; continuation: string; providerIds: string; windowIds: string };
5
+ const NUMERIC = ["thresholdPct", "pollSeconds", "resetGraceSeconds", "resumeSpacingSeconds"] as const;
6
+ const LISTS = ["providerIds", "windowIds"] as const;
7
+
8
+ export function timingOf(policy: Policy): Timing {
9
+ return {
10
+ thresholdPct: String(policy.thresholdPct), pollSeconds: String(policy.pollSeconds), resetGraceSeconds: String(policy.resetGraceSeconds),
11
+ resumeSpacingSeconds: String(policy.resumeSpacingSeconds), continuation: policy.continuation, providerIds: policy.providerIds.join(", "), windowIds: policy.windowIds.join(", "),
12
+ };
13
+ }
14
+ const list = (value: string) => [...new Set(value.split(",").map((s) => s.trim()).filter(Boolean))];
15
+
16
+ /** Each field checked on its own, so the message sits under the field it is about. */
17
+ export function checkTiming(draft: Timing): { values: Partial<Policy>; errors: Partial<Record<keyof Timing, string>> } {
18
+ const values: Record<string, unknown> = {};
19
+ const errors: Partial<Record<keyof Timing, string>> = {};
20
+ const range: Record<(typeof NUMERIC)[number], string> = {
21
+ thresholdPct: "Enter a number from 1 to 100.", pollSeconds: "Enter whole seconds from 5 to 300.",
22
+ resetGraceSeconds: "Enter whole seconds from 0 to 600.", resumeSpacingSeconds: "Enter whole seconds from 0 to 300.",
23
+ };
24
+ for (const key of NUMERIC) {
25
+ const raw = draft[key].trim();
26
+ const parsed = PolicySchema.shape[key].safeParse(raw === "" ? NaN : Number(raw));
27
+ if (parsed.success) values[key] = parsed.data; else errors[key] = range[key];
28
+ }
29
+ const continuation = PolicySchema.shape.continuation.safeParse(draft.continuation);
30
+ if (continuation.success) values.continuation = continuation.data; else errors.continuation = "Write a message of 1 to 8,000 characters.";
31
+ for (const key of LISTS) {
32
+ const parsed = PolicySchema.shape[key].safeParse(list(draft[key]));
33
+ if (parsed.success) values[key] = parsed.data; else errors[key] = "Name at least one window, e.g. five_hour.";
34
+ }
35
+ return { values: values as Partial<Policy>, errors };
36
+ }
37
+
@@ -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";