paseo-smart-session 2.0.0 → 2.1.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 +35 -139
- package/assets/LICENSE.lucide +43 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/client/agents.ts +178 -0
- package/client/guard/index.tsx +50 -0
- package/client/guard/paused.tsx +77 -0
- package/client/guard/pill-icons.tsx +26 -0
- package/client/guard/pills.ts +122 -0
- package/client/guard/settings.tsx +114 -0
- package/client/guard/surfaces.ts +2 -0
- package/client/guard/timeline.tsx +94 -0
- package/client/navigation.tsx +1 -1
- package/client/pill.tsx +12 -124
- package/client/surface.tsx +87 -2
- package/docs/session-guard.md +88 -0
- package/index.client.tsx +8 -3
- package/index.server.ts +5 -6
- package/mcp.mjs +21 -2
- package/package.json +2 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/client.ts +27 -0
- package/server/guard/engine.ts +554 -0
- package/server/guard/index.ts +395 -0
- package/server/guard/labels.ts +77 -0
- package/server/guard/processes.ts +147 -0
- package/server/guard/standalone.ts +61 -0
- package/server/guard/store.ts +113 -0
- package/server/guard/timeline.ts +24 -0
- package/server/guard/usage.ts +57 -0
- package/server/recorder.ts +2 -0
- package/server/store.ts +25 -1
- package/shared/guard/contracts.ts +65 -0
- package/shared/guard/policy.ts +128 -0
- package/shared/guard/presentation.ts +85 -0
- package/shared/guard/timeline.ts +34 -0
- package/shared/lifecycle.ts +18 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Session Guard pill: one per live session, beside Smart Compact's.
|
|
3
|
+
*
|
|
4
|
+
* Drawn only while this plugin is the host's Session Guard governor. Off, there is
|
|
5
|
+
* nothing for it to say; deferring to the standalone plugin, that plugin draws its own,
|
|
6
|
+
* and two shields on one composer would contradict each other. While the feature is
|
|
7
|
+
* being turned off, a session that still has a pause keeps its pill so the pause can
|
|
8
|
+
* be released from where it is.
|
|
9
|
+
*/
|
|
10
|
+
import type { PluginButtonIcon, PluginClientContext } from "@getpaseo/plugin/client";
|
|
11
|
+
import type { AgentDirectory } from "../agents.ts";
|
|
12
|
+
import { GUARD_SURFACES } from "./surfaces.ts";
|
|
13
|
+
import { cancelResumeRpc, setProtectionRpc, statusRpc } from "../../shared/guard/contracts.ts";
|
|
14
|
+
import { isPending, protects, type Pause, type Policy } from "../../shared/guard/policy.ts";
|
|
15
|
+
|
|
16
|
+
export function pillPresentation(policy: Policy, enabled: boolean, pause: Pause | undefined) {
|
|
17
|
+
if (!policy.enabled) return { label: "Guard off", title: "Session Guard is disabled on this host", icon: "ShieldOff" };
|
|
18
|
+
if (!enabled) return { label: "Guard off", title: "This session is excluded from automatic quota protection", icon: "ShieldOff" };
|
|
19
|
+
if (pause?.phase === "attention") return { label: "Check session", title: pause.note ?? "Check this session before continuing", icon: "TriangleAlert" };
|
|
20
|
+
if (pause) {
|
|
21
|
+
if (pause.demo) return { label: "Paused · demo", title: "This test session is paused. Try Resume now in its timeline; no provider quota was changed.", icon: "Pause" };
|
|
22
|
+
const date = pause.anchorResetsAt ? new Date(pause.anchorResetsAt) : null;
|
|
23
|
+
const time = date && Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null;
|
|
24
|
+
const resetDetail = date ? ` Expected reset ${date.toLocaleString([], { dateStyle: "short", timeStyle: "short" })}.` : " Reset time is not available yet.";
|
|
25
|
+
return { label: time ? `Paused · ${time}` : "Paused · awaiting reset", title: (policy.autoResume
|
|
26
|
+
? `Paused at the session limit${pause.suspension ? ", including native subagents" : ""}. Automatic continuation is scheduled after reset.`
|
|
27
|
+
: pause.suspension ? "Session and native subagents are paused. Release and turn off to continue manually; automatic continuation is off."
|
|
28
|
+
: "Paused at the session limit. Continue manually after reset; automatic continuation is off.") + resetDetail, icon: "Pause" };
|
|
29
|
+
}
|
|
30
|
+
return { label: "Session Guard", title: policy.autoResume
|
|
31
|
+
? `Pause at ${policy.thresholdPct}% session usage and continue after reset`
|
|
32
|
+
: `Pause at ${policy.thresholdPct}% session usage; continue manually after reset`, icon: "ShieldCheck" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How often a client re-reads Session Guard status while it is on, and while it is off. */
|
|
36
|
+
const ACTIVE_POLL_MS = 5_000;
|
|
37
|
+
const IDLE_POLL_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
const wakers = new Set<() => void>();
|
|
40
|
+
/**
|
|
41
|
+
* Re-reads status now. The settings screens call it whenever the Session Guard
|
|
42
|
+
* document changes — their `useSettings` hears changes from every client — so pills
|
|
43
|
+
* follow a switch without polling hard while the feature is off.
|
|
44
|
+
*/
|
|
45
|
+
export function refreshGuardPills(): void {
|
|
46
|
+
for (const wake of [...wakers]) wake();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function contributePills(client: PluginClientContext, agents: AgentDirectory, icons: Readonly<Record<string, PluginButtonIcon>> = {}) {
|
|
50
|
+
let stopped = false;
|
|
51
|
+
let running = false;
|
|
52
|
+
let status: Awaited<ReturnType<typeof readStatus>> | null = null;
|
|
53
|
+
const handles = new Map<string, { workspaceId: string; signature: string; registration: ReturnType<PluginClientContext["addComposerPill"]> }>();
|
|
54
|
+
const readStatus = () => client.rpc(statusRpc, {});
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
56
|
+
const refresh = async () => {
|
|
57
|
+
if (stopped || running) return;
|
|
58
|
+
running = true;
|
|
59
|
+
if (timer !== null) clearTimeout(timer);
|
|
60
|
+
timer = null;
|
|
61
|
+
try { const next = await readStatus(); if (!stopped) { status = next; reconcile(); } }
|
|
62
|
+
catch { /* Keep the last confirmed presentation across a brief disconnect. */ }
|
|
63
|
+
finally {
|
|
64
|
+
running = false;
|
|
65
|
+
// Off here and nobody governing from here: nothing to draw, so check rarely.
|
|
66
|
+
const idle = status !== null && !status.policy.enabled && status.governor === "off";
|
|
67
|
+
if (!stopped) timer = setTimeout(() => void refresh(), idle ? IDLE_POLL_MS : ACTIVE_POLL_MS);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
function reconcile() {
|
|
71
|
+
if (stopped || !status) return;
|
|
72
|
+
const sessions = new Map<string, string>();
|
|
73
|
+
if (status.governor === "active") {
|
|
74
|
+
for (const [agentId, agent] of agents.current()) {
|
|
75
|
+
if (agent.closed) continue;
|
|
76
|
+
if (status.policy.enabled || status.pauses.some(p => p.agentId === agentId && isPending(p))) sessions.set(agentId, agent.workspaceId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [id, handle] of handles) {
|
|
80
|
+
if (sessions.get(id) !== handle.workspaceId) { handle.registration.remove(); handles.delete(id); }
|
|
81
|
+
}
|
|
82
|
+
for (const [agentId, workspaceId] of sessions) {
|
|
83
|
+
const enabled = protects(status.policy, status.overrides, agentId);
|
|
84
|
+
const pause = status.pauses.find(p => p.agentId === agentId && isPending(p));
|
|
85
|
+
const presentation = pillPresentation(status.policy, enabled, pause);
|
|
86
|
+
const signature = JSON.stringify([presentation, enabled, agentId in status.overrides, pause?.id]);
|
|
87
|
+
const handle = handles.get(agentId);
|
|
88
|
+
// Preserve the host menu while polling or receiving agent updates.
|
|
89
|
+
// Replacing behavior on every read remounts an open adaptive menu.
|
|
90
|
+
if (handle?.signature === signature) continue;
|
|
91
|
+
const button = {
|
|
92
|
+
...presentation,
|
|
93
|
+
icon: icons[presentation.icon] ?? presentation.icon,
|
|
94
|
+
behavior: { kind: "menu" as const, items: [
|
|
95
|
+
{ kind: "item" as const, id: "toggle", title: enabled ? "Turn off for this session" : "Protect this session", icon: enabled ? "ShieldOff" : "ShieldCheck",
|
|
96
|
+
behavior: { kind: "action" as const, onPress: async () => { await client.rpc(setProtectionRpc, { agentId, enabled: !enabled }); await refresh(); } } },
|
|
97
|
+
{ kind: "item" as const, id: "default", title: "Use host default", visible: agentId in status.overrides,
|
|
98
|
+
behavior: { kind: "action" as const, onPress: async () => { await client.rpc(setProtectionRpc, { agentId, enabled: null }); await refresh(); } } },
|
|
99
|
+
{ kind: "item" as const, id: "cancel", title: pause?.suspension ? "Release and turn off for this session" : "Cancel automatic continuation", visible: !!pause,
|
|
100
|
+
behavior: { kind: "action" as const, onPress: async () => { await client.rpc(cancelResumeRpc, { agentId }); await refresh(); } } },
|
|
101
|
+
{ kind: "separator" as const, id: "separator" },
|
|
102
|
+
{ kind: "item" as const, id: "paused-sessions", title: "All paused sessions", icon: "Pause",
|
|
103
|
+
behavior: { kind: "action" as const, onPress: () => { client.openSurface(GUARD_SURFACES.paused); } } },
|
|
104
|
+
{ kind: "item" as const, id: "settings", title: "Session Guard settings", icon: "Settings",
|
|
105
|
+
behavior: { kind: "action" as const, onPress: () => { client.openSurface(GUARD_SURFACES.settings); } } },
|
|
106
|
+
] },
|
|
107
|
+
};
|
|
108
|
+
if (handle) { handle.registration.update(button); handle.signature = signature; }
|
|
109
|
+
else handles.set(agentId, { workspaceId, signature, registration: client.addComposerPill({ id: "session-guard", workspaceId, agentId, button }) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const unsubscribe = agents.subscribe(reconcile);
|
|
113
|
+
const wake = () => void refresh();
|
|
114
|
+
wakers.add(wake);
|
|
115
|
+
void refresh();
|
|
116
|
+
return () => {
|
|
117
|
+
stopped = true; wakers.delete(wake); unsubscribe();
|
|
118
|
+
if (timer !== null) clearTimeout(timer);
|
|
119
|
+
for (const handle of handles.values()) handle.registration.remove();
|
|
120
|
+
handles.clear();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -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,94 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
4
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
5
|
+
import { timelinePresentation, type TimelineData } from "../../shared/guard/timeline.ts";
|
|
6
|
+
import { dismissPauseRpc, resumeNowRpc, statusRpc } from "../../shared/guard/contracts.ts";
|
|
7
|
+
import type { Pause } from "../../shared/guard/policy.ts";
|
|
8
|
+
|
|
9
|
+
export function GuardTimelineItem({ theme, agentId, item, openOverview }: PluginTimelineItemProps<TimelineData> & { openOverview(): void }) {
|
|
10
|
+
const readStatus = useRpc(statusRpc);
|
|
11
|
+
const resumeNow = useRpc(resumeNowRpc);
|
|
12
|
+
const dismissPause = useRpc(dismissPauseRpc);
|
|
13
|
+
const [pause, setPause] = useState<Pause | null>(null);
|
|
14
|
+
const [loaded, setLoaded] = useState(false);
|
|
15
|
+
const [action, setAction] = useState<"resume" | "dismiss" | null>(null);
|
|
16
|
+
const busy = action !== null;
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [statusError, setStatusError] = useState<string | null>(null);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!["paused", "recovery"].includes(item.data.event)) return;
|
|
21
|
+
setPause(null); setLoaded(false); setError(null); setStatusError(null);
|
|
22
|
+
let stopped = false, pending = false;
|
|
23
|
+
const refresh = async () => {
|
|
24
|
+
if (pending || stopped) return;
|
|
25
|
+
pending = true;
|
|
26
|
+
try {
|
|
27
|
+
const status = await readStatus({});
|
|
28
|
+
if (!stopped) { setPause(status.pauses.find(p => p.id === item.data.pauseId && p.agentId === agentId) ?? null); setLoaded(true); setStatusError(null); }
|
|
29
|
+
} catch { if (!stopped) setStatusError("Could not check this pause. Retrying automatically."); }
|
|
30
|
+
finally { pending = false; }
|
|
31
|
+
};
|
|
32
|
+
void refresh(); const timer = setInterval(() => void refresh(), 5000);
|
|
33
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
34
|
+
}, [readStatus, agentId, item.data.event, item.data.pauseId]);
|
|
35
|
+
const act = async (kind: "resume" | "dismiss") => {
|
|
36
|
+
if (busy) return;
|
|
37
|
+
setAction(kind); setError(null);
|
|
38
|
+
try {
|
|
39
|
+
await (kind === "resume" ? resumeNow : dismissPause)({ agentId, pauseId: item.data.pauseId });
|
|
40
|
+
const status = await readStatus({});
|
|
41
|
+
setPause(status.pauses.find(p => p.id === item.data.pauseId && p.agentId === agentId) ?? null);
|
|
42
|
+
} catch { setError(kind === "resume" ? "Resume could not be confirmed. Check the session before trying again."
|
|
43
|
+
: "Dismissal could not be confirmed. Check the pause status; you can retry dismissal without sending a message."); }
|
|
44
|
+
finally { setAction(null); }
|
|
45
|
+
};
|
|
46
|
+
if (pause?.phase === "cancelled" && pause.dismissed && ["paused", "recovery"].includes(item.data.event)) return <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8, padding: 14 }}>
|
|
47
|
+
<Icon name="ShieldOff" size={16} color={theme.colors.foregroundMuted} />
|
|
48
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Pause dismissed · no message sent · protection off</Text>
|
|
49
|
+
</View>;
|
|
50
|
+
const presentation = timelinePresentation(item.data);
|
|
51
|
+
const color = presentation.tone === "warning" ? theme.colors.statusWarning : presentation.tone === "success" ? theme.colors.statusSuccess
|
|
52
|
+
: presentation.tone === "danger" ? theme.colors.statusDanger : theme.colors.foregroundMuted;
|
|
53
|
+
return <View style={{ borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, backgroundColor: theme.colors.surface1, padding: 14, gap: 10 }}>
|
|
54
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
55
|
+
<Icon name={presentation.icon} size={18} color={color} />
|
|
56
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 14, fontWeight: "600", flex: 1 }}>{presentation.title}</Text>
|
|
57
|
+
</View>
|
|
58
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 }}>{presentation.detail}</Text>
|
|
59
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{new Date(item.data.at).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}</Text>
|
|
60
|
+
{["paused", "recovery"].includes(item.data.event) && <View style={{ gap: 6 }}>
|
|
61
|
+
{!loaded && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Checking pause status…</Text>}
|
|
62
|
+
{pause?.phase === "waiting" && <>
|
|
63
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
64
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Resume now" accessibilityState={{ disabled: busy, busy: action === "resume" }} disabled={busy} onPress={() => void act("resume")} style={({ pressed }) => ({ alignSelf: "flex-start", minHeight: 44, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, opacity: busy || pressed ? 0.6 : 1 })}>
|
|
65
|
+
<Icon name="Play" size={16} color={theme.colors.statusSuccess} />
|
|
66
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600" }}>{action === "resume" ? "Resuming…" : "Resume now"}</Text>
|
|
67
|
+
</Pressable>
|
|
68
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Dismiss pause without sending a message" accessibilityState={{ disabled: busy, busy: action === "dismiss" }} disabled={busy} onPress={() => void act("dismiss")} style={({ pressed }) => ({ alignSelf: "flex-start", minHeight: 44, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, opacity: busy || pressed ? 0.6 : 1 })}>
|
|
69
|
+
<Icon name="ShieldOff" size={16} color={theme.colors.foregroundMuted} />
|
|
70
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 13 }}>{action === "dismiss" ? "Dismissing…" : "Dismiss pause"}</Text>
|
|
71
|
+
</Pressable>
|
|
72
|
+
</View>
|
|
73
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{item.data.demo
|
|
74
|
+
? "Releases this test session and turns its protection off."
|
|
75
|
+
: "Turns protection off for this session. The provider may still reject work until reset."}</Text>
|
|
76
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Dismiss cancels automatic continuation without sending a message. Existing workers are released and may continue their current work.</Text>
|
|
77
|
+
</>}
|
|
78
|
+
{loaded && pause?.phase === "resumed" && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{pause.manualResume ? "Resumed · protection off for this session" : "This pause has already resumed."}</Text>}
|
|
79
|
+
{loaded && (!pause || pause.phase === "cancelled") && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>This pause is no longer active.</Text>}
|
|
80
|
+
{pause?.phase === "resuming" && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Resuming session…</Text>}
|
|
81
|
+
{pause?.phase === "attention" && <>
|
|
82
|
+
<Text style={{ color: theme.colors.statusDanger, fontSize: 12 }}>{pause.dismissed ? "Worker release needs attention. Automatic continuation is cancelled." : "Check the session before continuing manually."}</Text>
|
|
83
|
+
{pause.dismissed && <Pressable accessibilityRole="button" accessibilityLabel="Retry dismissing pause without sending a message" disabled={busy} accessibilityState={{ disabled: busy, busy: action === "dismiss" }} onPress={() => void act("dismiss")} style={{ minHeight: 44, justifyContent: "center" }}>
|
|
84
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 13, textDecorationLine: "underline" }}>{action === "dismiss" ? "Dismissing…" : "Retry dismissal"}</Text>
|
|
85
|
+
</Pressable>}
|
|
86
|
+
</>}
|
|
87
|
+
{error && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger, fontSize: 12 }}>{error}</Text>}
|
|
88
|
+
{statusError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger, fontSize: 12 }}>{statusError}</Text>}
|
|
89
|
+
</View>}
|
|
90
|
+
<Pressable accessibilityRole="button" accessibilityLabel="See all paused sessions" onPress={openOverview} style={({ pressed }) => ({ alignSelf: "flex-start", minHeight: 44, justifyContent: "center", paddingVertical: 8, paddingHorizontal: 2, opacity: pressed ? 0.7 : 1 })}>
|
|
91
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 13, textDecorationLine: "underline" }}>See all paused sessions</Text>
|
|
92
|
+
</Pressable>
|
|
93
|
+
</View>;
|
|
94
|
+
}
|
package/client/navigation.tsx
CHANGED
|
@@ -6,7 +6,7 @@ export function withNavigation<P extends PluginSurfaceProps>(Component: Componen
|
|
|
6
6
|
return function PluginView(props: P) {
|
|
7
7
|
return <View style={{ flex: 1, backgroundColor: props.theme.colors.surface0 }}>
|
|
8
8
|
<View accessibilityRole="toolbar" style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, padding: 8, borderBottomWidth: 1, borderColor: props.theme.colors.border }}>
|
|
9
|
-
{[{ id: "overview", title: "Overview", target: home }, { id: "display", title: "Display settings", target: "display-settings" }].map(item => <Pressable key={item.id} accessibilityRole="button" accessibilityState={{ selected: active === item.id }} onPress={() => client.openSurface(item.target)} style={({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, justifyContent: "center", borderRadius: 6, backgroundColor: pressed || active === item.id ? props.theme.colors.surface2 : "transparent" })}><Text style={{ color: active === item.id ? props.theme.colors.foreground : props.theme.colors.foregroundMuted, fontSize: 13, fontWeight: active === item.id ? "600" : "400" }}>{item.title}</Text></Pressable>)}
|
|
9
|
+
{[{ id: "overview", title: "Overview", target: home }, { id: "session-guard", title: "Session Guard", target: "session-guard" }, { id: "display", title: "Display settings", target: "display-settings" }].map(item => <Pressable key={item.id} accessibilityRole="button" accessibilityState={{ selected: active === item.id }} onPress={() => client.openSurface(item.target)} style={({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, justifyContent: "center", borderRadius: 6, backgroundColor: pressed || active === item.id ? props.theme.colors.surface2 : "transparent" })}><Text style={{ color: active === item.id ? props.theme.colors.foreground : props.theme.colors.foregroundMuted, fontSize: 13, fontWeight: active === item.id ? "600" : "400" }}>{item.title}</Text></Pressable>)}
|
|
10
10
|
</View>
|
|
11
11
|
<Component {...props} />
|
|
12
12
|
</View>;
|
package/client/pill.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { Text, View } from "react-native";
|
|
|
24
24
|
|
|
25
25
|
import { smartCompactLabel } from "../shared/format";
|
|
26
26
|
import { enrolmentState, setEnrolment, type EnrolmentState } from "../shared/governor";
|
|
27
|
+
import type { AgentDirectory } from "./agents";
|
|
27
28
|
|
|
28
29
|
/** How often the client re-reads enrolment, for changes made somewhere else. */
|
|
29
30
|
const REFRESH_MS = 20_000;
|
|
@@ -197,29 +198,6 @@ function AutoCompactIcon({
|
|
|
197
198
|
return <Icon name="FoldVertical" size={size} color={color} />;
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
/** The lean shapes this needs from the agent directory, which carries far more. */
|
|
201
|
-
interface ListedAgent {
|
|
202
|
-
readonly id?: string;
|
|
203
|
-
readonly workspaceId?: string | null;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
interface AgentUpdate {
|
|
207
|
-
readonly kind: "remove" | "upsert";
|
|
208
|
-
readonly agentId?: string;
|
|
209
|
-
readonly agent?: ListedAgent;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
interface AgentListing {
|
|
213
|
-
readonly entries?: readonly { readonly agent?: ListedAgent }[];
|
|
214
|
-
readonly subscription?: {
|
|
215
|
-
subscribe(observer: {
|
|
216
|
-
snapshot(listing: AgentListing): void;
|
|
217
|
-
update(message: { readonly type?: string; readonly payload?: AgentUpdate }): void;
|
|
218
|
-
}): () => void;
|
|
219
|
-
release(): Promise<void>;
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
201
|
/**
|
|
224
202
|
* The published 0.8 descriptor API returns this handle. The 0.8.0-beta.1 npm
|
|
225
203
|
* package and app still return the legacy remover function, so registrations
|
|
@@ -242,13 +220,9 @@ function updatePill(handle: PillHandle, title: string): void {
|
|
|
242
220
|
if (typeof handle !== "function") handle.update({ title });
|
|
243
221
|
}
|
|
244
222
|
|
|
245
|
-
export function contributeClient(client: PluginClientContext) {
|
|
223
|
+
export function contributeClient(client: PluginClientContext, agents: AgentDirectory) {
|
|
246
224
|
/** Every agent this app knows of, and its pill registration while one is up. */
|
|
247
225
|
const tracked = new Map<string, { workspaceId: string; handle: PillHandle | null }>();
|
|
248
|
-
const lifetime = new AbortController();
|
|
249
|
-
let stopped = false;
|
|
250
|
-
let unsubscribeOwnedObservation: (() => void) | null = null;
|
|
251
|
-
let releaseAgentObservation: (() => Promise<void>) | null = null;
|
|
252
226
|
|
|
253
227
|
function register(agentId: string, entry: { workspaceId: string; handle: PillHandle | null }): void {
|
|
254
228
|
const toggle = async () => {
|
|
@@ -321,114 +295,28 @@ export function contributeClient(client: PluginClientContext) {
|
|
|
321
295
|
tracked.delete(agentId);
|
|
322
296
|
}
|
|
323
297
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const agent = update.agent;
|
|
330
|
-
if (
|
|
331
|
-
typeof agent?.id !== "string" ||
|
|
332
|
-
typeof agent.workspaceId !== "string" ||
|
|
333
|
-
agent.workspaceId === ""
|
|
334
|
-
) return;
|
|
335
|
-
track(agent.id, agent.workspaceId);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function trackAgents(entries: AgentListing["entries"]): Set<string> {
|
|
339
|
-
const present = new Set<string>();
|
|
340
|
-
for (const entry of entries ?? []) {
|
|
341
|
-
const agent = entry.agent;
|
|
342
|
-
if (
|
|
343
|
-
typeof agent?.id !== "string" ||
|
|
344
|
-
typeof agent.workspaceId !== "string" ||
|
|
345
|
-
agent.workspaceId === ""
|
|
346
|
-
) continue;
|
|
347
|
-
present.add(agent.id);
|
|
348
|
-
track(agent.id, agent.workspaceId);
|
|
349
|
-
}
|
|
350
|
-
return present;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** A restored observation replaces its directory snapshot, including removals. */
|
|
354
|
-
function replaceAgents(entries: AgentListing["entries"]): void {
|
|
355
|
-
const present = trackAgents(entries);
|
|
356
|
-
for (const agentId of [...tracked.keys()]) {
|
|
357
|
-
if (!present.has(agentId)) forget(agentId);
|
|
298
|
+
/** Follows the shared directory: new agents gain a pill, moved or removed ones lose theirs. */
|
|
299
|
+
function followDirectory(): void {
|
|
300
|
+
const current = agents.current();
|
|
301
|
+
for (const [agentId, entry] of [...tracked]) {
|
|
302
|
+
if (current.get(agentId)?.workspaceId !== entry.workspaceId) forget(agentId);
|
|
358
303
|
}
|
|
304
|
+
for (const [agentId, agent] of current) track(agentId, agent.workspaceId);
|
|
359
305
|
}
|
|
360
306
|
|
|
361
307
|
const refresh = () => refreshPills(() => client.rpc(enrolmentState, {}));
|
|
362
308
|
|
|
363
309
|
const unsubscribeStore = subscribe(sync);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
// local listener. Keep it attached until a newer runtime gives us the owned
|
|
367
|
-
// observation whose snapshots can reconcile the whole directory on reconnect.
|
|
368
|
-
let unsubscribeLegacyAgents: (() => void) | null = client.paseo.agents.subscribe((update) => {
|
|
369
|
-
applyAgentUpdate(update as AgentUpdate);
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// The subscription only carries agents that report in from now on, and an idle
|
|
373
|
-
// one may not do that for hours.
|
|
374
|
-
void (async () => {
|
|
375
|
-
try {
|
|
376
|
-
// `agents.subscribe()` listens only to observations already owned by
|
|
377
|
-
// this API instance. A plain list stopped receiving newly created agents
|
|
378
|
-
// in Paseo 0.9, which made both plugin pills disappear on new sessions.
|
|
379
|
-
const listAgents = client.paseo.agents.list as unknown as (options: {
|
|
380
|
-
subscribe: Record<string, never>;
|
|
381
|
-
signal: AbortSignal;
|
|
382
|
-
}) => Promise<AgentListing>;
|
|
383
|
-
const listing = await listAgents({ subscribe: {}, signal: lifetime.signal });
|
|
384
|
-
const release = listing.subscription
|
|
385
|
-
? () => listing.subscription!.release()
|
|
386
|
-
: null;
|
|
387
|
-
if (stopped) {
|
|
388
|
-
await release?.();
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
releaseAgentObservation = release;
|
|
392
|
-
// The legacy listener was already live while this snapshot was in flight,
|
|
393
|
-
// so bootstrap must be additive or it can erase a just-created agent.
|
|
394
|
-
trackAgents(listing.entries);
|
|
395
|
-
if (listing.subscription) {
|
|
396
|
-
unsubscribeOwnedObservation = listing.subscription.subscribe({
|
|
397
|
-
snapshot(snapshot) {
|
|
398
|
-
if (!stopped) replaceAgents(snapshot.entries);
|
|
399
|
-
},
|
|
400
|
-
update(message) {
|
|
401
|
-
if (!stopped && message.type === "agent_update" && message.payload) {
|
|
402
|
-
applyAgentUpdate(message.payload);
|
|
403
|
-
}
|
|
404
|
-
},
|
|
405
|
-
});
|
|
406
|
-
unsubscribeLegacyAgents?.();
|
|
407
|
-
unsubscribeLegacyAgents = null;
|
|
408
|
-
}
|
|
409
|
-
} catch {
|
|
410
|
-
const release = releaseAgentObservation;
|
|
411
|
-
releaseAgentObservation = null;
|
|
412
|
-
await release?.().catch(() => undefined);
|
|
413
|
-
// Not fatal to the rest of the client contribution; a reload can retry.
|
|
414
|
-
}
|
|
415
|
-
})();
|
|
310
|
+
const unsubscribeAgents = agents.subscribe(followDirectory);
|
|
311
|
+
followDirectory();
|
|
416
312
|
|
|
417
313
|
void refresh().catch(() => undefined);
|
|
418
314
|
const timer = setInterval(() => void refresh().catch(() => undefined), REFRESH_MS);
|
|
419
315
|
|
|
420
|
-
return
|
|
421
|
-
stopped = true;
|
|
422
|
-
lifetime.abort();
|
|
316
|
+
return () => {
|
|
423
317
|
clearInterval(timer);
|
|
424
|
-
|
|
425
|
-
unsubscribeLegacyAgents = null;
|
|
426
|
-
unsubscribeOwnedObservation?.();
|
|
427
|
-
unsubscribeOwnedObservation = null;
|
|
318
|
+
unsubscribeAgents();
|
|
428
319
|
unsubscribeStore();
|
|
429
320
|
for (const agentId of [...tracked.keys()]) forget(agentId);
|
|
430
|
-
const release = releaseAgentObservation;
|
|
431
|
-
releaseAgentObservation = null;
|
|
432
|
-
await release?.().catch(() => undefined);
|
|
433
321
|
};
|
|
434
322
|
}
|