paseo-smart-session 2.1.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.
- package/README.md +40 -11
- package/client/agents.ts +26 -4
- package/client/charts.tsx +277 -152
- package/client/chat.tsx +122 -0
- package/client/display.tsx +55 -33
- package/client/guard/index.tsx +14 -16
- package/client/guard/paused.tsx +125 -38
- package/client/guard/pill-icons.tsx +1 -1
- package/client/guard/pills.ts +16 -6
- package/client/guard/surfaces.ts +3 -2
- package/client/guard/timeline.tsx +116 -56
- package/client/kit.tsx +144 -0
- package/client/live.ts +49 -0
- package/client/motion.tsx +94 -0
- package/client/overview.tsx +373 -0
- package/client/pill-store.ts +77 -0
- package/client/pill.tsx +83 -260
- package/client/popover.tsx +113 -0
- package/client/settings.tsx +105 -0
- package/client/theme.ts +56 -0
- package/docs/media/smart-session-chat.png +0 -0
- package/docs/media/smart-session-flow.gif +0 -0
- package/docs/media/smart-session-guard.png +0 -0
- package/docs/media/smart-session-hero-light.png +0 -0
- package/docs/media/smart-session-hero.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/navigation-verification.md +6 -6
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
- package/docs/session-guard.md +3 -3
- package/index.client.tsx +47 -35
- package/index.server.ts +14 -5
- package/mcp.mjs +1 -1
- package/package.json +1 -1
- package/paseo-plugin.json +10 -0
- package/server/settings.ts +10 -0
- package/shared/blocks.ts +15 -0
- package/shared/chat.ts +174 -0
- package/shared/forecast.ts +233 -0
- package/shared/format.ts +47 -1
- package/shared/governor.ts +3 -0
- package/shared/smart-session.ts +16 -0
- package/client/navigation.tsx +0 -14
- package/client/surface.tsx +0 -659
- package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/display.tsx
CHANGED
|
@@ -1,73 +1,96 @@
|
|
|
1
|
-
import type { PluginClientContext,
|
|
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 {
|
|
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
|
-
|
|
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={{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
55
|
-
export function contributeDisplay(client: PluginClientContext,
|
|
56
|
-
|
|
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
|
|
84
|
+
// already-open sidebar route can resolve its screen during that first render.
|
|
60
85
|
sidebar.set(true);
|
|
61
|
-
const
|
|
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:
|
|
89
|
+
title: "Smart Session: display settings",
|
|
67
90
|
icon: "PanelLeft",
|
|
68
|
-
keywords: [
|
|
91
|
+
keywords: ["smart session", "sidebar", "pill", "show", "hide", "display", "settings"],
|
|
69
92
|
context: "global",
|
|
70
|
-
onSelect({
|
|
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
|
};
|
package/client/guard/index.tsx
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
15
|
-
export function contributeGuardClient(client: PluginClientContext, agents: AgentDirectory,
|
|
16
|
-
const openPaused = () => client.
|
|
17
|
-
const openSettings = () => client.
|
|
18
|
-
const Settings =
|
|
19
|
-
<ScrollView style={{ flex: 1 }} contentContainerStyle={{
|
|
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
|
-
)
|
|
23
|
-
const Paused = withNavigation((props) => <PausedSessions {...props} openSettings={openSettings} />, client, home, "session-guard");
|
|
22
|
+
);
|
|
24
23
|
|
|
25
24
|
const removers = [
|
|
26
|
-
client.
|
|
27
|
-
client.
|
|
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({
|
|
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({
|
|
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();
|
package/client/guard/paused.tsx
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
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/
|
|
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("
|
|
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 (
|
|
47
|
+
const release = async (pause: Pause) => {
|
|
32
48
|
if (busy) return;
|
|
33
|
-
setBusy(agentId);
|
|
34
|
-
try { await cancel({ agentId }); await refresh.current();
|
|
35
|
-
catch {
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<
|
|
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
|
-
|
|
63
|
-
{notice
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
6
|
+
return <Icon name="ShieldCheck" size={size} color={theme.colors.foreground} />;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function GuardOffIcon({ theme, size }: PluginButtonIconProps) {
|
package/client/guard/pills.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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; }
|
package/client/guard/surfaces.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
/** Session Guard's
|
|
2
|
-
export 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";
|