paseo-smart-session 3.1.0 → 3.3.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 +34 -23
- package/client/chat.tsx +2 -2
- package/client/commands.ts +62 -0
- package/client/display.tsx +9 -62
- package/client/guard/index.tsx +11 -42
- package/client/guard/paused.tsx +91 -120
- package/client/guard/status.ts +113 -0
- package/client/guard/timeline.tsx +61 -56
- package/client/overview.tsx +59 -52
- package/client/pill-store.ts +8 -2
- package/client/pill-view.ts +61 -0
- package/client/pill.tsx +50 -47
- package/client/popover.tsx +147 -31
- package/client/screens.ts +6 -0
- package/client/settings.tsx +223 -83
- package/docs/media/before-after.png +0 -0
- package/docs/media/smart-session-guard.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/media/smart-session-settings.png +0 -0
- package/docs/navigation-verification.md +4 -4
- package/docs/session-guard.md +15 -17
- package/index.client.tsx +19 -34
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/engine.ts +27 -3
- package/server/guard/index.ts +35 -11
- package/shared/guard/limit-notice.ts +87 -0
- package/shared/guard/presentation.ts +66 -11
- package/shared/guard/timeline.ts +3 -3
- package/shared/guard/timing.ts +37 -0
- package/shared/guard/zerosub.ts +92 -10
- package/client/guard/pill-icons.tsx +0 -26
- package/client/guard/pills.ts +0 -132
- package/client/guard/settings.tsx +0 -114
- package/client/guard/surfaces.ts +0 -3
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
package/client/guard/paused.tsx
CHANGED
|
@@ -1,164 +1,135 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import { Text, TextInput, View } from "react-native";
|
|
3
|
-
import { type
|
|
4
|
-
import { Icon,
|
|
5
|
-
import { cancelResumeRpc, statusRpc } from "../../shared/guard/contracts.ts";
|
|
6
|
-
import { governorNotice, pauseOwnerNote, pausedSessions, pausePresentation } from "../../shared/guard/presentation.ts";
|
|
3
|
+
import { type PluginScreenProps, useRpc } from "@getpaseo/plugin/client";
|
|
4
|
+
import { Icon, useToast } from "@getpaseo/plugin/client/ui";
|
|
5
|
+
import { cancelResumeRpc, dismissPauseRpc, resumeNowRpc, setProtectionRpc, statusRpc } from "../../shared/guard/contracts.ts";
|
|
6
|
+
import { PAUSE_ACTIONS, governorNotice, pauseOwnerNote, pausedLead, pausedSessions, pausePresentation } from "../../shared/guard/presentation.ts";
|
|
7
7
|
import { timestamp, type Pause } from "../../shared/guard/policy.ts";
|
|
8
|
-
import { formatDuration
|
|
8
|
+
import { formatDuration } from "../../shared/format.ts";
|
|
9
9
|
import { formatMoment } from "../../shared/forecast.ts";
|
|
10
|
-
import { Button, Card, CardRow,
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { Button, Card, CardRow, SectionHead, TextLink, text } from "../kit.tsx";
|
|
11
|
+
import { tabular, type Theme } from "../theme.ts";
|
|
12
|
+
import { publishGuard, type GuardStatus } from "./status.ts";
|
|
13
13
|
|
|
14
14
|
const providerName = (id: string) => (id === "claude" ? "Claude" : id === "codex" ? "Codex" : id);
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
* resets and whether it continues by itself. Rows act only on
|
|
19
|
-
* governs; another governor's pauses are listed read-only.
|
|
17
|
+
* The overview's Paused sessions section: every pause on this host as times, when
|
|
18
|
+
* each session's window resets and whether it continues by itself. Rows act only on
|
|
19
|
+
* pauses this process governs; another governor's pauses are listed read-only.
|
|
20
20
|
*/
|
|
21
|
-
export function
|
|
21
|
+
export function PausedSection({ theme, layout, host, navigation, status, shared: lead, now, openSettings }: Pick<PluginScreenProps, "theme" | "layout" | "host" | "navigation"> & {
|
|
22
|
+
status: GuardStatus | null; shared: number | null; now: number; openSettings(): void;
|
|
23
|
+
}) {
|
|
22
24
|
const c = theme.colors;
|
|
23
25
|
const t = text(c);
|
|
24
26
|
const compact = layout.compact;
|
|
25
27
|
const toast = useToast();
|
|
26
|
-
const now = useNow(15_000);
|
|
27
28
|
const read = useRpc(statusRpc);
|
|
28
29
|
const cancel = useRpc(cancelResumeRpc);
|
|
29
|
-
const
|
|
30
|
-
const
|
|
30
|
+
const resumeNow = useRpc(resumeNowRpc);
|
|
31
|
+
const dismiss = useRpc(dismissPauseRpc);
|
|
31
32
|
const [busy, setBusy] = useState<string | null>(null);
|
|
32
33
|
const [query, setQuery] = useState("");
|
|
33
|
-
|
|
34
|
-
|
|
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) => {
|
|
34
|
+
// A waiting pause gets the same two actions as its chat card; only a stuck one is released.
|
|
35
|
+
const act = async (pause: Pause, kind: "resume" | "dismiss" | "release") => {
|
|
48
36
|
if (busy) return;
|
|
49
|
-
setBusy(pause.agentId);
|
|
50
|
-
try {
|
|
51
|
-
|
|
37
|
+
setBusy(`${pause.agentId}:${kind}`);
|
|
38
|
+
try {
|
|
39
|
+
if (kind === "release") await cancel({ agentId: pause.agentId });
|
|
40
|
+
else await (kind === "resume" ? resumeNow : dismiss)({ agentId: pause.agentId, pauseId: pause.id });
|
|
41
|
+
publishGuard(await read({}));
|
|
42
|
+
toast.show(kind === "resume" ? "Resumed · it no longer pauses at the limit" : kind === "dismiss" ? "Pause dismissed · nothing was sent" : "Released");
|
|
43
|
+
} catch { toast.error("Couldn't change this pause. Check the session and try again."); }
|
|
52
44
|
finally { setBusy(null); }
|
|
53
45
|
};
|
|
54
46
|
|
|
55
47
|
const all = status ? pausedSessions(status.pauses) : [];
|
|
56
48
|
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
49
|
// Both plugins share one state file: when this one is not governing, the list is
|
|
60
50
|
// the other plugin's (or a leftover), and its controls would only fail.
|
|
61
51
|
const notice = status ? governorNotice(status) : null;
|
|
62
52
|
const owner = status ? pauseOwnerNote(status) : null;
|
|
63
53
|
const autoResume = status ? status.policy.enabled && status.policy.autoResume : false;
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
const
|
|
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(" · ");
|
|
54
|
+
// The overview's lead names the shared reset only for pauses governed here; the list
|
|
55
|
+
// finds it itself otherwise, so rows never repeat a time the section already implies.
|
|
56
|
+
const shared = lead ?? pausedLead(all, autoResume, now, String)?.shared ?? null;
|
|
84
57
|
|
|
85
58
|
const row = (pause: Pause, i: number) => {
|
|
86
59
|
const presentation = pausePresentation(pause, status!.policy, now);
|
|
87
60
|
const reset = timestamp(pause.anchorResetsAt);
|
|
88
61
|
const stuck = pause.phase === "attention";
|
|
89
|
-
// A row leads with what differs between rows, the session; its time only when it differs from the
|
|
62
|
+
// A row leads with what differs between rows, the session; its time only when it differs from the lead's.
|
|
90
63
|
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
|
|
92
|
-
: pause.demo ? "Test pause
|
|
64
|
+
const after = stuck ? (pause.note ?? "It stopped before the pause was confirmed. Check it before continuing by hand.")
|
|
65
|
+
: pause.demo ? "Test pause, waits for you"
|
|
93
66
|
: reset === null ? "Waiting for the provider to report the reset"
|
|
94
|
-
: reset > now ? `${autoResume ? "Resumes" : "Resets"} in ${formatDuration(reset - now)}`
|
|
67
|
+
: reset > now ? (ownTime === null && shared !== null ? null : `${autoResume ? "Resumes" : "Resets"} in ${formatDuration(reset - now)}`)
|
|
95
68
|
: `Reset was ${formatMoment(pause.anchorResetsAt!, now)} · waiting for a fresh reading`;
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
69
|
+
// The lead says when a shared reset lands and what happens then; a row adds only what differs.
|
|
70
|
+
const facts = [after, providerName(pause.provider), presentation.coverage.toLowerCase()].filter(Boolean).join(" · ");
|
|
71
|
+
const actions = owner !== null ? null : pause.phase === "waiting" ? <>
|
|
72
|
+
<Button theme={theme} small compact={compact} icon={PAUSE_ACTIONS.resume.icon} title={busy === `${pause.agentId}:resume` ? PAUSE_ACTIONS.resume.busy : PAUSE_ACTIONS.resume.label} disabled={busy !== null}
|
|
73
|
+
accessibilityHint={PAUSE_ACTIONS.resume.hint} onPress={() => void act(pause, "resume")} />
|
|
74
|
+
<Button theme={theme} small compact={compact} kind="ghost" title={busy === `${pause.agentId}:dismiss` ? PAUSE_ACTIONS.dismiss.busy : PAUSE_ACTIONS.dismiss.label} disabled={busy !== null}
|
|
75
|
+
accessibilityHint={PAUSE_ACTIONS.dismiss.hint} onPress={() => void act(pause, "dismiss")} />
|
|
76
|
+
</> : stuck ? <Button theme={theme} small compact={compact} kind="ghost" title={busy === `${pause.agentId}:release` ? "Updating…" : presentation.actionLabel} disabled={busy !== null}
|
|
77
|
+
accessibilityHint={presentation.actionHint} onPress={() => void act(pause, "release")} /> : null;
|
|
78
|
+
const open = navigation ? <TextLink theme={theme} compact={compact} label="Open" onPress={() => navigation.openAgent({ agentId: pause.agentId, serverId: host.id })} /> : null;
|
|
79
|
+
return <CardRow key={pause.id} theme={theme} first={i === 0 && !(notice && all.length > 0)} style={{ gap: compact ? 6 : 2 }}>
|
|
99
80
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
100
81
|
<Icon name={stuck ? "TriangleAlert" : "Pause"} size={14} color={stuck ? c.statusDanger : c.statusWarning} />
|
|
101
|
-
<Text numberOfLines={
|
|
102
|
-
{ownTime ? <Text style={{
|
|
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}
|
|
82
|
+
<Text numberOfLines={1} style={{ ...t.label, flexShrink: 1 }}>{presentation.title}</Text>
|
|
83
|
+
{ownTime ? <Text style={{ ...t.label, ...tabular }}>{ownTime}</Text> : null}
|
|
112
84
|
<View style={{ flex: 1, minWidth: 8 }} />
|
|
113
|
-
{
|
|
114
|
-
accessibilityHint={presentation.actionHint} onPress={() => void release(pause)} /> : null}
|
|
85
|
+
{compact ? open : <>{open}{actions}</>}
|
|
115
86
|
</View>
|
|
87
|
+
<Text style={{ ...t.meta, color: stuck ? c.statusDanger : c.foregroundMuted, paddingLeft: 22 }}>{facts}</Text>
|
|
88
|
+
{compact && actions ? <View style={{ flexDirection: "row", gap: 6, paddingLeft: 22 }}>{actions}</View> : null}
|
|
116
89
|
</CardRow>;
|
|
117
90
|
};
|
|
118
91
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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}
|
|
92
|
+
const attention = visible.filter((p) => p.phase === "attention");
|
|
93
|
+
const waiting = visible.filter((p) => p.phase !== "attention");
|
|
94
|
+
return <View style={{ gap: 8 }}>
|
|
95
|
+
<SectionHead theme={theme} title="Paused sessions" count={all.length || undefined} />
|
|
96
|
+
{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"
|
|
97
|
+
style={{ minHeight: compact ? 44 : 34, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: c.border, backgroundColor: c.surface1, color: c.foreground, fontSize: 13 }} /> : null}
|
|
98
|
+
{status && all.length === 0 ? <Card theme={theme}><CardRow theme={theme} first style={{ gap: 4, paddingVertical: 16 }}>
|
|
99
|
+
<Text style={t.label}>Nothing is waiting for a reset</Text>
|
|
100
|
+
<Text style={t.meta}>{notice ? notice.title + "." : `When a session reaches ${status.policy.thresholdPct}% of its session window, it pauses here and ${status.policy.autoResume ? "continues in the same conversation after the reset" : "waits for you after the reset"}.`}</Text>
|
|
101
|
+
</CardRow></Card> : null}
|
|
102
|
+
{status && all.length > 0 && visible.length === 0 ? <Text style={t.meta}>No paused session matches that.</Text> : null}
|
|
103
|
+
{visible.length > 0 ? <Card theme={theme}>
|
|
104
|
+
{notice ? <CardRow theme={theme} first style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
105
|
+
<Icon name="CirclePause" size={14} color={c.foregroundMuted} />
|
|
106
|
+
<Text style={{ ...t.meta, flex: 1 }}>{`${notice.title}. ${status?.governor === "off" && owner ? owner : notice.hint}`}</Text>
|
|
107
|
+
</CardRow> : null}
|
|
108
|
+
{[...attention, ...waiting].map(row)}
|
|
109
|
+
</Card> : null}
|
|
110
|
+
</View>;
|
|
111
|
+
}
|
|
159
112
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
113
|
+
/** Sessions changed from the default, with the way back. Read-only unless this process governs. */
|
|
114
|
+
export function Exceptions({ theme, layout, status, titleOf }: { theme: Theme; layout: { compact: boolean }; status: GuardStatus; titleOf(agentId: string): string }) {
|
|
115
|
+
const c = theme.colors;
|
|
116
|
+
const t = text(c);
|
|
117
|
+
const write = useRpc(setProtectionRpc);
|
|
118
|
+
const read = useRpc(statusRpc);
|
|
119
|
+
const toast = useToast();
|
|
120
|
+
const entries = Object.entries(status.overrides);
|
|
121
|
+
if (entries.length === 0) return null;
|
|
122
|
+
const canAct = status.governor === "active";
|
|
123
|
+
const reset = async (agentId: string) => {
|
|
124
|
+
try { await write({ agentId, enabled: null }); publishGuard(await read({})); toast.show("Back to your default"); }
|
|
125
|
+
catch { toast.error("Couldn't change that session. Try again."); }
|
|
126
|
+
};
|
|
127
|
+
return <View style={{ gap: 2, paddingLeft: 25 }}>
|
|
128
|
+
<Text style={t.small}>{entries.length === 1 ? "One session differs from your default" : `${entries.length} sessions differ from your default`}</Text>
|
|
129
|
+
{entries.map(([agentId, on]) => <View key={agentId} style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: layout.compact ? 44 : 24 }}>
|
|
130
|
+
<Text numberOfLines={1} style={{ ...t.meta, flex: 1, color: c.foreground }}>{titleOf(agentId)}</Text>
|
|
131
|
+
<Text style={t.meta}>{on ? "Pauses" : "Doesn't pause"}</Text>
|
|
132
|
+
{canAct ? <TextLink theme={theme} compact={layout.compact} icon="RotateCcw" label="Use default" onPress={() => void reset(agentId)} /> : null}
|
|
133
|
+
</View>)}
|
|
134
|
+
</View>;
|
|
164
135
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protection status for this client bundle: one poll of `smart-session.guard.status`
|
|
3
|
+
* that the composer pill, its popover and the sidebar row all read.
|
|
4
|
+
*
|
|
5
|
+
* Module-level for the same reason as the pill store: everything that shows or
|
|
6
|
+
* changes protection lives in this one bundle, so a change made from the popover
|
|
7
|
+
* redraws the pill at once instead of waiting out the next read.
|
|
8
|
+
*/
|
|
9
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
10
|
+
import type { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { statusRpc } from "../../shared/guard/contracts.ts";
|
|
13
|
+
import { isPending, protects, timestamp, type Pause } from "../../shared/guard/policy.ts";
|
|
14
|
+
import type { Live } from "../live.ts";
|
|
15
|
+
|
|
16
|
+
export type GuardStatus = z.infer<(typeof statusRpc)["output"]>;
|
|
17
|
+
|
|
18
|
+
/** How often status is re-read while protection runs here, and while it is off. */
|
|
19
|
+
const ACTIVE_POLL_MS = 5_000;
|
|
20
|
+
const IDLE_POLL_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
let current: GuardStatus | null = null;
|
|
23
|
+
const listeners = new Set<() => void>();
|
|
24
|
+
const wakers = new Set<() => void>();
|
|
25
|
+
|
|
26
|
+
export function guardSnapshot(): GuardStatus | null {
|
|
27
|
+
return current;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function subscribeGuard(listener: () => void): () => void {
|
|
31
|
+
listeners.add(listener);
|
|
32
|
+
return () => { listeners.delete(listener); };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Publishes a reading. Exported for the screens that read status themselves, and for tests. */
|
|
36
|
+
export function publishGuard(next: GuardStatus | null): void {
|
|
37
|
+
current = next;
|
|
38
|
+
for (const listener of [...listeners]) listener();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Re-reads status now. Settings and the overview call it when the protection
|
|
43
|
+
* document changes (their `useSettings` hears every client), so the pill follows a
|
|
44
|
+
* switch without polling hard while the feature is off.
|
|
45
|
+
*/
|
|
46
|
+
export function refreshGuard(): void {
|
|
47
|
+
for (const wake of [...wakers]) wake();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The pending pause this process can act on for one session, if any. */
|
|
51
|
+
export function governedPause(status: GuardStatus | null, agentId: string): Pause | undefined {
|
|
52
|
+
if (status === null || status.governor !== "active") return undefined;
|
|
53
|
+
return status.pauses.find((pause) => pause.agentId === agentId && isPending(pause));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What protection means for one session, in the words the pill and popover use. */
|
|
57
|
+
export function sessionProtection(status: GuardStatus | null, agentId: string) {
|
|
58
|
+
if (status === null) return { kind: "unknown" as const };
|
|
59
|
+
if (status.governor === "deferred") return { kind: "elsewhere" as const, standalone: status.standalone !== false };
|
|
60
|
+
if (status.governor === "error") return { kind: "error" as const, error: status.error };
|
|
61
|
+
if (status.governor === "off" || !status.policy.enabled) return { kind: "off" as const };
|
|
62
|
+
const override = agentId in status.overrides ? status.overrides[agentId]! : null;
|
|
63
|
+
return {
|
|
64
|
+
kind: "on" as const,
|
|
65
|
+
protected: protects(status.policy, status.overrides, agentId),
|
|
66
|
+
byDefault: status.policy.protectByDefault,
|
|
67
|
+
override,
|
|
68
|
+
thresholdPct: status.policy.thresholdPct,
|
|
69
|
+
autoResume: status.policy.autoResume,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The moment a pause comes back, or null when the provider has not said. */
|
|
74
|
+
export function resumeMoment(pause: Pause): number | null {
|
|
75
|
+
return timestamp(pause.anchorResetsAt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Starts the one poll for this bundle and feeds the sidebar row's paused count. */
|
|
79
|
+
export function watchGuard(client: PluginClientContext, live?: Live): () => void {
|
|
80
|
+
let stopped = false;
|
|
81
|
+
let running = false;
|
|
82
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
83
|
+
const refresh = async () => {
|
|
84
|
+
if (stopped || running) return;
|
|
85
|
+
running = true;
|
|
86
|
+
if (timer !== null) clearTimeout(timer);
|
|
87
|
+
timer = null;
|
|
88
|
+
try {
|
|
89
|
+
const next = await client.rpc(statusRpc, {});
|
|
90
|
+
if (!stopped) {
|
|
91
|
+
publishGuard(next);
|
|
92
|
+
// The sidebar row counts pauses only this process can act on.
|
|
93
|
+
const pending = next.governor === "active" ? next.pauses.filter(isPending) : [];
|
|
94
|
+
live?.setPaused({ waiting: pending.filter((p) => p.phase !== "attention").length, attention: pending.filter((p) => p.phase === "attention").length });
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Keep the last confirmed reading across a brief disconnect.
|
|
98
|
+
} finally {
|
|
99
|
+
running = false;
|
|
100
|
+
// Off here and nobody governing from here: nothing to draw, so check rarely.
|
|
101
|
+
const idle = current !== null && !current.policy.enabled && current.governor === "off";
|
|
102
|
+
if (!stopped) timer = setTimeout(() => void refresh(), idle ? IDLE_POLL_MS : ACTIVE_POLL_MS);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const wake = () => void refresh();
|
|
106
|
+
wakers.add(wake);
|
|
107
|
+
void refresh();
|
|
108
|
+
return () => {
|
|
109
|
+
stopped = true;
|
|
110
|
+
wakers.delete(wake);
|
|
111
|
+
if (timer !== null) clearTimeout(timer);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
2
|
import { Text, View } from "react-native";
|
|
3
3
|
import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
4
4
|
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
5
5
|
import { timelinePresentation, type TimelineData } from "../../shared/guard/timeline.ts";
|
|
6
6
|
import { dismissPauseRpc, resumeNowRpc, statusRpc } from "../../shared/guard/contracts.ts";
|
|
7
|
+
import { PAUSE_ACTIONS } from "../../shared/guard/presentation.ts";
|
|
7
8
|
import { timestamp, type Pause } from "../../shared/guard/policy.ts";
|
|
8
9
|
import { formatClock, formatDuration } from "../../shared/format.ts";
|
|
9
10
|
import { formatMoment } from "../../shared/forecast.ts";
|
|
@@ -14,7 +15,8 @@ import { raised, tabular, withAlpha } from "../theme.ts";
|
|
|
14
15
|
const providerName = (id: string) => (id === "claude" ? "Claude" : id === "codex" ? "Codex" : id);
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* A
|
|
18
|
+
* A pause at the session limit, in the chat, drawn like Smart Session's other chat
|
|
19
|
+
* cards and rows. While the pause waits, it is a card that leads
|
|
18
20
|
* with the clock the window resets at, a live countdown, and Resume now / Dismiss.
|
|
19
21
|
* Once the pause settles it resolves itself into one quiet line, so the chat keeps
|
|
20
22
|
* the history without a stale card. Settled events (resumed, released) are lines
|
|
@@ -68,32 +70,38 @@ export function GuardTimelineItem({ theme, agentId, item, layout, openOverview }
|
|
|
68
70
|
const at = formatClock(item.data.at);
|
|
69
71
|
|
|
70
72
|
/** One quiet line: the settled form of every card. */
|
|
73
|
+
/** One quiet line, sized like Smart Session's tool rows and Paseo's own. */
|
|
71
74
|
const line = (icon: string, color: string, words: string, extra?: string) => (
|
|
72
|
-
<View style={{ flexDirection: "row", alignItems: "center",
|
|
73
|
-
<View style={{ width: 22, alignItems: "center" }}><Icon name={icon} size={
|
|
74
|
-
<Text style={{ flexShrink: 1, color: c.foregroundMuted, fontSize:
|
|
75
|
+
<View style={{ marginHorizontal: -13, flexDirection: "row", alignItems: "center", paddingHorizontal: 8, paddingVertical: 4 }}>
|
|
76
|
+
<View style={{ width: 22, height: 22, marginRight: 4, alignItems: "center", justifyContent: "center" }}><Icon name={icon} size={12} color={color} /></View>
|
|
77
|
+
<Text numberOfLines={2} style={{ flexShrink: 1, color: c.foregroundMuted, fontSize: 14, lineHeight: 20, ...tabular }}>
|
|
75
78
|
<Text style={{ color: c.foreground }}>{words}</Text>{extra ? ` · ${extra}` : ""}
|
|
76
79
|
</Text>
|
|
77
80
|
</View>
|
|
78
81
|
);
|
|
82
|
+
/** The card shell shared with the messages Smart Session sends. */
|
|
83
|
+
const shell = (alarm: boolean, children: ReactNode) => <View style={{ width: "100%", maxWidth: 560, marginVertical: 4 }}>
|
|
84
|
+
<View style={[{ borderRadius: 12, backgroundColor: c.surface1, overflow: "hidden" }, alarm ? { borderWidth: 1, borderColor: withAlpha(c.statusDanger, 0.6) } : raised(c)]}>{children}</View>
|
|
85
|
+
</View>;
|
|
86
|
+
const titleRow = (icon: string, color: string, title: string, titleColor = c.foreground) => <View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingTop: 11, paddingBottom: 4 }}>
|
|
87
|
+
<Icon name={icon} size={14} color={color} />
|
|
88
|
+
<Text numberOfLines={1} style={{ flex: 1, color: titleColor, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{title}</Text>
|
|
89
|
+
<Text style={t.meta}>{at}</Text>
|
|
90
|
+
</View>;
|
|
79
91
|
|
|
80
92
|
if (!live) {
|
|
81
|
-
if (item.data.event === "resumed") return line("Play", c.foregroundMuted, presentation.title, item.data.manualResume ? `${at} · by you ·
|
|
82
|
-
if (item.data.event === "released") return line("
|
|
93
|
+
if (item.data.event === "resumed") return line("Play", c.foregroundMuted, presentation.title, item.data.manualResume ? `${at} · by you · no longer pauses at the limit` : `${at} · ${item.data.native ? "workers continued in place" : "continuation sent"}`);
|
|
94
|
+
if (item.data.event === "released") return line("Pause", c.foregroundMuted, presentation.title, item.data.dismissed ? `${at} · no message sent · no longer pauses at the limit` : at);
|
|
83
95
|
// attention
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<Text style={t.meta}>{at}</Text>
|
|
89
|
-
</View>
|
|
90
|
-
<Text style={t.body}>{presentation.detail}</Text>
|
|
91
|
-
</View>;
|
|
96
|
+
return shell(true, <>
|
|
97
|
+
{titleRow("TriangleAlert", c.statusDanger, presentation.title, c.statusDanger)}
|
|
98
|
+
<View style={{ paddingHorizontal: 14, paddingBottom: 11 }}><Text style={t.body}>{presentation.detail}</Text></View>
|
|
99
|
+
</>);
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
// A live pause: settled ones fold to a line.
|
|
95
103
|
if (loaded && (pause === null || pause.phase === "cancelled" || pause.phase === "resumed")) {
|
|
96
|
-
if (pause?.phase === "cancelled" && pause.dismissed) return line("
|
|
104
|
+
if (pause?.phase === "cancelled" && pause.dismissed) return line("Pause", c.foregroundMuted, "Pause dismissed", "no message sent · no longer pauses at the limit");
|
|
97
105
|
if (pause?.phase === "resumed") return line("Pause", c.foregroundMuted, `Paused at ${at}`, pause.manualResume ? `resumed by you ${formatClock(pause.settledAt)}` : `resumed ${formatClock(pause.settledAt)}`);
|
|
98
106
|
// The event that ended it has its own line; this one only says when.
|
|
99
107
|
return line("Pause", c.foregroundMuted, `Paused at ${at}`, pause?.settledAt ? `ended ${formatClock(pause.settledAt)}` : "no longer active");
|
|
@@ -106,49 +114,46 @@ export function GuardTimelineItem({ theme, agentId, item, layout, openOverview }
|
|
|
106
114
|
// Lead with the moment, the way every family card does: the reset clock, then the countdown.
|
|
107
115
|
const big = item.data.demo ? "Test pause" : reset === null ? "Reset time unknown" : formatMoment(item.data.resetAt!, now);
|
|
108
116
|
const countdown = item.data.demo ? "waits for you" : reset === null ? "waiting for the provider to report it" : reset <= now ? "reset passed · waiting for a fresh reading" : `${auto ? "resumes" : "resets"} in ${formatDuration(reset - now)}`;
|
|
109
|
-
const facts = [
|
|
117
|
+
const facts = [providerName(item.data.provider),
|
|
110
118
|
item.data.usedPct === null ? null : `${Math.round(item.data.usedPct)}% used`,
|
|
111
119
|
item.data.native ? "native subagents frozen" : "turn interrupted",
|
|
112
120
|
auto ? null : "continue by hand"].filter(Boolean).join(" · ");
|
|
113
121
|
const accent = stuck ? c.statusDanger : c.statusWarning;
|
|
114
122
|
|
|
115
|
-
return
|
|
116
|
-
{stuck ?
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<Text style={t.meta}>
|
|
130
|
-
{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
{stuck ? <TextLink theme={theme} compact={compact} label="All paused sessions" onPress={openOverview} /> : null}
|
|
152
|
-
</View>;
|
|
123
|
+
return shell(stuck, <>
|
|
124
|
+
{stuck ? titleRow("TriangleAlert", accent, "Pause needs attention", c.statusDanger)
|
|
125
|
+
: titleRow("Pause", accent, item.data.demo ? "Test pause" : item.data.event === "recovery" ? "Still paused after a Paseo restart" : "Paused at the session limit")}
|
|
126
|
+
<View style={{ paddingHorizontal: 14, paddingBottom: 11, gap: 8 }}>
|
|
127
|
+
{stuck ? <Text style={t.body}>{pause?.note ?? (pause?.dismissed ? "Releasing the workers needs a look. Automatic continuation is cancelled." : "Check the session before continuing by hand.")}</Text> : <>
|
|
128
|
+
<View style={{ gap: 2 }}>
|
|
129
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8, flexWrap: "wrap", rowGap: 0 }}>
|
|
130
|
+
<Text style={{ color: c.foreground, fontSize: compact ? 17 : 18, lineHeight: compact ? 22 : 24, fontWeight: "600", ...tabular }}>{big}</Text>
|
|
131
|
+
<Text style={{ ...t.meta, fontSize: 13, flexShrink: 1 }}>{countdown}</Text>
|
|
132
|
+
</View>
|
|
133
|
+
<Text style={t.meta}>{facts}</Text>
|
|
134
|
+
{item.data.event === "recovery" ? <Text style={t.meta}>The original workers ended; the conversation and workspace stay in place.</Text> : null}
|
|
135
|
+
</View>
|
|
136
|
+
</>}
|
|
137
|
+
{!loaded ? <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Spinner color={c.foregroundMuted} size={11} /><Text style={t.meta}>Checking the pause…</Text></View> : null}
|
|
138
|
+
{pause?.phase === "resuming" ? <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Spinner color={c.foregroundMuted} size={11} /><Text style={t.meta}>Resuming…</Text></View> : null}
|
|
139
|
+
{waiting ? <View style={{ gap: 6 }}>
|
|
140
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
|
|
141
|
+
<Button theme={theme} small compact={compact} icon={PAUSE_ACTIONS.resume.icon} title={action === "resume" ? PAUSE_ACTIONS.resume.busy : PAUSE_ACTIONS.resume.label} loading={action === "resume"} disabled={busy}
|
|
142
|
+
accessibilityHint={item.data.demo ? "Releases this test session and stops it pausing at the limit." : PAUSE_ACTIONS.resume.hint} onPress={() => void act("resume")} />
|
|
143
|
+
<Button theme={theme} small compact={compact} kind="ghost" title={action === "dismiss" ? PAUSE_ACTIONS.dismiss.busy : PAUSE_ACTIONS.dismiss.label} disabled={busy}
|
|
144
|
+
accessibilityHint={PAUSE_ACTIONS.dismiss.hint} onPress={() => void act("dismiss")} />
|
|
145
|
+
<View style={{ flex: 1, minWidth: 8 }} />
|
|
146
|
+
<TextLink theme={theme} compact={compact} label="Paused sessions" onPress={openOverview} />
|
|
147
|
+
</View>
|
|
148
|
+
<Text style={t.small}>{item.data.demo ? "Resume now releases this test session." : PAUSE_ACTIONS.note}</Text>
|
|
149
|
+
</View> : null}
|
|
150
|
+
{stuck && pause?.dismissed ? <View style={{ flexDirection: "row" }}>
|
|
151
|
+
<Button theme={theme} small compact={compact} title={action === "dismiss" ? "Dismissing…" : "Retry dismissal"} disabled={busy}
|
|
152
|
+
accessibilityLabel="Retry dismissing pause without sending a message" onPress={() => void act("dismiss")} />
|
|
153
|
+
</View> : null}
|
|
154
|
+
{error ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{error}</Text> : null}
|
|
155
|
+
{statusError ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{statusError}</Text> : null}
|
|
156
|
+
{stuck ? <TextLink theme={theme} compact={compact} label="Paused sessions" onPress={openOverview} /> : null}
|
|
157
|
+
</View>
|
|
158
|
+
</>);
|
|
153
159
|
}
|
|
154
|
-
|