paseo-smart-session 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +55 -130
  2. package/assets/LICENSE.lucide +43 -0
  3. package/assets/icon.png +0 -0
  4. package/assets/icon.svg +1 -0
  5. package/client/agents.ts +200 -0
  6. package/client/charts.tsx +277 -152
  7. package/client/chat.tsx +122 -0
  8. package/client/display.tsx +55 -33
  9. package/client/guard/index.tsx +48 -0
  10. package/client/guard/paused.tsx +164 -0
  11. package/client/guard/pill-icons.tsx +26 -0
  12. package/client/guard/pills.ts +132 -0
  13. package/client/guard/settings.tsx +114 -0
  14. package/client/guard/surfaces.ts +3 -0
  15. package/client/guard/timeline.tsx +154 -0
  16. package/client/kit.tsx +144 -0
  17. package/client/live.ts +49 -0
  18. package/client/motion.tsx +94 -0
  19. package/client/overview.tsx +373 -0
  20. package/client/pill-store.ts +77 -0
  21. package/client/pill.tsx +91 -380
  22. package/client/popover.tsx +113 -0
  23. package/client/settings.tsx +105 -0
  24. package/client/theme.ts +56 -0
  25. package/docs/media/smart-session-chat.png +0 -0
  26. package/docs/media/smart-session-flow.gif +0 -0
  27. package/docs/media/smart-session-guard.png +0 -0
  28. package/docs/media/smart-session-hero-light.png +0 -0
  29. package/docs/media/smart-session-hero.png +0 -0
  30. package/docs/media/smart-session-mobile.png +0 -0
  31. package/docs/media/smart-session-pill.png +0 -0
  32. package/docs/navigation-verification.md +6 -6
  33. package/docs/screenshots/display-settings-desktop.png +0 -0
  34. package/docs/screenshots/display-settings-mobile.png +0 -0
  35. package/docs/session-guard.md +88 -0
  36. package/index.client.tsx +52 -35
  37. package/index.server.ts +19 -11
  38. package/mcp.mjs +22 -3
  39. package/package.json +2 -1
  40. package/paseo-plugin.json +11 -0
  41. package/server/guard/client.ts +27 -0
  42. package/server/guard/engine.ts +554 -0
  43. package/server/guard/index.ts +395 -0
  44. package/server/guard/labels.ts +77 -0
  45. package/server/guard/processes.ts +147 -0
  46. package/server/guard/standalone.ts +61 -0
  47. package/server/guard/store.ts +113 -0
  48. package/server/guard/timeline.ts +24 -0
  49. package/server/guard/usage.ts +57 -0
  50. package/server/recorder.ts +2 -0
  51. package/server/settings.ts +10 -0
  52. package/server/store.ts +25 -1
  53. package/shared/blocks.ts +15 -0
  54. package/shared/chat.ts +174 -0
  55. package/shared/forecast.ts +233 -0
  56. package/shared/format.ts +47 -1
  57. package/shared/governor.ts +3 -0
  58. package/shared/guard/contracts.ts +65 -0
  59. package/shared/guard/policy.ts +128 -0
  60. package/shared/guard/presentation.ts +85 -0
  61. package/shared/guard/timeline.ts +34 -0
  62. package/shared/lifecycle.ts +18 -0
  63. package/shared/smart-session.ts +16 -0
  64. package/client/navigation.tsx +0 -14
  65. package/client/surface.tsx +0 -574
  66. package/docs/screenshots/smart-compact-handoff.png +0 -0
@@ -0,0 +1,114 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ActivityIndicator, Text, View } from "react-native";
3
+ import { useRpc, useSettings, type PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSelect, SettingsSwitch } from "@getpaseo/plugin/client/ui";
5
+ import { cancelResumeRpc, guardPreferences, setProtectionRpc, statusRpc } from "../../shared/guard/contracts.ts";
6
+ import { isPending, PolicySchema, type Policy } from "../../shared/guard/policy.ts";
7
+ import { governorNotice, pauseOwnerNote } from "../../shared/guard/presentation.ts";
8
+ import { refreshGuardPills } from "./pills.ts";
9
+
10
+ type Draft = { thresholdPct: string; pollSeconds: string; resetGraceSeconds: string; resumeSpacingSeconds: string; continuation: string; providerIds: string; windowIds: string };
11
+ function draftOf(policy: Policy): Draft {
12
+ return { thresholdPct: String(policy.thresholdPct), pollSeconds: String(policy.pollSeconds), resetGraceSeconds: String(policy.resetGraceSeconds),
13
+ resumeSpacingSeconds: String(policy.resumeSpacingSeconds), continuation: policy.continuation, providerIds: policy.providerIds.join(", "), windowIds: policy.windowIds.join(", ") };
14
+ }
15
+ const list = (value: string) => [...new Set(value.split(",").map(s => s.trim()).filter(Boolean))];
16
+
17
+ export function GuardSettings({ theme, openOverview }: PluginSurfaceProps & { openOverview(): void }) {
18
+ const settings = useSettings(guardPreferences);
19
+ const values = settings.status === "ready" ? settings.values : undefined;
20
+ // Pills poll slowly while the feature is off; tell them as soon as it changes.
21
+ useEffect(() => {
22
+ if (values !== undefined) refreshGuardPills();
23
+ }, [values]);
24
+ const readStatus = useRpc(statusRpc);
25
+ const cancelResume = useRpc(cancelResumeRpc);
26
+ const setProtection = useRpc(setProtectionRpc);
27
+ const [status, setStatus] = useState<Awaited<ReturnType<typeof readStatus>> | null>(null);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [statusError, setStatusError] = useState<string | null>(null);
30
+ const [draft, setDraft] = useState<Draft | null>(null);
31
+ const [revision, setRevision] = useState<string | null>(null);
32
+ useEffect(() => {
33
+ let stopped = false;
34
+ let pending = false;
35
+ const refresh = async () => {
36
+ if (pending || stopped) return;
37
+ pending = true;
38
+ try { const next = await readStatus({}); if (!stopped) { setStatus(next); setStatusError(null); } }
39
+ catch { if (!stopped) setStatusError("Could not load current session status. Retrying automatically."); }
40
+ finally { pending = false; }
41
+ };
42
+ void refresh(); const timer = setInterval(() => void refresh(), 5000);
43
+ return () => { stopped = true; clearInterval(timer); };
44
+ }, [readStatus]);
45
+ if (settings.status === "loading") return <ActivityIndicator accessibilityLabel="Loading Session Guard settings" color={theme.colors.foregroundMuted} />;
46
+ if (settings.status !== "ready") return <View style={{ gap: 12 }}>
47
+ <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.error}</Text>
48
+ <SettingsCard><SettingsAction label="Load saved settings again" actionLabel="Retry" onPress={() => void settings.reload()} />
49
+ {settings.status === "invalid" && <SettingsAction label="Replace invalid settings with defaults" actionLabel="Reset" onPress={() => void settings.reset()} disabled={settings.saving} />}</SettingsCard>
50
+ </View>;
51
+ const change = (patch: Partial<Policy>) => void settings.save({ ...settings.values, ...patch }, settings.revision);
52
+ const action = async (work: () => Promise<unknown>) => {
53
+ try { await work(); setStatus(await readStatus({})); setError(null); }
54
+ catch { setError("The change could not be saved. Try again."); }
55
+ };
56
+ const saveAdvanced = async () => {
57
+ if (!draft || !revision) return;
58
+ const parsed = PolicySchema.safeParse({ ...settings.values, ...draft,
59
+ thresholdPct: Number(draft.thresholdPct), pollSeconds: Number(draft.pollSeconds), resetGraceSeconds: Number(draft.resetGraceSeconds), resumeSpacingSeconds: Number(draft.resumeSpacingSeconds),
60
+ providerIds: list(draft.providerIds), windowIds: list(draft.windowIds),
61
+ });
62
+ if (!parsed.success) { setError(parsed.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join("\n")); return; }
63
+ if (await settings.save(parsed.data, revision)) { setDraft(null); setRevision(null); setError(null); }
64
+ };
65
+ const text = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 };
66
+ const waiting = status?.pauses.filter(isPending) ?? [];
67
+ // A non-governing plugin can show the shared pauses but cannot act on them.
68
+ const notice = status ? governorNotice(status) : null;
69
+ const owner = status ? pauseOwnerNote(status) : null;
70
+ return <View style={{ gap: 24 }}>
71
+ <Text style={text}>Pause work at the provider’s session limit, then continue the same conversation after reset. Your workspace and conversation stay in place.</Text>
72
+ {notice && status?.governor !== "off" && <SettingsCard><SettingsRow label={notice.title} hint={notice.hint} /></SettingsCard>}
73
+ <SettingsSection title="Protection"><SettingsCard>
74
+ <SettingsSwitch label="Enable Session Guard" hint="Off until you turn it on. Runs even when Paseo’s app is closed. Turning it off releases suspended workers; message continuations wait until you turn it back on or cancel them." value={settings.values.enabled} disabled={settings.saving || !!draft} onValueChange={enabled => change({ enabled })} />
75
+ <SettingsSelect label="Default for sessions" hint="Each session’s composer pill can override this default. Existing explicit overrides stay in place." value={settings.values.protectByDefault ? "all" : "opt-in"} options={[{ label: "Protect all sessions", value: "all" }, { label: "Opt in per session", value: "opt-in" }]} disabled={settings.saving || !!draft} onValueChange={value => change({ protectByDefault: value === "all" })} />
76
+ <SettingsSwitch label="Continue automatically after reset" hint="Waits for a fresh usage report below your threshold. Turn this off to continue manually." value={settings.values.autoResume} disabled={settings.saving || !!draft} onValueChange={autoResume => change({ autoResume })} />
77
+ <SettingsSwitch label="Include native subagents" hint="On Linux, pause Claude and Codex sessions together with their nested workers. Turn this off to interrupt only the foreground turn." value={settings.values.includeNativeSubagents} disabled={settings.saving || !!draft} onValueChange={includeNativeSubagents => change({ includeNativeSubagents })} />
78
+ <SettingsSwitch label="Label paused workspaces" hint="Show an amber Paused label in the left sidebar until every paused session in that workspace is released." value={settings.values.workspaceLabels} disabled={settings.saving || !!draft} onValueChange={workspaceLabels => change({ workspaceLabels })} />
79
+ </SettingsCard></SettingsSection>
80
+ <SettingsSection title="Waiting for reset"><SettingsCard>
81
+ {!status && <SettingsRow label="Loading session status…" />}
82
+ {status && waiting.length === 0 && <SettingsRow label="No sessions are paused" hint="Protected work will appear here when the session limit is reached." />}
83
+ {waiting.map(pause => {
84
+ const hint = pause.note ?? `${pause.provider}${pause.suspension ? " · Includes native subagents" : ""} · ${pause.anchorResetsAt ? `Reset ${new Date(pause.anchorResetsAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}` : "Waiting for usage to become available"}`;
85
+ return owner ? <SettingsRow key={pause.id} label={pause.title ?? "Untitled session"} hint={`${hint}\n${owner}`} />
86
+ : <SettingsAction key={pause.id} label={pause.title ?? "Untitled session"} hint={hint} actionLabel={pause.suspension ? "Release and turn off" : "Cancel continuation"} disabled={settings.saving} onPress={() => void action(() => cancelResume({ agentId: pause.agentId }))} />;
87
+ })}
88
+ <SettingsAction label="All paused sessions" hint="Open sessions, check reset times, and manage each pause in one place." actionLabel="Open list" onPress={openOverview} />
89
+ </SettingsCard></SettingsSection>
90
+ {status && Object.keys(status.overrides).length > 0 && <SettingsSection title="Session overrides"><SettingsCard>
91
+ {Object.entries(status.overrides).map(([agentId, enabled]) => {
92
+ const label = status.pauses.find(p => p.agentId === agentId)?.title ?? `Session ${agentId.slice(0, 8)}`;
93
+ const hint = enabled ? "Explicitly protected" : "Excluded from protection";
94
+ return status.governor === "active" ? <SettingsAction key={agentId} label={label} hint={hint} actionLabel="Use default" onPress={() => void action(() => setProtection({ agentId, enabled: null }))} />
95
+ : <SettingsRow key={agentId} label={label} hint={hint} />;
96
+ })}
97
+ </SettingsCard></SettingsSection>}
98
+ <SettingsSection title="Timing and continuation"><SettingsCard>
99
+ {!draft ? <SettingsAction label={`Pause at ${settings.values.thresholdPct}% session usage`} hint={`Check every ${settings.values.pollSeconds}s · wait ${settings.values.resetGraceSeconds}s after reset · space continuations by ${settings.values.resumeSpacingSeconds}s`} actionLabel="Edit" onPress={() => { setDraft(draftOf(settings.values)); setRevision(settings.revision); }} /> : <>
100
+ <SettingsInput label="Pause threshold (%)" hint="1–100. Default: 100." initialValue={draft.thresholdPct} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, thresholdPct: value })} />
101
+ <SettingsInput label="Check interval (seconds)" hint="5–300. Upstream usage may be cached by Paseo; this cannot force a provider refresh." initialValue={draft.pollSeconds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, pollSeconds: value })} />
102
+ <SettingsInput label="Wait after reset (seconds)" hint="0–600. Default: 30." initialValue={draft.resetGraceSeconds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, resetGraceSeconds: value })} />
103
+ <SettingsInput label="Space continuations (seconds)" hint="0–300. Avoid sending every paused session at once." initialValue={draft.resumeSpacingSeconds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, resumeSpacingSeconds: value })} />
104
+ <SettingsInput label="Continuation message" hint="Sent once into the same conversation after the reset is confirmed." initialValue={draft.continuation} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, continuation: value })} />
105
+ <SettingsInput label="Provider IDs" hint="Comma separated. Leave empty to protect all providers reporting session usage." initialValue={draft.providerIds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, providerIds: value })} />
106
+ <SettingsInput label="Session window IDs" hint="Comma separated, in preference order. Default: five_hour, session." initialValue={draft.windowIds} disabled={settings.saving} onChangeText={value => setDraft(previous => previous && { ...previous, windowIds: value })} />
107
+ <SettingsAction label="Apply timing and continuation" actionLabel="Save changes" disabled={settings.saving} onPress={() => void saveAdvanced()} />
108
+ <SettingsAction label="Keep saved values" actionLabel="Discard changes" disabled={settings.saving} onPress={() => { setDraft(null); setRevision(null); setError(null); }} />
109
+ </>}
110
+ </SettingsCard></SettingsSection>
111
+ {(error || settings.saveError) && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{error ?? settings.saveError}</Text>}
112
+ {(statusError || status?.error) && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{statusError ?? status?.error}</Text>}
113
+ </View>;
114
+ }
@@ -0,0 +1,3 @@
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";
@@ -0,0 +1,154 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/ui";
5
+ import { timelinePresentation, type TimelineData } from "../../shared/guard/timeline.ts";
6
+ import { dismissPauseRpc, resumeNowRpc, statusRpc } from "../../shared/guard/contracts.ts";
7
+ import { timestamp, type Pause } from "../../shared/guard/policy.ts";
8
+ import { formatClock, formatDuration } from "../../shared/format.ts";
9
+ import { formatMoment } from "../../shared/forecast.ts";
10
+ import { Button, TextLink, text, useNow } from "../kit.tsx";
11
+ import { Spinner } from "../motion.tsx";
12
+ import { raised, tabular, withAlpha } from "../theme.ts";
13
+
14
+ const providerName = (id: string) => (id === "claude" ? "Claude" : id === "codex" ? "Codex" : id);
15
+
16
+ /**
17
+ * A Session Guard pause in the chat. While the pause waits, it is a card that leads
18
+ * with the clock the window resets at, a live countdown, and Resume now / Dismiss.
19
+ * Once the pause settles it resolves itself into one quiet line, so the chat keeps
20
+ * the history without a stale card. Settled events (resumed, released) are lines
21
+ * from the start.
22
+ */
23
+ export function GuardTimelineItem({ theme, agentId, item, layout, openOverview }: PluginTimelineItemProps<TimelineData> & { openOverview(): void }) {
24
+ const c = theme.colors;
25
+ const t = text(c);
26
+ const compact = layout.compact;
27
+ const readStatus = useRpc(statusRpc);
28
+ const resumeNow = useRpc(resumeNowRpc);
29
+ const dismissPause = useRpc(dismissPauseRpc);
30
+ const [pause, setPause] = useState<Pause | null>(null);
31
+ const [loaded, setLoaded] = useState(false);
32
+ const [action, setAction] = useState<"resume" | "dismiss" | null>(null);
33
+ const busy = action !== null;
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [statusError, setStatusError] = useState<string | null>(null);
36
+ const live = ["paused", "recovery"].includes(item.data.event);
37
+ const now = useNow(live && pause?.phase === "waiting" ? 15_000 : 60_000);
38
+ useEffect(() => {
39
+ if (!live) return;
40
+ setPause(null); setLoaded(false); setError(null); setStatusError(null);
41
+ let stopped = false, pending = false;
42
+ const refresh = async () => {
43
+ if (pending || stopped) return;
44
+ pending = true;
45
+ try {
46
+ const status = await readStatus({});
47
+ if (!stopped) { setPause(status.pauses.find((p) => p.id === item.data.pauseId && p.agentId === agentId) ?? null); setLoaded(true); setStatusError(null); }
48
+ } catch { if (!stopped) setStatusError("Couldn't check this pause. Retrying."); }
49
+ finally { pending = false; }
50
+ };
51
+ void refresh(); const timer = setInterval(() => void refresh(), 5000);
52
+ return () => { stopped = true; clearInterval(timer); };
53
+ }, [readStatus, agentId, item.data.event, item.data.pauseId]);
54
+ const act = async (kind: "resume" | "dismiss") => {
55
+ if (busy) return;
56
+ setAction(kind); setError(null);
57
+ try {
58
+ await (kind === "resume" ? resumeNow : dismissPause)({ agentId, pauseId: item.data.pauseId });
59
+ const status = await readStatus({});
60
+ setPause(status.pauses.find((p) => p.id === item.data.pauseId && p.agentId === agentId) ?? null);
61
+ } catch {
62
+ setError(kind === "resume" ? "Resume could not be confirmed. Check the session before trying again."
63
+ : "Dismissal could not be confirmed. Check the pause; retrying dismissal sends no message.");
64
+ } finally { setAction(null); }
65
+ };
66
+
67
+ const presentation = timelinePresentation(item.data);
68
+ const at = formatClock(item.data.at);
69
+
70
+ /** One quiet line: the settled form of every card. */
71
+ const line = (icon: string, color: string, words: string, extra?: string) => (
72
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 4 }}>
73
+ <View style={{ width: 22, alignItems: "center" }}><Icon name={icon} size={13} color={color} /></View>
74
+ <Text style={{ flexShrink: 1, color: c.foregroundMuted, fontSize: 13, lineHeight: 18, ...tabular }}>
75
+ <Text style={{ color: c.foreground }}>{words}</Text>{extra ? ` · ${extra}` : ""}
76
+ </Text>
77
+ </View>
78
+ );
79
+
80
+ if (!live) {
81
+ if (item.data.event === "resumed") return line("Play", c.foregroundMuted, presentation.title, item.data.manualResume ? `${at} · by you · protection off for this session` : `${at} · ${item.data.native ? "workers continued in place" : "continuation sent"}`);
82
+ if (item.data.event === "released") return line("ShieldOff", c.foregroundMuted, presentation.title, item.data.dismissed ? `${at} · no message sent · protection off` : at);
83
+ // attention
84
+ return <View style={[{ borderRadius: 12, backgroundColor: c.surface1, padding: 14, gap: 6, borderWidth: 1, borderColor: withAlpha(c.statusDanger, 0.6) }]}>
85
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
86
+ <Icon name="TriangleAlert" size={15} color={c.statusDanger} />
87
+ <Text style={{ ...t.label, color: c.statusDanger, flex: 1 }}>{presentation.title}</Text>
88
+ <Text style={t.meta}>{at}</Text>
89
+ </View>
90
+ <Text style={t.body}>{presentation.detail}</Text>
91
+ </View>;
92
+ }
93
+
94
+ // A live pause: settled ones fold to a line.
95
+ if (loaded && (pause === null || pause.phase === "cancelled" || pause.phase === "resumed")) {
96
+ if (pause?.phase === "cancelled" && pause.dismissed) return line("ShieldOff", c.foregroundMuted, "Pause dismissed", "no message sent · protection off");
97
+ 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
+ // The event that ended it has its own line; this one only says when.
99
+ return line("Pause", c.foregroundMuted, `Paused at ${at}`, pause?.settledAt ? `ended ${formatClock(pause.settledAt)}` : "no longer active");
100
+ }
101
+
102
+ const reset = timestamp(item.data.resetAt);
103
+ const auto = item.data.autoResume;
104
+ const waiting = pause?.phase === "waiting";
105
+ const stuck = pause?.phase === "attention";
106
+ // Lead with the moment, the way every family card does: the reset clock, then the countdown.
107
+ const big = item.data.demo ? "Test pause" : reset === null ? "Reset time unknown" : formatMoment(item.data.resetAt!, now);
108
+ 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 = [item.data.demo ? "Test pause" : item.data.event === "recovery" ? "Saved through a Paseo restart" : "Paused at the session limit", at, providerName(item.data.provider),
110
+ item.data.usedPct === null ? null : `${Math.round(item.data.usedPct)}% used`,
111
+ item.data.native ? "native subagents frozen" : "turn interrupted",
112
+ auto ? null : "continue by hand"].filter(Boolean).join(" · ");
113
+ const accent = stuck ? c.statusDanger : c.statusWarning;
114
+
115
+ return <View style={[{ borderRadius: 12, backgroundColor: c.surface1, paddingHorizontal: 14, paddingVertical: 12, gap: 8 }, stuck ? { borderWidth: 1, borderColor: withAlpha(c.statusDanger, 0.6) } : raised(c)]}>
116
+ {stuck ? <>
117
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
118
+ <Icon name="TriangleAlert" size={14} color={accent} />
119
+ <Text style={{ ...t.label, flex: 1, color: c.statusDanger }}>Pause needs attention</Text>
120
+ <Text style={t.meta}>{at}</Text>
121
+ </View>
122
+ <Text style={t.body}>{pause?.note ?? (pause?.dismissed ? "Worker release needs attention. Automatic continuation is cancelled." : "Check the session before continuing by hand.")}</Text>
123
+ </> : <>
124
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap", rowGap: 0 }}>
125
+ <Icon name="Pause" size={compact ? 15 : 16} color={accent} />
126
+ <Text style={{ color: c.foreground, fontSize: compact ? 18 : 20, lineHeight: compact ? 24 : 26, fontWeight: "600", ...tabular }}>{big}</Text>
127
+ <Text style={{ ...t.meta, fontSize: 13, flexShrink: 1 }}>{countdown}</Text>
128
+ </View>
129
+ <Text style={t.meta}>{facts}</Text>
130
+ {item.data.event === "recovery" ? <Text style={t.meta}>The original workers ended; the conversation and workspace stay in place.</Text> : null}
131
+ </>}
132
+ {!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}
133
+ {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}
134
+ {waiting ? <View style={{ gap: 6 }}>
135
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
136
+ <Button theme={theme} small compact={compact} icon="Play" title={action === "resume" ? "Resuming…" : "Resume now"} loading={action === "resume"} disabled={busy}
137
+ accessibilityHint={item.data.demo ? "Releases this test session and turns its protection off." : "Continues before the reset and turns protection off for this session. The provider may still refuse work until the reset."} onPress={() => void act("resume")} />
138
+ <Button theme={theme} small compact={compact} kind="ghost" title={action === "dismiss" ? "Dismissing…" : "Dismiss pause"} disabled={busy}
139
+ accessibilityLabel="Dismiss pause without sending a message" onPress={() => void act("dismiss")} />
140
+ <View style={{ flex: 1, minWidth: 8 }} />
141
+ <TextLink theme={theme} compact={compact} label="All paused" onPress={openOverview} />
142
+ </View>
143
+ <Text style={t.small}>{item.data.demo ? "Resume now releases this test session and turns its protection off." : "Resume now turns protection off for this session · Dismiss sends nothing"}</Text>
144
+ </View> : null}
145
+ {stuck && pause?.dismissed ? <View style={{ flexDirection: "row" }}>
146
+ <Button theme={theme} small compact={compact} title={action === "dismiss" ? "Dismissing…" : "Retry dismissal"} disabled={busy}
147
+ accessibilityLabel="Retry dismissing pause without sending a message" onPress={() => void act("dismiss")} />
148
+ </View> : null}
149
+ {error ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{error}</Text> : null}
150
+ {statusError ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{statusError}</Text> : null}
151
+ {stuck ? <TextLink theme={theme} compact={compact} label="All paused sessions" onPress={openOverview} /> : null}
152
+ </View>;
153
+ }
154
+
package/client/kit.tsx ADDED
@@ -0,0 +1,144 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/ui";
4
+ import { PressScale, Spinner } from "./motion";
5
+ import { raised, tabular, tint, withAlpha, type Colors, type Theme } from "./theme";
6
+
7
+ export type { Theme } from "./theme";
8
+
9
+ /**
10
+ * The plugin family's kit (Follow-up and Defer use the same pieces): one button
11
+ * vocabulary, raised cards, tinted chips, an accessible switch and the type scale.
12
+ */
13
+
14
+ /** Re-render on an interval so relative times keep moving between polls. */
15
+ export function useNow(intervalMs = 30_000): number {
16
+ const [now, setNow] = useState(() => Date.now());
17
+ useEffect(() => {
18
+ setNow(Date.now());
19
+ const id = setInterval(() => setNow(Date.now()), intervalMs);
20
+ return () => clearInterval(id);
21
+ }, [intervalMs]);
22
+ return now;
23
+ }
24
+
25
+ export function errorText(error: unknown): string {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+
29
+ /** The family type scale: tabular numerals wherever a time or count can appear. */
30
+ export const text = (colors: Colors) => ({
31
+ title: { color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" as const },
32
+ body: { color: colors.foreground, fontSize: 13, lineHeight: 19 },
33
+ meta: { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, ...tabular },
34
+ small: { color: colors.foregroundMuted, fontSize: 11, lineHeight: 15, ...tabular },
35
+ label: { color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" as const },
36
+ group: { color: colors.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" as const, ...tabular },
37
+ });
38
+
39
+ type ButtonKind = "primary" | "secondary" | "ghost" | "danger";
40
+ /** 28–32px with a hit slop on desktop, 44px on touch layouts. */
41
+ export function Button({ title, onPress, theme, kind = "secondary", disabled = false, loading = false, icon, iconColor, compact = false, small = false, accessibilityLabel, accessibilityHint }: {
42
+ title: string; onPress: () => void; theme: Theme; kind?: ButtonKind; disabled?: boolean; loading?: boolean; icon?: string; iconColor?: string; compact?: boolean; small?: boolean; accessibilityLabel?: string; accessibilityHint?: string;
43
+ }) {
44
+ const c = theme.colors;
45
+ const fg = kind === "primary" ? c.accentForeground : kind === "danger" ? c.statusDanger : c.foreground;
46
+ const inactive = disabled || loading;
47
+ const height = compact ? 44 : small ? 28 : 32;
48
+ return <PressScale accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? title} accessibilityHint={accessibilityHint} accessibilityState={{ disabled: inactive, busy: loading }}
49
+ disabled={inactive} onPress={onPress} hitSlop={compact ? 0 : 4} outerStyle={{ alignSelf: "flex-start" }}
50
+ style={({ pressed, hovered }) => ({
51
+ minHeight: height, paddingHorizontal: compact ? 14 : small ? 9 : 11, borderRadius: compact ? 8 : 6, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6,
52
+ opacity: disabled ? 0.45 : 1,
53
+ ...(kind === "primary" ? { backgroundColor: pressed ? withAlpha(c.accent, 0.85) : hovered ? withAlpha(c.accent, 0.92) : c.accent }
54
+ : kind === "ghost" || kind === "danger" ? { backgroundColor: pressed ? c.surface2 : hovered ? c.surface1 : "transparent" }
55
+ : { ...raised(c), backgroundColor: pressed ? c.surface2 : hovered ? c.surface2 : c.surface1 }),
56
+ })}>
57
+ {loading ? <Spinner color={fg} size={12} /> : icon ? <Icon name={icon} size={compact ? 16 : 14} color={iconColor ?? fg} /> : null}
58
+ <Text numberOfLines={1} style={{ color: fg, fontSize: compact ? 14 : small ? 12 : 13, lineHeight: compact ? 19 : 16, fontWeight: kind === "primary" ? "600" : "500", ...tabular }}>{title}</Text>
59
+ </PressScale>;
60
+ }
61
+
62
+ /** A square icon-only control with an accessible name. */
63
+ export function IconButton({ icon, label, onPress, theme, compact = false, disabled = false, color }: { icon: string; label: string; onPress: () => void; theme: Theme; compact?: boolean; disabled?: boolean; color?: string }) {
64
+ const c = theme.colors;
65
+ const size = compact ? 44 : 28;
66
+ return <PressScale accessibilityRole="button" accessibilityLabel={label} disabled={disabled} onPress={onPress} hitSlop={compact ? 0 : 6}
67
+ style={({ pressed, hovered }) => ({ width: size, height: size, borderRadius: 6, alignItems: "center", justifyContent: "center", backgroundColor: pressed ? c.surface2 : hovered ? c.surface1 : "transparent", opacity: disabled ? 0.45 : 1 })}>
68
+ <Icon name={icon} size={compact ? 18 : 15} color={color ?? c.foregroundMuted} />
69
+ </PressScale>;
70
+ }
71
+
72
+ /** A raised container. Groups of rows sit inside one; rows divide with hairlines. */
73
+ export function Card({ theme, children, style }: { theme: Theme; children: ReactNode; style?: StyleProp<ViewStyle> }) {
74
+ const c = theme.colors;
75
+ return <View style={[{ borderRadius: 10, overflow: "hidden", backgroundColor: c.surface0 }, raised(c), style]}>{children}</View>;
76
+ }
77
+
78
+ /** A row inside a card: hairline on top, hover tint when pressable. */
79
+ export function CardRow({ theme, onPress, accessibilityLabel, children, style, first = false }: { theme: Theme; onPress?: () => void; accessibilityLabel?: string; children: ReactNode; style?: StyleProp<ViewStyle>; first?: boolean }) {
80
+ const c = theme.colors;
81
+ const base: ViewStyle = { paddingHorizontal: 14, paddingVertical: 12, borderTopWidth: first ? 0 : 1, borderTopColor: c.border };
82
+ if (!onPress) return <View style={[base, style]}>{children}</View>;
83
+ return <Pressable accessibilityRole="button" accessibilityLabel={accessibilityLabel} onPress={onPress}
84
+ style={(state) => [base, { backgroundColor: state.pressed ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" }, style]}>{children}</Pressable>;
85
+ }
86
+
87
+ /** A count or status tag in its own hue at 12–16%. */
88
+ export function Chip({ theme, label, color, icon }: { theme: Theme; label: string; color?: string; icon?: string }) {
89
+ const c = theme.colors;
90
+ const hue = color ?? c.foregroundMuted;
91
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 7, paddingVertical: 1, borderRadius: 999, backgroundColor: tint(hue, c), alignSelf: "center" }}>
92
+ {icon ? <Icon name={icon} size={11} color={hue} /> : null}
93
+ <Text numberOfLines={1} style={{ color: hue, fontSize: 11, lineHeight: 16, fontWeight: "600", ...tabular }}>{label}</Text>
94
+ </View>;
95
+ }
96
+
97
+ /** A section heading with an optional count and a right-hand slot. */
98
+ export function SectionHead({ theme, title, count, trailing }: { theme: Theme; title: string; count?: number; trailing?: ReactNode }) {
99
+ const c = theme.colors;
100
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 7, minHeight: 28 }}>
101
+ <Text accessibilityRole="header" style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{title}</Text>
102
+ {count === undefined ? null : <Chip theme={theme} label={String(count)} />}
103
+ <View style={{ flex: 1 }} />
104
+ {trailing}
105
+ </View>;
106
+ }
107
+
108
+ /** A quiet text link with a trailing arrow, for places a view points to rather than controls. */
109
+ export function TextLink({ theme, label, onPress, icon = "ArrowUpRight", compact = false }: { theme: Theme; label: string; onPress: () => void; icon?: string; compact?: boolean }) {
110
+ const c = theme.colors;
111
+ return <Pressable accessibilityRole="link" accessibilityLabel={label} onPress={onPress} hitSlop={compact ? 0 : 6}
112
+ style={(state) => ({ minHeight: compact ? 44 : 24, flexDirection: "row", alignItems: "center", gap: 3, opacity: state.pressed ? 0.6 : 1 })}>
113
+ {(state) => <>
114
+ <Text style={{ color: (state as { hovered?: boolean }).hovered ? c.foreground : c.foregroundMuted, fontSize: 12, lineHeight: 17, textDecorationLine: (state as { hovered?: boolean }).hovered ? "underline" : "none" }}>{label}</Text>
115
+ <Icon name={icon} size={12} color={c.foregroundMuted} />
116
+ </>}
117
+ </Pressable>;
118
+ }
119
+
120
+ /** An accessible switch: accent only for "on", the one place this plugin uses it besides primary buttons. */
121
+ export function Switch({ theme, label, value, onChange, disabled = false, hideLabel = false, compact = false }: { theme: Theme; label: string; value: boolean | null; onChange: (next: boolean) => void; disabled?: boolean; hideLabel?: boolean; compact?: boolean }) {
122
+ const c = theme.colors;
123
+ const on = value === true;
124
+ const inactive = disabled || value === null;
125
+ return <Pressable accessibilityRole="switch" aria-checked={value === null ? "mixed" : on} accessibilityLabel={label}
126
+ accessibilityState={{ checked: value === null ? "mixed" : on, disabled: inactive }} accessibilityValue={{ text: value === null ? "Unknown" : on ? "On" : "Off" }}
127
+ disabled={inactive} onPress={() => onChange(!on)} hitSlop={compact ? 0 : 6}
128
+ style={({ pressed }) => ({ minHeight: compact ? 44 : 28, flexDirection: "row", alignItems: "center", gap: 8, opacity: inactive ? 0.5 : pressed ? 0.8 : 1 })}>
129
+ {hideLabel ? null : <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{label}</Text>}
130
+ <View style={{ width: 34, height: 20, borderRadius: 10, backgroundColor: on ? c.accent : c.surface2, borderWidth: 1, borderColor: on ? c.accent : c.border, justifyContent: "center" }}>
131
+ <View style={{ width: 14, height: 14, borderRadius: 7, marginLeft: value === null ? 9 : on ? 16 : 2, backgroundColor: on ? c.accentForeground : value === null ? c.border : c.foregroundMuted }} />
132
+ </View>
133
+ </Pressable>;
134
+ }
135
+
136
+ /** A small status dot, or a spinner while the agent works. */
137
+ export function StatusGlyph({ theme, status }: { theme: Theme; status: string }) {
138
+ const c = theme.colors;
139
+ if (status === "running" || status === "initializing") return <Spinner color={c.foregroundMuted} size={11} label="Working" />;
140
+ const color = status === "error" ? c.statusDanger : status === "idle" ? c.foregroundMuted : c.border;
141
+ return <View accessibilityLabel={status} style={{ width: 11, height: 11, alignItems: "center", justifyContent: "center" }}>
142
+ <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: color }} />
143
+ </View>;
144
+ }
package/client/live.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import type { z } from "zod";
3
+
4
+ import type { budgetStatus } from "../shared/smart-session";
5
+
6
+ type Budget = z.infer<(typeof budgetStatus)["output"]>;
7
+
8
+ /**
9
+ * What the sidebar row shows, fed by the polls that already run: the budget from
10
+ * the overview and the client's own slow poll, the paused count from the Session
11
+ * Guard pills' status poll. One per plugin installation.
12
+ */
13
+ export interface Live {
14
+ subscribe(listener: () => void): () => void;
15
+ version(): number;
16
+ budget(): Budget | null;
17
+ paused(): { waiting: number; attention: number };
18
+ setBudget(budget: Budget): void;
19
+ setPaused(counts: { waiting: number; attention: number }): void;
20
+ }
21
+
22
+ export function createLive(): Live {
23
+ let budget: Budget | null = null;
24
+ let paused = { waiting: 0, attention: 0 };
25
+ let version = 0;
26
+ const listeners = new Set<() => void>();
27
+ const notify = () => {
28
+ version += 1;
29
+ for (const listener of [...listeners]) listener();
30
+ };
31
+ return {
32
+ subscribe(listener) {
33
+ listeners.add(listener);
34
+ return () => { listeners.delete(listener); };
35
+ },
36
+ version: () => version,
37
+ budget: () => budget,
38
+ paused: () => paused,
39
+ setBudget(next) { budget = next; notify(); },
40
+ setPaused(next) {
41
+ if (next.waiting === paused.waiting && next.attention === paused.attention) return;
42
+ paused = next; notify();
43
+ },
44
+ };
45
+ }
46
+
47
+ export function useLive(live: Live): number {
48
+ return useSyncExternalStore(live.subscribe, live.version, live.version);
49
+ }
@@ -0,0 +1,94 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
+ import { AccessibilityInfo, ActivityIndicator, Animated, Easing, Platform, Pressable, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
3
+
4
+ // The native driver is unavailable on web, where react-native-web animates in JS.
5
+ const native = () => Platform.OS !== "web";
6
+ // Built on first use, so importing this module needs nothing from react-native.
7
+ const out = () => Easing.bezier(0.16, 1, 0.3, 1);
8
+
9
+ export function useReducedMotion(): boolean {
10
+ const [reduced, setReduced] = useState(false);
11
+ useEffect(() => {
12
+ let live = true;
13
+ void AccessibilityInfo.isReduceMotionEnabled().then(value => { if (live) setReduced(value); }).catch(() => undefined);
14
+ const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", setReduced);
15
+ return () => { live = false; subscription.remove(); };
16
+ }, []);
17
+ return reduced;
18
+ }
19
+
20
+ type PressState = { pressed: boolean; hovered: boolean };
21
+ /** A pressable that eases to 97% while held. Hover and press tint come from the caller's style function. */
22
+ export function PressScale({ style, outerStyle, children, ...props }: Omit<PressableProps, "style" | "children"> & {
23
+ style?: StyleProp<ViewStyle> | ((state: PressState) => StyleProp<ViewStyle>);
24
+ outerStyle?: StyleProp<ViewStyle>;
25
+ children: ReactNode | ((state: PressState) => ReactNode);
26
+ }) {
27
+ const reduced = useReducedMotion();
28
+ const scale = useRef(new Animated.Value(1)).current;
29
+ const [pressed, setPressed] = useState(false);
30
+ const [hovered, setHovered] = useState(false);
31
+ const to = (value: number) => { if (!reduced) Animated.timing(scale, { toValue: value, duration: 140, easing: out(), useNativeDriver: native() }).start(); };
32
+ const state = { pressed, hovered };
33
+ return <Pressable {...props} style={outerStyle}
34
+ onHoverIn={event => { setHovered(true); props.onHoverIn?.(event); }}
35
+ onHoverOut={event => { setHovered(false); props.onHoverOut?.(event); }}
36
+ onPressIn={event => { setPressed(true); to(0.97); props.onPressIn?.(event); }}
37
+ onPressOut={event => { setPressed(false); to(1); props.onPressOut?.(event); }}>
38
+ <Animated.View style={[typeof style === "function" ? style(state) : style, { transform: [{ scale }] }]}>
39
+ {typeof children === "function" ? children(state) : children}
40
+ </Animated.View>
41
+ </Pressable>;
42
+ }
43
+
44
+ /** Cross-fades an icon when its key changes: the new one grows in from 40%. Nothing moves on first render. */
45
+ export function IconSwap({ swapKey, size, children }: { swapKey: string; size: number; children: ReactNode }) {
46
+ const reduced = useReducedMotion();
47
+ const enter = useRef(new Animated.Value(1)).current;
48
+ const first = useRef(true);
49
+ useLayoutEffect(() => {
50
+ if (first.current) { first.current = false; return; }
51
+ if (reduced) return;
52
+ enter.setValue(0);
53
+ const animation = Animated.timing(enter, { toValue: 1, duration: 260, easing: out(), useNativeDriver: native() });
54
+ animation.start();
55
+ return () => animation.stop();
56
+ }, [swapKey]);
57
+ return <Animated.View style={{ width: size, height: size, alignItems: "center", justifyContent: "center", opacity: enter, transform: [{ scale: enter.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) }] }}>{children}</Animated.View>;
58
+ }
59
+
60
+ /** Content that arrives after load rises 6px into place. Reduced motion shows it at once. */
61
+ export function Reveal({ children, delay = 0 }: { children: ReactNode; delay?: number }) {
62
+ const reduced = useReducedMotion();
63
+ const value = useRef(new Animated.Value(reduced ? 1 : 0)).current;
64
+ useEffect(() => {
65
+ if (reduced) { value.setValue(1); return; }
66
+ const animation = Animated.timing(value, { toValue: 1, duration: 280, delay, easing: out(), useNativeDriver: native() });
67
+ animation.start();
68
+ return () => animation.stop();
69
+ }, [reduced]);
70
+ return <Animated.View style={{ opacity: value, transform: [{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [6, 0] }) }] }}>{children}</Animated.View>;
71
+ }
72
+
73
+ /** The system spinner, sized to sit beside a line of text. */
74
+ export function Spinner({ color, size = 12, label = "Working" }: { color: string; size?: number; label?: string }) {
75
+ return <View accessibilityLabel={label} style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
76
+ {Platform.OS === "ios" ? <ActivityIndicator size="small" color={color} style={{ transform: [{ scale: size / 20 }] }} /> : <ActivityIndicator size={size} color={color} />}
77
+ </View>;
78
+ }
79
+
80
+ /** A soft breathing placeholder bar for loading states. Static under reduced motion. */
81
+ export function Skeleton({ width, height = 10, color, radius = 4 }: { width: number | `${number}%`; height?: number; color: string; radius?: number }) {
82
+ const reduced = useReducedMotion();
83
+ const value = useRef(new Animated.Value(0.55)).current;
84
+ useEffect(() => {
85
+ if (reduced) return;
86
+ const loop = Animated.loop(Animated.sequence([
87
+ Animated.timing(value, { toValue: 1, duration: 800, easing: Easing.inOut(Easing.ease), useNativeDriver: native() }),
88
+ Animated.timing(value, { toValue: 0.55, duration: 800, easing: Easing.inOut(Easing.ease), useNativeDriver: native() }),
89
+ ]));
90
+ loop.start();
91
+ return () => loop.stop();
92
+ }, [reduced]);
93
+ return <Animated.View style={{ width, height, borderRadius: radius, backgroundColor: color, opacity: value }} />;
94
+ }