paseo-smart-session 2.1.0 → 3.0.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 (46) hide show
  1. package/README.md +40 -11
  2. package/client/agents.ts +26 -4
  3. package/client/charts.tsx +277 -152
  4. package/client/chat.tsx +122 -0
  5. package/client/display.tsx +55 -33
  6. package/client/guard/index.tsx +14 -16
  7. package/client/guard/paused.tsx +125 -38
  8. package/client/guard/pill-icons.tsx +1 -1
  9. package/client/guard/pills.ts +16 -6
  10. package/client/guard/surfaces.ts +3 -2
  11. package/client/guard/timeline.tsx +116 -56
  12. package/client/kit.tsx +144 -0
  13. package/client/live.ts +49 -0
  14. package/client/motion.tsx +94 -0
  15. package/client/overview.tsx +373 -0
  16. package/client/pill-store.ts +77 -0
  17. package/client/pill.tsx +83 -260
  18. package/client/popover.tsx +113 -0
  19. package/client/settings.tsx +105 -0
  20. package/client/theme.ts +56 -0
  21. package/docs/media/smart-session-chat.png +0 -0
  22. package/docs/media/smart-session-flow.gif +0 -0
  23. package/docs/media/smart-session-guard.png +0 -0
  24. package/docs/media/smart-session-hero-light.png +0 -0
  25. package/docs/media/smart-session-hero.png +0 -0
  26. package/docs/media/smart-session-mobile.png +0 -0
  27. package/docs/media/smart-session-pill.png +0 -0
  28. package/docs/navigation-verification.md +6 -6
  29. package/docs/screenshots/display-settings-desktop.png +0 -0
  30. package/docs/screenshots/display-settings-mobile.png +0 -0
  31. package/docs/session-guard.md +3 -3
  32. package/index.client.tsx +47 -35
  33. package/index.server.ts +14 -5
  34. package/mcp.mjs +1 -1
  35. package/package.json +1 -1
  36. package/paseo-plugin.json +10 -0
  37. package/server/settings.ts +10 -0
  38. package/shared/blocks.ts +15 -0
  39. package/shared/chat.ts +174 -0
  40. package/shared/forecast.ts +233 -0
  41. package/shared/format.ts +47 -1
  42. package/shared/governor.ts +3 -0
  43. package/shared/smart-session.ts +16 -0
  44. package/client/navigation.tsx +0 -14
  45. package/client/surface.tsx +0 -659
  46. 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
  };
@@ -3,38 +3,36 @@ import { ScrollView } from "react-native";
3
3
 
4
4
  import { TimelineDataSchema } from "../../shared/guard/timeline.ts";
5
5
  import type { AgentDirectory } from "../agents.ts";
6
- import { withNavigation } from "../navigation.tsx";
6
+ import type { Live } from "../live.ts";
7
7
  import { PausedSessions } from "./paused.tsx";
8
8
  import { guardPillIcons } from "./pill-icons.tsx";
9
9
  import { contributePills } from "./pills.ts";
10
10
  import { GuardSettings } from "./settings.tsx";
11
- import { GUARD_SURFACES } from "./surfaces.ts";
11
+ import { GUARD_SCREENS, GUARD_SETTINGS } from "./surfaces.ts";
12
12
  import { GuardTimelineItem } from "./timeline.tsx";
13
13
 
14
- /** Session Guard's client half: its settings, paused-sessions list, timeline cards and pill. */
15
- export function contributeGuardClient(client: PluginClientContext, agents: AgentDirectory, home: string) {
16
- const openPaused = () => client.openSurface(GUARD_SURFACES.paused);
17
- const openSettings = () => client.openSurface(GUARD_SURFACES.settings);
18
- const Settings = withNavigation((props) => (
19
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
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
20
  <GuardSettings {...props} openOverview={openPaused} />
21
21
  </ScrollView>
22
- ), client, home, "session-guard");
23
- const Paused = withNavigation((props) => <PausedSessions {...props} openSettings={openSettings} />, client, home, "session-guard");
22
+ );
24
23
 
25
24
  const removers = [
26
- client.addSurface(GUARD_SURFACES.settings, Settings),
27
- client.addSurface(GUARD_SURFACES.paused, Paused),
28
- client.addSettingsScreen({ id: "session-guard", title: "Session Guard", icon: "ShieldCheck", Component: Settings }),
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 }),
29
27
  client.addCommandCenterItem({
30
28
  id: "session-guard-settings", title: "Session Guard: settings", icon: "ShieldCheck",
31
29
  keywords: ["quota", "limit", "reset", "pause", "resume", "protection", "session guard"], context: "global",
32
- onSelect({ openSurface }) { openSurface(GUARD_SURFACES.settings); },
30
+ onSelect({ openSettings: open }) { open(GUARD_SETTINGS); },
33
31
  }),
34
32
  client.addCommandCenterItem({
35
33
  id: "session-guard-paused", title: "Session Guard: paused sessions", icon: "Pause",
36
34
  keywords: ["quota", "waiting", "reset", "resume", "subagents", "session guard"], context: "global",
37
- onSelect({ openSurface }) { openSurface(GUARD_SURFACES.paused); },
35
+ onSelect({ openScreen }) { openScreen({ screenId: GUARD_SCREENS.paused }); },
38
36
  }),
39
37
  // The kind and version the standalone plugin wrote, so a pause it recorded and this
40
38
  // plugin later delivers renders the same card.
@@ -42,7 +40,7 @@ export function contributeGuardClient(client: PluginClientContext, agents: Agent
42
40
  kind: "quota-pause", version: 1, schema: TimelineDataSchema,
43
41
  Component: (props) => <GuardTimelineItem {...props} openOverview={openPaused} />,
44
42
  }),
45
- contributePills(client, agents, guardPillIcons),
43
+ contributePills(client, agents, guardPillIcons, live),
46
44
  ];
47
45
  return () => {
48
46
  for (const remove of removers.splice(0)) remove();
@@ -1,17 +1,33 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import { ActivityIndicator, Text, View } from "react-native";
2
+ import { Text, TextInput, View } from "react-native";
3
3
  import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
4
- import { ScrollView } from "@getpaseo/plugin/client/react-native";
5
- import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection } from "@getpaseo/plugin/client/ui";
4
+ import { Icon, ScrollView, useToast } from "@getpaseo/plugin/client/ui";
6
5
  import { cancelResumeRpc, statusRpc } from "../../shared/guard/contracts.ts";
7
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";
8
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
+ */
9
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);
10
27
  const read = useRpc(statusRpc);
11
28
  const cancel = useRpc(cancelResumeRpc);
12
29
  const [status, setStatus] = useState<Awaited<ReturnType<typeof read>> | null>(null);
13
30
  const [loadError, setLoadError] = useState<string | null>(null);
14
- const [actionError, setActionError] = useState<string | null>(null);
15
31
  const [busy, setBusy] = useState<string | null>(null);
16
32
  const [query, setQuery] = useState("");
17
33
  const refresh = useRef<() => Promise<void>>(async () => {});
@@ -21,57 +37,128 @@ export function PausedSessions({ theme, host, layout, navigation, openSettings }
21
37
  if (fetching || stopped) return;
22
38
  fetching = true;
23
39
  try { const next = await read({}); if (!stopped) { setStatus(next); setLoadError(null); } }
24
- catch { if (!stopped) setLoadError("Could not refresh paused sessions. Showing the last report; retrying automatically."); }
40
+ catch { if (!stopped) setLoadError("Couldn't refresh paused sessions. Showing the last report; retrying."); }
25
41
  finally { fetching = false; }
26
42
  };
27
43
  refresh.current = fetch;
28
44
  void fetch(); const timer = setInterval(() => void fetch(), 5000);
29
45
  return () => { stopped = true; clearInterval(timer); };
30
46
  }, [read]);
31
- const release = async (agentId: string) => {
47
+ const release = async (pause: Pause) => {
32
48
  if (busy) return;
33
- setBusy(agentId);
34
- try { await cancel({ agentId }); await refresh.current(); setActionError(null); }
35
- catch { setActionError("Could not change this pause. Refresh the list and try again."); }
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."); }
36
52
  finally { setBusy(null); }
37
53
  };
54
+
38
55
  const all = status ? pausedSessions(status.pauses) : [];
39
56
  const visible = status ? pausedSessions(status.pauses, query) : [];
40
- const attention = visible.filter(p => p.phase === "attention");
41
- const waiting = visible.filter(p => p.phase !== "attention");
42
- const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 };
57
+ const attention = visible.filter((p) => p.phase === "attention");
58
+ const waiting = visible.filter((p) => p.phase !== "attention");
43
59
  // Both plugins share one state file: when this one is not governing, the list is
44
60
  // the other plugin's (or a leftover), and its controls would only fail.
45
61
  const notice = status ? governorNotice(status) : null;
46
62
  const owner = status ? pauseOwnerNote(status) : null;
47
- const row = (pause: typeof all[number]) => {
48
- const presentation = pausePresentation(pause, status!.policy);
49
- const hint = `${pause.provider} · ${pause.agentId.slice(0, 8)} · ${presentation.coverage}\n${presentation.resetLabel}\n${owner ?? presentation.state}${pause.note ? `\n${pause.note}` : ""}`;
50
- return <View key={pause.id}>
51
- {navigation ? <SettingsAction label={presentation.title} hint={hint} actionLabel="Open session" onPress={() => navigation.openAgent({ agentId: pause.agentId, serverId: host.id })} />
52
- : <SettingsRow label={presentation.title} hint={hint} />}
53
- {owner === null && <SettingsAction label={busy === pause.agentId ? "Updating this pause…" : "Manual control"} hint={presentation.actionHint} actionLabel={presentation.actionLabel} disabled={busy !== null} onPress={() => void release(pause.agentId)} />}
54
- </View>;
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>;
55
117
  };
56
- return <ScrollView contentContainerStyle={{ padding: layout.compact ? 16 : 24, paddingBottom: 40 }}>
57
- <View style={{ width: "100%", maxWidth: 760, alignSelf: "center", gap: 24 }}>
58
- <View style={{ gap: 8 }}>
59
- <Text accessibilityRole="header" style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "600" }}>Paused sessions</Text>
60
- <Text style={muted}>All Session Guard pauses on {host.label}. Native subagents are included with their parent session.</Text>
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}
61
128
  </View>
62
- {!status && !loadError && <ActivityIndicator accessibilityLabel="Loading paused sessions" color={theme.colors.foregroundMuted} />}
63
- {notice && <SettingsCard><SettingsAction label={notice.title} hint={notice.hint} actionLabel="Settings" onPress={openSettings} /></SettingsCard>}
64
- {status && !notice && <SettingsCard><SettingsAction label={`${all.length} ${all.length === 1 ? "session" : "sessions"} in Session Guard${all.some(p => p.phase === "attention") ? " · attention needed" : ""}`} hint={status.checkedAt ? `Last checked ${new Date(status.checkedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · refreshes automatically` : "Waiting for the first usage check"} actionLabel="Refresh" onPress={() => void refresh.current()} /></SettingsCard>}
65
- {(all.length > 5 || query !== "") && <SettingsCard><SettingsInput label="Find a paused session" placeholder="Title or provider" initialValue="" onChangeText={setQuery} /></SettingsCard>}
66
- {status && all.length === 0 && !notice && <SettingsCard><SettingsRow label="No sessions are paused" hint="When a protected session reaches its limit, it appears here and receives a timeline entry." /></SettingsCard>}
67
- {status && all.length > 0 && visible.length === 0 && <SettingsCard><SettingsRow label="No matching paused sessions" hint="Try another title or provider." /></SettingsCard>}
68
- {owner && visible.length > 0 && <SettingsSection title={`Not managed by Smart Session (${visible.length})`}><SettingsCard>{visible.map(row)}</SettingsCard></SettingsSection>}
69
- {!owner && attention.length > 0 && <SettingsSection title={`Needs attention (${attention.length})`}><SettingsCard>{attention.map(row)}</SettingsCard></SettingsSection>}
70
- {!owner && waiting.length > 0 && <SettingsSection title={`Waiting for reset (${waiting.length})`}><SettingsCard>{waiting.map(row)}</SettingsCard></SettingsSection>}
71
- <SettingsCard><SettingsAction label="Protection and timing" hint="Configure the default, native subagents, and automatic resume." actionLabel="Settings" onPress={openSettings} /></SettingsCard>
72
- {loadError && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{status ? loadError : "Could not load paused sessions. Retrying automatically."}</Text>}
73
- {actionError && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{actionError}</Text>}
74
- {status?.error && !notice && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{status.error}</Text>}
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}
75
162
  </View>
76
163
  </ScrollView>;
77
164
  }
@@ -3,7 +3,7 @@ import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
 
4
4
  // Like Smart Compact, keep the host's pill chrome and color the icon slot.
5
5
  function GuardOnIcon({ theme, size }: PluginButtonIconProps) {
6
- return <Icon name="ShieldCheck" size={size} color={theme.colors.statusSuccess} />;
6
+ return <Icon name="ShieldCheck" size={size} color={theme.colors.foreground} />;
7
7
  }
8
8
 
9
9
  function GuardOffIcon({ theme, size }: PluginButtonIconProps) {
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import type { PluginButtonIcon, PluginClientContext } from "@getpaseo/plugin/client";
11
11
  import type { AgentDirectory } from "../agents.ts";
12
- import { GUARD_SURFACES } from "./surfaces.ts";
12
+ import { GUARD_SCREENS, GUARD_SETTINGS } from "./surfaces.ts";
13
+ import type { Live } from "../live.ts";
13
14
  import { cancelResumeRpc, setProtectionRpc, statusRpc } from "../../shared/guard/contracts.ts";
14
15
  import { isPending, protects, type Pause, type Policy } from "../../shared/guard/policy.ts";
15
16
 
@@ -22,7 +23,8 @@ export function pillPresentation(policy: Policy, enabled: boolean, pause: Pause
22
23
  const date = pause.anchorResetsAt ? new Date(pause.anchorResetsAt) : null;
23
24
  const time = date && Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null;
24
25
  const resetDetail = date ? ` Expected reset ${date.toLocaleString([], { dateStyle: "short", timeStyle: "short" })}.` : " Reset time is not available yet.";
25
- return { label: time ? `Paused · ${time}` : "Paused · awaiting reset", title: (policy.autoResume
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
26
28
  ? `Paused at the session limit${pause.suspension ? ", including native subagents" : ""}. Automatic continuation is scheduled after reset.`
27
29
  : pause.suspension ? "Session and native subagents are paused. Release and turn off to continue manually; automatic continuation is off."
28
30
  : "Paused at the session limit. Continue manually after reset; automatic continuation is off.") + resetDetail, icon: "Pause" };
@@ -46,7 +48,7 @@ export function refreshGuardPills(): void {
46
48
  for (const wake of [...wakers]) wake();
47
49
  }
48
50
 
49
- export function contributePills(client: PluginClientContext, agents: AgentDirectory, icons: Readonly<Record<string, PluginButtonIcon>> = {}) {
51
+ export function contributePills(client: PluginClientContext, agents: AgentDirectory, icons: Readonly<Record<string, PluginButtonIcon>> = {}, live?: Live) {
50
52
  let stopped = false;
51
53
  let running = false;
52
54
  let status: Awaited<ReturnType<typeof readStatus>> | null = null;
@@ -58,7 +60,15 @@ export function contributePills(client: PluginClientContext, agents: AgentDirect
58
60
  running = true;
59
61
  if (timer !== null) clearTimeout(timer);
60
62
  timer = null;
61
- try { const next = await readStatus(); if (!stopped) { status = next; reconcile(); } }
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
+ }
62
72
  catch { /* Keep the last confirmed presentation across a brief disconnect. */ }
63
73
  finally {
64
74
  running = false;
@@ -100,9 +110,9 @@ export function contributePills(client: PluginClientContext, agents: AgentDirect
100
110
  behavior: { kind: "action" as const, onPress: async () => { await client.rpc(cancelResumeRpc, { agentId }); await refresh(); } } },
101
111
  { kind: "separator" as const, id: "separator" },
102
112
  { kind: "item" as const, id: "paused-sessions", title: "All paused sessions", icon: "Pause",
103
- behavior: { kind: "action" as const, onPress: () => { client.openSurface(GUARD_SURFACES.paused); } } },
113
+ behavior: { kind: "action" as const, onPress: () => { client.openScreen({ screenId: GUARD_SCREENS.paused }); } } },
104
114
  { kind: "item" as const, id: "settings", title: "Session Guard settings", icon: "Settings",
105
- behavior: { kind: "action" as const, onPress: () => { client.openSurface(GUARD_SURFACES.settings); } } },
115
+ behavior: { kind: "action" as const, onPress: () => { client.openSettings(GUARD_SETTINGS); } } },
106
116
  ] },
107
117
  };
108
118
  if (handle) { handle.registration.update(button); handle.signature = signature; }
@@ -1,2 +1,3 @@
1
- /** Session Guard's surfaces, opened from its pill, the overview, settings and timeline. */
2
- export const GUARD_SURFACES = { paused: "paused-sessions", settings: "session-guard" } as const;
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";