paseo-smart-session 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +55 -130
  2. package/assets/LICENSE.lucide +43 -0
  3. package/assets/icon.png +0 -0
  4. package/assets/icon.svg +1 -0
  5. package/client/agents.ts +200 -0
  6. package/client/charts.tsx +277 -152
  7. package/client/chat.tsx +122 -0
  8. package/client/display.tsx +55 -33
  9. package/client/guard/index.tsx +48 -0
  10. package/client/guard/paused.tsx +164 -0
  11. package/client/guard/pill-icons.tsx +26 -0
  12. package/client/guard/pills.ts +132 -0
  13. package/client/guard/settings.tsx +114 -0
  14. package/client/guard/surfaces.ts +3 -0
  15. package/client/guard/timeline.tsx +154 -0
  16. package/client/kit.tsx +144 -0
  17. package/client/live.ts +49 -0
  18. package/client/motion.tsx +94 -0
  19. package/client/overview.tsx +373 -0
  20. package/client/pill-store.ts +77 -0
  21. package/client/pill.tsx +91 -380
  22. package/client/popover.tsx +113 -0
  23. package/client/settings.tsx +105 -0
  24. package/client/theme.ts +56 -0
  25. package/docs/media/smart-session-chat.png +0 -0
  26. package/docs/media/smart-session-flow.gif +0 -0
  27. package/docs/media/smart-session-guard.png +0 -0
  28. package/docs/media/smart-session-hero-light.png +0 -0
  29. package/docs/media/smart-session-hero.png +0 -0
  30. package/docs/media/smart-session-mobile.png +0 -0
  31. package/docs/media/smart-session-pill.png +0 -0
  32. package/docs/navigation-verification.md +6 -6
  33. package/docs/screenshots/display-settings-desktop.png +0 -0
  34. package/docs/screenshots/display-settings-mobile.png +0 -0
  35. package/docs/session-guard.md +88 -0
  36. package/index.client.tsx +52 -35
  37. package/index.server.ts +19 -11
  38. package/mcp.mjs +22 -3
  39. package/package.json +2 -1
  40. package/paseo-plugin.json +11 -0
  41. package/server/guard/client.ts +27 -0
  42. package/server/guard/engine.ts +554 -0
  43. package/server/guard/index.ts +395 -0
  44. package/server/guard/labels.ts +77 -0
  45. package/server/guard/processes.ts +147 -0
  46. package/server/guard/standalone.ts +61 -0
  47. package/server/guard/store.ts +113 -0
  48. package/server/guard/timeline.ts +24 -0
  49. package/server/guard/usage.ts +57 -0
  50. package/server/recorder.ts +2 -0
  51. package/server/settings.ts +10 -0
  52. package/server/store.ts +25 -1
  53. package/shared/blocks.ts +15 -0
  54. package/shared/chat.ts +174 -0
  55. package/shared/forecast.ts +233 -0
  56. package/shared/format.ts +47 -1
  57. package/shared/governor.ts +3 -0
  58. package/shared/guard/contracts.ts +65 -0
  59. package/shared/guard/policy.ts +128 -0
  60. package/shared/guard/presentation.ts +85 -0
  61. package/shared/guard/timeline.ts +34 -0
  62. package/shared/lifecycle.ts +18 -0
  63. package/shared/smart-session.ts +16 -0
  64. package/client/navigation.tsx +0 -14
  65. package/client/surface.tsx +0 -574
  66. package/docs/screenshots/smart-compact-handoff.png +0 -0
@@ -1,73 +1,96 @@
1
- import type { PluginClientContext, PluginSidebarContribution, PluginSurfaceProps } from "@getpaseo/plugin/client";
1
+ import type { PluginClientContext, PluginSidebarItemProps, PluginSurfaceProps } from "@getpaseo/plugin/client";
2
2
  import { useSettings } from "@getpaseo/plugin/client";
3
- import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
3
+ import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch, SidebarRow } from "@getpaseo/plugin/client/ui";
4
4
  import { useEffect } from "react";
5
5
  import { ActivityIndicator, ScrollView, Text, View } from "react-native";
6
6
  import { displayPreferences, displayRpc } from "../shared/display";
7
- import { withNavigation } from "./navigation";
7
+ import { Chip, useNow } from "./kit";
8
+ import { useLive, type Live } from "./live";
9
+ import { OVERVIEW_SCREEN, sidebarTrailing } from "./overview";
8
10
  import { sidebarVisibility } from "./sidebar-visibility";
11
+ import { tabular, toneColor } from "./theme";
9
12
 
10
13
  type Display = import("zod").infer<typeof displayPreferences.schema>;
11
14
  const listeners = new Set<(values: Display) => void>();
12
15
  export function subscribeDisplay(listener: (values: Display) => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }
13
16
 
14
- function DisplaySettings({ theme }: PluginSurfaceProps) {
17
+ /** Where Smart Session shows up. Hiding something never turns its feature off. */
18
+ function DisplaySettings({ theme, layout }: PluginSurfaceProps) {
15
19
  const settings = useSettings(displayPreferences);
16
20
  const values = settings.status === "ready" ? settings.values : undefined;
17
21
  useEffect(() => {
18
22
  if (values !== undefined) for (const listener of listeners) listener(values);
19
23
  }, [values]);
20
24
 
21
- if (settings.status === "loading") return <ActivityIndicator accessibilityLabel="Loading display settings" color={theme.colors.foregroundMuted} />;
25
+ if (settings.status === "loading") return <ActivityIndicator accessibilityLabel="Loading display settings" color={theme.colors.foregroundMuted} style={{ padding: 24 }} />;
22
26
  if (settings.status !== "ready") return (
23
- <View style={{ gap: 12 }}>
27
+ <View style={{ gap: 12, padding: 16 }}>
24
28
  <Text style={{ color: theme.colors.statusDanger }}>{settings.error}</Text>
25
29
  <SettingsCard>
26
30
  <SettingsAction label="Try loading the saved settings again" actionLabel="Retry" onPress={() => void settings.reload()} />
27
31
  {settings.status === "invalid" && <SettingsAction label="Replace invalid display settings with defaults" actionLabel="Reset" disabled={settings.saving} onPress={() => void settings.reset()} />}
28
-
29
32
  </SettingsCard>
30
33
  {settings.saveError && <Text style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
31
34
  </View>
32
35
  );
33
36
 
34
37
  return (
35
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
36
- <SettingsSection title="Navigation">
37
- <SettingsCard>
38
- <SettingsSwitch
39
- label="Show in sidebar"
40
- hint="Open this plugin from Command-K (Ctrl-K on Windows and Linux) while viewing a workspace or plugin on this host."
41
- value={settings.values.showSidebar}
42
- disabled={settings.saving}
43
- onValueChange={(showSidebar) => void settings.save({ ...settings.values, showSidebar }, settings.revision)}
44
- />
45
-
46
- <SettingsSwitch label="Show composer button" hint="Command-K and the sidebar remain available when this button is hidden." value={settings.values.enable_composer_button} disabled={settings.saving} onValueChange={(enable_composer_button) => void settings.save({ ...settings.values, enable_composer_button }, settings.revision)} />
47
- </SettingsCard>
48
- {settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
49
- </SettingsSection>
38
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 8, gap: 16 }}>
39
+ <SettingsSection title="Where Smart Session appears" info="Hiding a row or a pill never turns its feature off. ⌘K Smart Session still opens the overview.">
40
+ <SettingsCard>
41
+ <SettingsSwitch
42
+ label="Sidebar row"
43
+ hint="Smart Session in the sidebar, with the session window's usage, or how many sessions are paused."
44
+ value={settings.values.showSidebar}
45
+ disabled={settings.saving}
46
+ onValueChange={(showSidebar) => void settings.save({ ...settings.values, showSidebar }, settings.revision)}
47
+ />
48
+ <SettingsSwitch
49
+ label="Composer pills"
50
+ hint="The context ring and, while Session Guard runs, its shield, above each message box."
51
+ value={settings.values.enable_composer_button}
52
+ disabled={settings.saving}
53
+ onValueChange={(enable_composer_button) => void settings.save({ ...settings.values, enable_composer_button }, settings.revision)}
54
+ />
55
+ </SettingsCard>
56
+ {settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
57
+ </SettingsSection>
50
58
  </ScrollView>
51
59
  );
52
60
  }
53
61
 
54
- /** Settings notifications reach mounted screens; poll for changes made on other clients. */
55
- export function contributeDisplay(client: PluginClientContext, item: PluginSidebarContribution) {
56
- const sidebar = sidebarVisibility(() => client.addSidebarItem(item));
62
+ /** The sidebar row, the Display settings screen, and the preference poll that drives both. */
63
+ export function contributeDisplay(client: PluginClientContext, live: Live) {
64
+ function SmartSessionSidebarItem({ currentScreen, openScreen, theme }: PluginSidebarItemProps) {
65
+ useLive(live);
66
+ const now = useNow(60_000);
67
+ const trailing = sidebarTrailing(live, now);
68
+ const c = theme.colors;
69
+ return (
70
+ <SidebarRow
71
+ icon="Gauge"
72
+ active={currentScreen?.screenId === OVERVIEW_SCREEN || currentScreen?.screenId === "paused-sessions"}
73
+ onPress={() => openScreen({ screenId: OVERVIEW_SCREEN })}
74
+ trailing={trailing === null ? undefined : trailing.tone === "muted"
75
+ ? <Text accessibilityLabel={`Session window ${trailing.label} used`} numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, ...tabular }}>{trailing.label}</Text>
76
+ : <Chip theme={theme} label={trailing.label} color={toneColor(c, trailing.tone)} />}
77
+ />
78
+ );
79
+ }
80
+
81
+ const sidebar = sidebarVisibility(() => client.addSidebarHeaderItem({ id: "smart-session", title: "Smart Session", Component: SmartSessionSidebarItem }));
57
82
  // Paseo 0.10.1 memoizes sidebar route resolution when an installation mounts.
58
83
  // Register synchronously, then reconcile the saved preference, so that an
59
- // already-open sidebar route can resolve its surface during that first render.
84
+ // already-open sidebar route can resolve its screen during that first render.
60
85
  sidebar.set(true);
61
- const Settings = withNavigation(DisplaySettings, client, item.surface, "display");
62
- const removeSurface = client.addSurface("display-settings", Settings);
63
- const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: Settings });
86
+ const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: DisplaySettings });
64
87
  const removeCommand = client.addCommandCenterItem({
65
88
  id: "display-settings",
66
- title: `${item.title}: display settings`,
89
+ title: "Smart Session: display settings",
67
90
  icon: "PanelLeft",
68
- keywords: [item.title, "sidebar", "show", "hide", "display", "settings"],
91
+ keywords: ["smart session", "sidebar", "pill", "show", "hide", "display", "settings"],
69
92
  context: "global",
70
- onSelect({ openSurface }) { openSurface("display-settings"); },
93
+ onSelect({ openSettings }) { openSettings("display"); },
71
94
  });
72
95
  let disposed = false;
73
96
  let pending = false;
@@ -95,7 +118,6 @@ export function contributeDisplay(client: PluginClientContext, item: PluginSideb
95
118
  clearInterval(timer);
96
119
  listeners.delete(update);
97
120
  sidebar.dispose();
98
- removeSurface();
99
121
  removeSettings();
100
122
  removeCommand();
101
123
  };
@@ -0,0 +1,48 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { ScrollView } from "react-native";
3
+
4
+ import { TimelineDataSchema } from "../../shared/guard/timeline.ts";
5
+ import type { AgentDirectory } from "../agents.ts";
6
+ import type { Live } from "../live.ts";
7
+ import { PausedSessions } from "./paused.tsx";
8
+ import { guardPillIcons } from "./pill-icons.tsx";
9
+ import { contributePills } from "./pills.ts";
10
+ import { GuardSettings } from "./settings.tsx";
11
+ import { GUARD_SCREENS, GUARD_SETTINGS } from "./surfaces.ts";
12
+ import { GuardTimelineItem } from "./timeline.tsx";
13
+
14
+ /** Session Guard's client half: its settings, paused-sessions screen, timeline cards and pill. */
15
+ export function contributeGuardClient(client: PluginClientContext, agents: AgentDirectory, live?: Live) {
16
+ const openPaused = () => client.openScreen({ screenId: GUARD_SCREENS.paused });
17
+ const openSettings = () => client.openSettings(GUARD_SETTINGS);
18
+ const Settings = (props: Parameters<typeof GuardSettings>[0] extends infer P ? Omit<P, "openOverview"> : never) => (
19
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 8 }}>
20
+ <GuardSettings {...props} openOverview={openPaused} />
21
+ </ScrollView>
22
+ );
23
+
24
+ const removers = [
25
+ client.addScreen({ id: GUARD_SCREENS.paused, title: "Paused sessions", Component: (props) => <PausedSessions {...props} openSettings={openSettings} /> }),
26
+ client.addSettingsScreen({ id: GUARD_SETTINGS, title: "Session Guard", icon: "ShieldCheck", Component: Settings }),
27
+ client.addCommandCenterItem({
28
+ id: "session-guard-settings", title: "Session Guard: settings", icon: "ShieldCheck",
29
+ keywords: ["quota", "limit", "reset", "pause", "resume", "protection", "session guard"], context: "global",
30
+ onSelect({ openSettings: open }) { open(GUARD_SETTINGS); },
31
+ }),
32
+ client.addCommandCenterItem({
33
+ id: "session-guard-paused", title: "Session Guard: paused sessions", icon: "Pause",
34
+ keywords: ["quota", "waiting", "reset", "resume", "subagents", "session guard"], context: "global",
35
+ onSelect({ openScreen }) { openScreen({ screenId: GUARD_SCREENS.paused }); },
36
+ }),
37
+ // The kind and version the standalone plugin wrote, so a pause it recorded and this
38
+ // plugin later delivers renders the same card.
39
+ client.addTimelineRenderer({
40
+ kind: "quota-pause", version: 1, schema: TimelineDataSchema,
41
+ Component: (props) => <GuardTimelineItem {...props} openOverview={openPaused} />,
42
+ }),
43
+ contributePills(client, agents, guardPillIcons, live),
44
+ ];
45
+ return () => {
46
+ for (const remove of removers.splice(0)) remove();
47
+ };
48
+ }
@@ -0,0 +1,164 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Text, TextInput, View } from "react-native";
3
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
4
+ import { Icon, ScrollView, useToast } from "@getpaseo/plugin/client/ui";
5
+ import { cancelResumeRpc, statusRpc } from "../../shared/guard/contracts.ts";
6
+ import { governorNotice, pauseOwnerNote, pausedSessions, pausePresentation } from "../../shared/guard/presentation.ts";
7
+ import { timestamp, type Pause } from "../../shared/guard/policy.ts";
8
+ import { formatDuration, formatRelative } from "../../shared/format.ts";
9
+ import { formatMoment } from "../../shared/forecast.ts";
10
+ import { Button, Card, CardRow, Chip, IconButton, SectionHead, text, useNow } from "../kit.tsx";
11
+ import { Skeleton } from "../motion.tsx";
12
+ import { tabular } from "../theme.ts";
13
+
14
+ const providerName = (id: string) => (id === "claude" ? "Claude" : id === "codex" ? "Codex" : id);
15
+
16
+ /**
17
+ * Every Session Guard pause on this host, as times: when each session's window
18
+ * resets and whether it continues by itself. Rows act only on pauses this process
19
+ * governs; another governor's pauses are listed read-only.
20
+ */
21
+ export function PausedSessions({ theme, host, layout, navigation, openSettings }: PluginSurfaceProps & { openSettings(): void }) {
22
+ const c = theme.colors;
23
+ const t = text(c);
24
+ const compact = layout.compact;
25
+ const toast = useToast();
26
+ const now = useNow(15_000);
27
+ const read = useRpc(statusRpc);
28
+ const cancel = useRpc(cancelResumeRpc);
29
+ const [status, setStatus] = useState<Awaited<ReturnType<typeof read>> | null>(null);
30
+ const [loadError, setLoadError] = useState<string | null>(null);
31
+ const [busy, setBusy] = useState<string | null>(null);
32
+ const [query, setQuery] = useState("");
33
+ const refresh = useRef<() => Promise<void>>(async () => {});
34
+ useEffect(() => {
35
+ let stopped = false, fetching = false;
36
+ const fetch = async () => {
37
+ if (fetching || stopped) return;
38
+ fetching = true;
39
+ try { const next = await read({}); if (!stopped) { setStatus(next); setLoadError(null); } }
40
+ catch { if (!stopped) setLoadError("Couldn't refresh paused sessions. Showing the last report; retrying."); }
41
+ finally { fetching = false; }
42
+ };
43
+ refresh.current = fetch;
44
+ void fetch(); const timer = setInterval(() => void fetch(), 5000);
45
+ return () => { stopped = true; clearInterval(timer); };
46
+ }, [read]);
47
+ const release = async (pause: Pause) => {
48
+ if (busy) return;
49
+ setBusy(pause.agentId);
50
+ try { await cancel({ agentId: pause.agentId }); await refresh.current(); toast.show(pause.suspension ? "Released · protection off for that session" : "Continuation cancelled"); }
51
+ catch { toast.error("Couldn't change this pause. Refresh and try again."); }
52
+ finally { setBusy(null); }
53
+ };
54
+
55
+ const all = status ? pausedSessions(status.pauses) : [];
56
+ const visible = status ? pausedSessions(status.pauses, query) : [];
57
+ const attention = visible.filter((p) => p.phase === "attention");
58
+ const waiting = visible.filter((p) => p.phase !== "attention");
59
+ // Both plugins share one state file: when this one is not governing, the list is
60
+ // the other plugin's (or a leftover), and its controls would only fail.
61
+ const notice = status ? governorNotice(status) : null;
62
+ const owner = status ? pauseOwnerNote(status) : null;
63
+ const autoResume = status ? status.policy.enabled && status.policy.autoResume : false;
64
+
65
+ // The headline is the moment: when they come back, or that one needs a look.
66
+ const allWaiting = all.filter((p) => p.phase !== "attention");
67
+ const allStuck = all.length - allWaiting.length;
68
+ const resets = allWaiting.map((p) => timestamp(p.anchorResetsAt)).filter((v): v is number => v !== null && v > now).sort((a, b) => a - b);
69
+ const shared = resets.length === allWaiting.length && resets.length > 0 && resets[resets.length - 1]! - resets[0]! < 60_000 ? resets[0]! : null;
70
+ const n = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;
71
+ const clock = (ms: number) => formatMoment(new Date(ms).toISOString(), now);
72
+ const title = status === null ? null
73
+ : all.length === 0 ? "Nothing is paused"
74
+ : allWaiting.length === 0 ? `${n(allStuck, "session needs", "sessions need")} a look`
75
+ : shared !== null ? (autoResume ? `${n(allWaiting.length, "session resumes", "sessions resume")} at ${clock(shared)}` : `${n(allWaiting.length, "session waits", "sessions wait")} for the ${clock(shared)} reset`)
76
+ : resets.length > 0 ? `${n(allWaiting.length, "session", "sessions")} paused · next ${autoResume ? "resumes" : "reset"} at ${clock(resets[0]!)}`
77
+ : `${n(allWaiting.length, "session waits", "sessions wait")} for a fresh reading`;
78
+ const sentence = status === null ? "" : [
79
+ shared !== null ? `in ${formatDuration(shared - now)}` : null,
80
+ allStuck > 0 && allWaiting.length > 0 ? `${allStuck} ${allStuck === 1 ? "needs" : "need"} a look` : null,
81
+ all.length === 0 || allWaiting.length === 0 ? null : owner ? "Managed by another Session Guard" : autoResume ? "Each continues in the same conversation" : "Continue each by hand after the reset",
82
+ status.checkedAt ? `Checked ${formatRelative(status.checkedAt, now)}` : "Waiting for the first usage check",
83
+ ].filter(Boolean).join(" · ");
84
+
85
+ const row = (pause: Pause, i: number) => {
86
+ const presentation = pausePresentation(pause, status!.policy, now);
87
+ const reset = timestamp(pause.anchorResetsAt);
88
+ const stuck = pause.phase === "attention";
89
+ // A row leads with what differs between rows, the session; its time only when it differs from the headline's.
90
+ const ownTime = !stuck && !pause.demo && reset !== null && reset > now && (shared === null || Math.abs(reset - shared) >= 60_000) ? formatMoment(pause.anchorResetsAt!, now) : null;
91
+ const after = stuck ? (pause.note ?? "It stopped before Session Guard could confirm the pause. Check it before continuing by hand.")
92
+ : pause.demo ? "Test pause · waits for you"
93
+ : reset === null ? "Waiting for the provider to report the reset"
94
+ : reset > now ? `${autoResume ? "Resumes" : "Resets"} in ${formatDuration(reset - now)}`
95
+ : `Reset was ${formatMoment(pause.anchorResetsAt!, now)} · waiting for a fresh reading`;
96
+ const chips = [providerName(pause.provider), presentation.coverage];
97
+ const canAct = owner === null;
98
+ return <CardRow key={pause.id} theme={theme} first={i === 0} style={{ gap: 6 }}>
99
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
100
+ <Icon name={stuck ? "TriangleAlert" : "Pause"} size={14} color={stuck ? c.statusDanger : c.statusWarning} />
101
+ <Text numberOfLines={2} style={{ ...t.title, flex: 1 }}>{presentation.title}</Text>
102
+ {ownTime ? <Text style={{ color: c.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600", ...tabular }}>{ownTime}</Text> : null}
103
+ </View>
104
+ <Text style={{ ...t.meta, color: stuck ? c.statusDanger : c.foregroundMuted, paddingLeft: 22 }}>{after}</Text>
105
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, paddingLeft: 22 }}>
106
+ {chips.map((chip) => <Chip key={chip} theme={theme} label={chip} />)}
107
+ {owner ? <Chip theme={theme} label="Read-only here" /> : null}
108
+ </View>
109
+ {owner ? <Text style={{ ...t.meta, paddingLeft: 22 }}>{owner}</Text> : null}
110
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 6, marginTop: 2, paddingLeft: 22 }}>
111
+ {navigation ? <Button theme={theme} small compact={compact} icon={compact ? undefined : "ArrowUpRight"} title="Open session" onPress={() => navigation.openAgent({ agentId: pause.agentId, serverId: host.id })} /> : null}
112
+ <View style={{ flex: 1, minWidth: 8 }} />
113
+ {canAct ? <Button theme={theme} small compact={compact} kind="ghost" title={busy === pause.agentId ? "Updating…" : presentation.actionLabel} disabled={busy !== null}
114
+ accessibilityHint={presentation.actionHint} onPress={() => void release(pause)} /> : null}
115
+ </View>
116
+ </CardRow>;
117
+ };
118
+
119
+ return <ScrollView contentContainerStyle={{ padding: compact ? 16 : 32, paddingBottom: 48 }}>
120
+ <View style={{ width: "100%", maxWidth: 760, alignSelf: "center", gap: compact ? 18 : 22 }}>
121
+ <View style={{ gap: 6 }}>
122
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
123
+ {title ? <Text accessibilityRole="header" style={{ flex: 1, color: c.foreground, fontSize: compact ? 19 : 22, lineHeight: compact ? 25 : 29, fontWeight: "600" }}>{title}</Text>
124
+ : <View style={{ flex: 1 }}><Skeleton width="40%" height={22} color={c.surface2} /></View>}
125
+ <IconButton theme={theme} icon="Settings" label="Session Guard settings" compact={compact} onPress={openSettings} />
126
+ </View>
127
+ {sentence ? <Text style={{ ...t.meta, fontSize: 13, lineHeight: 19 }}>{sentence}</Text> : null}
128
+ </View>
129
+
130
+ {notice ? <Card theme={theme}><CardRow theme={theme} first style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }}>
131
+ <Icon name="ShieldOff" size={15} color={c.foregroundMuted} />
132
+ <View style={{ flex: 1, gap: 3 }}>
133
+ <Text style={t.label}>{notice.title}</Text>
134
+ <Text style={t.meta}>{notice.hint}</Text>
135
+ </View>
136
+ <Button theme={theme} small compact={compact} title="Settings" onPress={openSettings} />
137
+ </CardRow></Card> : null}
138
+
139
+ {all.length > 5 || query !== "" ? <TextInput value={query} onChangeText={setQuery} placeholder="Find a paused session by title or provider" placeholderTextColor={c.foregroundMuted} accessibilityLabel="Find a paused session"
140
+ style={{ minHeight: compact ? 44 : 34, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: c.border, backgroundColor: c.surface1, color: c.foreground, fontSize: 13 }} /> : null}
141
+
142
+ {status === null && !loadError ? <Card theme={theme}>{[0, 1].map((i) => <CardRow key={i} theme={theme} first={i === 0} style={{ gap: 8 }}>
143
+ <Skeleton width="30%" height={14} color={c.surface2} /><Skeleton width="70%" height={11} color={c.surface2} /></CardRow>)}</Card> : null}
144
+
145
+ {status && all.length === 0 && !notice ? <Card theme={theme}><CardRow theme={theme} first style={{ gap: 6, paddingVertical: 16 }}>
146
+ <Text style={t.label}>Nothing is waiting for a reset</Text>
147
+ <Text style={t.meta}>{`When a protected session reaches ${status.policy.thresholdPct}% of its session window, Session Guard pauses it here and ${status.policy.autoResume ? "continues it in the same conversation after the reset" : "you continue it after the reset"}.`}</Text>
148
+ </CardRow></Card> : null}
149
+ {status && all.length > 0 && visible.length === 0 ? <Text style={t.meta}>No paused session matches that.</Text> : null}
150
+
151
+ {attention.length > 0 ? <View style={{ gap: 8 }}>
152
+ <SectionHead theme={theme} title="Needs attention" count={attention.length} />
153
+ <Card theme={theme}>{attention.map(row)}</Card>
154
+ </View> : null}
155
+ {waiting.length > 0 ? <View style={{ gap: 8 }}>
156
+ <SectionHead theme={theme} title="Waiting for the reset" count={waiting.length} />
157
+ <Card theme={theme}>{waiting.map(row)}</Card>
158
+ </View> : null}
159
+
160
+ {loadError ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{status ? loadError : "Couldn't load paused sessions. Retrying."}</Text> : null}
161
+ {status?.error && !notice ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{status.error}</Text> : null}
162
+ </View>
163
+ </ScrollView>;
164
+ }
@@ -0,0 +1,26 @@
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
+ };
@@ -0,0 +1,132 @@
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
+ }