paseo-smart-session 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +13 -17
- package/index.client.tsx +19 -34
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/index.ts +8 -8
- package/shared/guard/presentation.ts +66 -11
- package/shared/guard/timeline.ts +3 -3
- package/shared/guard/timing.ts +37 -0
- 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/settings.tsx
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
-
import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSwitch, useToast } from "@getpaseo/plugin/client/ui";
|
|
1
|
+
import { useRpc, useSettings, type PluginClientContext, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSelect, SettingsSwitch, useToast } from "@getpaseo/plugin/client/ui";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
5
|
-
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
|
4
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { ActivityIndicator, ScrollView, Text, TextInput, View } from "react-native";
|
|
6
6
|
|
|
7
|
+
import { displayPreferences } from "../shared/display";
|
|
7
8
|
import { formatCompactTokens } from "../shared/format";
|
|
8
9
|
import { enrolmentState } from "../shared/governor";
|
|
10
|
+
import { guardPreferences } from "../shared/guard/contracts";
|
|
11
|
+
import { isPending, type Policy } from "../shared/guard/policy";
|
|
12
|
+
import { checkTiming, timingOf, type Timing } from "../shared/guard/timing";
|
|
13
|
+
import { governorNotice } from "../shared/guard/presentation";
|
|
9
14
|
import { getSettings, installStatus, setSettings } from "../shared/smart-session";
|
|
10
15
|
import { withCompactThreshold } from "../shared/thresholds";
|
|
11
|
-
import {
|
|
16
|
+
import { broadcastDisplay } from "./display";
|
|
17
|
+
import { guardSnapshot, refreshGuard, subscribeGuard } from "./guard/status";
|
|
18
|
+
import { Button, errorText } from "./kit";
|
|
12
19
|
import { refreshPills } from "./pill-store";
|
|
13
20
|
|
|
14
21
|
/** A whole number from 1 to 99, or null. */
|
|
@@ -18,88 +25,221 @@ function percent(value: string): number | null {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
|
-
* Settings → Plugins → Smart Session
|
|
22
|
-
* session
|
|
28
|
+
* Settings → Plugins → Smart Session: every setting in one place, in the order a
|
|
29
|
+
* session meets them. Context first (when to compact), then the limit (pause and
|
|
30
|
+
* continue), then where Smart Session shows up, the Claude Code hooks, and the
|
|
31
|
+
* protection timing nobody needs to touch, folded behind Edit. Live lists of paused
|
|
32
|
+
* sessions and per-session choices live on the overview; this links there.
|
|
23
33
|
*/
|
|
24
|
-
export function
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
export function makeSettings(client: PluginClientContext) {
|
|
35
|
+
const openPaused = () => client.openScreen({ screenId: "overview", params: { focus: "paused" } });
|
|
36
|
+
return function SmartSessionSettings({ theme }: PluginSurfaceProps) {
|
|
37
|
+
const c = theme.colors;
|
|
38
|
+
const toast = useToast();
|
|
39
|
+
const queryClient = useQueryClient();
|
|
40
|
+
const read = useRpc(getSettings);
|
|
41
|
+
const write = useRpc(setSettings);
|
|
42
|
+
const readEnrolment = useRpc(enrolmentState);
|
|
43
|
+
const readInstall = useRpc(installStatus);
|
|
44
|
+
const query = useQuery({ queryKey: ["smart-session", "settings"], queryFn: () => read({}), refetchInterval: 60_000 });
|
|
45
|
+
const install = useQuery({ queryKey: ["smart-session", "install"], queryFn: () => readInstall({}), refetchInterval: 5 * 60_000 });
|
|
46
|
+
const guard = useSettings(guardPreferences);
|
|
47
|
+
const display = useSettings(displayPreferences);
|
|
48
|
+
const status = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot);
|
|
49
|
+
const [saving, setSaving] = useState(false);
|
|
50
|
+
const [draft, setDraft] = useState<{ large: string; small: string } | null>(null);
|
|
51
|
+
const [inputKey, setInputKey] = useState(0);
|
|
52
|
+
const [timing, setTiming] = useState<{ draft: Timing; revision: string } | null>(null);
|
|
53
|
+
const [timingTried, setTimingTried] = useState(false);
|
|
54
|
+
const settings = query.data?.settings;
|
|
55
|
+
useEffect(() => { setDraft(null); }, [settings?.thresholds.large.compact, settings?.thresholds.small.compact]);
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
// The pill and sidebar follow these documents; `useSettings` hears every client's change.
|
|
58
|
+
const guardValues = guard.status === "ready" ? guard.values : undefined;
|
|
59
|
+
useEffect(() => { if (guardValues !== undefined) refreshGuard(); }, [guardValues]);
|
|
60
|
+
const displayValues = display.status === "ready" ? display.values : undefined;
|
|
61
|
+
useEffect(() => { if (displayValues !== undefined) broadcastDisplay(displayValues); }, [displayValues]);
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
if (query.isError && settings === undefined) return <View style={{ paddingVertical: 8, gap: 12 }}>
|
|
64
|
+
<Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{errorText(query.error)}</Text>
|
|
65
|
+
<SettingsCard><SettingsAction label="Load the settings again" actionLabel="Retry" onPress={() => void query.refetch()} /></SettingsCard>
|
|
66
|
+
</View>;
|
|
67
|
+
if (settings === undefined) return <ActivityIndicator accessibilityLabel="Loading Smart Session settings" color={c.foregroundMuted} style={{ padding: 24 }} />;
|
|
68
|
+
|
|
69
|
+
async function save(patch: Parameters<typeof write>[0], message: string) {
|
|
70
|
+
setSaving(true);
|
|
71
|
+
try {
|
|
72
|
+
await write(patch);
|
|
73
|
+
await refreshPills(() => readEnrolment({}));
|
|
74
|
+
await queryClient.invalidateQueries({ queryKey: ["smart-session"] });
|
|
75
|
+
toast.show(message);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
toast.error(errorText(error));
|
|
78
|
+
} finally {
|
|
79
|
+
setSaving(false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function changeGuard(patch: Partial<Policy>, message: string) {
|
|
83
|
+
if (guard.status !== "ready") return;
|
|
84
|
+
void guard.save({ ...guard.values, ...patch }, guard.revision).then((saved) => {
|
|
85
|
+
if (saved) toast.show(message);
|
|
86
|
+
else toast.error("That change couldn't be saved. Try again.");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function setPill(next: boolean) {
|
|
90
|
+
// One switch over two stored preferences: the Smart Compact pill flag and the Display gate.
|
|
91
|
+
if (next && display.status === "ready" && !display.values.enable_composer_button) await display.save({ ...display.values, enable_composer_button: true }, display.revision);
|
|
92
|
+
if (settings!.showPill !== next) await save({ showPill: next }, next ? "Pill shown on every session" : "Pill hidden");
|
|
93
|
+
else toast.show(next ? "Pill shown on every session" : "Pill hidden");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const large = draft === null ? settings.thresholds.large.compact : percent(draft.large);
|
|
97
|
+
const small = draft === null ? settings.thresholds.small.compact : percent(draft.small);
|
|
98
|
+
const changed = draft !== null && large !== null && small !== null && (large !== settings.thresholds.large.compact || small !== settings.thresholds.small.compact);
|
|
99
|
+
const off = !settings.enabled || saving;
|
|
100
|
+
const report = install.data?.report;
|
|
101
|
+
const hooksHint = report === undefined ? "Keeps this plugin's context and compaction hooks in ~/.claude/settings.json."
|
|
102
|
+
: report.error !== null ? report.error
|
|
103
|
+
: report.pluginDir === null || report.hooks.length === 0 ? "Not registered, so nothing will ask a session to compact itself."
|
|
104
|
+
: `Registered: ${report.hooks.join(", ")}. Only entries that point at this plugin are touched.`;
|
|
105
|
+
const from = formatCompactTokens(settings.thresholds.largeWindowFrom);
|
|
106
|
+
|
|
107
|
+
// Protection: the stored document, plus who runs it on this host.
|
|
108
|
+
const policy = guard.status === "ready" ? guard.values : null;
|
|
109
|
+
const notice = status === null ? null : governorNotice(status);
|
|
110
|
+
const elsewhere = notice !== null && status?.governor !== "off" ? notice : null;
|
|
111
|
+
const guardBusy = guard.status !== "ready" || guard.saving || timing !== null;
|
|
112
|
+
const pending = status?.pauses.filter(isPending) ?? [];
|
|
113
|
+
const overrides = status === null ? 0 : Object.keys(status.overrides).length;
|
|
114
|
+
const listHint = status === null ? "Paused sessions and sessions you changed from the default, on the overview."
|
|
115
|
+
: [pending.length === 0 ? "Nothing paused" : `${pending.length} paused`, overrides === 0 ? "no session changed from the default" : `${overrides} ${overrides === 1 ? "session" : "sessions"} changed from the default`].join(" · ");
|
|
116
|
+
const checked = timing === null ? null : checkTiming(timing.draft);
|
|
117
|
+
const fieldError = (key: keyof Timing) => (timingTried ? checked?.errors[key] ?? null : null);
|
|
118
|
+
async function saveTiming() {
|
|
119
|
+
if (timing === null || guard.status !== "ready" || checked === null) return;
|
|
120
|
+
setTimingTried(true);
|
|
121
|
+
if (Object.keys(checked.errors).length > 0) return;
|
|
122
|
+
if (await guard.save({ ...guard.values, ...checked.values }, timing.revision)) {
|
|
123
|
+
setTiming(null); setTimingTried(false);
|
|
124
|
+
toast.show("Limit timing saved");
|
|
125
|
+
}
|
|
57
126
|
}
|
|
58
|
-
|
|
127
|
+
const pillOn = settings.showPill && (display.status !== "ready" || display.values.enable_composer_button);
|
|
128
|
+
|
|
129
|
+
return <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 8, gap: 24 }}>
|
|
130
|
+
<SettingsSection title="Smart Compact" info="A session is never compacted unless it agrees. At the end of a turn past its line, it is asked whether to compact; it can compact, put it off with a reason, or keep working.">
|
|
131
|
+
<SettingsCard>
|
|
132
|
+
<SettingsSwitch label="Smart Compact" hint={settings.enabled ? "On. Context notices, and the question at the compact line." : "Off. Nothing is asked and nothing is compacted."}
|
|
133
|
+
value={settings.enabled} disabled={saving} onValueChange={(enabled) => void save({ enabled }, enabled ? "Smart Compact on" : "Smart Compact off")} />
|
|
134
|
+
<SettingsSwitch label="Enrol sessions automatically" hint="A session that saves its task state with the checkpoint tool is enrolled by that alone. Off, enrol each one from its pill."
|
|
135
|
+
value={settings.autoEnrol} disabled={off} onValueChange={(autoEnrol) => void save({ autoEnrol }, autoEnrol ? "Sessions enrol themselves" : "Sessions are enrolled one at a time")} />
|
|
136
|
+
</SettingsCard>
|
|
137
|
+
</SettingsSection>
|
|
138
|
+
|
|
139
|
+
<SettingsSection title="When to ask" info="What wears a session down is its absolute size, not its share of the window, so large windows are asked much earlier. Earlier context notices move down with the line.">
|
|
140
|
+
<SettingsCard>
|
|
141
|
+
<SettingsInput key={`large-${inputKey}`} label={`Windows of ${from} or more (%)`} hint={`Asked at ${large ?? "—"}%, about ${large === null ? "—" : formatCompactTokens((large / 100) * 1_000_000)} tokens of a 1M window. Default 30.`}
|
|
142
|
+
initialValue={String(settings.thresholds.large.compact)} disabled={off} error={draft !== null && large === null ? "Enter a whole number from 1 to 99." : null}
|
|
143
|
+
onChangeText={(value) => setDraft((previous) => ({ large: value, small: previous?.small ?? String(settings.thresholds.small.compact) }))} />
|
|
144
|
+
<SettingsInput key={`small-${inputKey}`} label="Smaller windows (%)" hint={`Asked at ${small ?? "—"}% of a window under ${from}. Default 85.`}
|
|
145
|
+
initialValue={String(settings.thresholds.small.compact)} disabled={off} error={draft !== null && small === null ? "Enter a whole number from 1 to 99." : null}
|
|
146
|
+
onChangeText={(value) => setDraft((previous) => ({ small: value, large: previous?.large ?? String(settings.thresholds.large.compact) }))} />
|
|
147
|
+
{draft !== null ? <>
|
|
148
|
+
<SettingsAction label="Apply the new lines" hint={changed ? `Large ${large}% · small ${small}%` : "Nothing to apply yet"} actionLabel="Save" disabled={!changed || off}
|
|
149
|
+
onPress={() => void save({ thresholds: { ...settings.thresholds, large: withCompactThreshold(settings.thresholds.large, large!), small: withCompactThreshold(settings.thresholds.small, small!) } }, `Smart Compact asks at ${large}% and ${small}%`)} />
|
|
150
|
+
<SettingsAction label="Keep the saved lines" actionLabel="Discard" disabled={saving} onPress={() => { setDraft(null); setInputKey((k) => k + 1); }} />
|
|
151
|
+
</> : null}
|
|
152
|
+
</SettingsCard>
|
|
153
|
+
</SettingsSection>
|
|
154
|
+
|
|
155
|
+
<SettingsSection title="Pause at the limit" info="When the provider's session window is spent, a running session is paused instead of failing, then continued in the same conversation once the window resets. Formerly the Session Guard plugin; its choices and waiting pauses carry over.">
|
|
156
|
+
{guard.status === "loading" ? <ActivityIndicator accessibilityLabel="Loading Pause at the limit settings" color={c.foregroundMuted} style={{ padding: 16 }} />
|
|
157
|
+
: guard.status !== "ready" ? <View style={{ gap: 8 }}>
|
|
158
|
+
<Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{guard.error}</Text>
|
|
159
|
+
<SettingsCard>
|
|
160
|
+
<SettingsAction label="Load the saved settings again" actionLabel="Retry" onPress={() => void guard.reload()} />
|
|
161
|
+
{guard.status === "invalid" ? <SettingsAction label="Replace the invalid settings with defaults" actionLabel="Reset" disabled={guard.saving} onPress={() => void guard.reset()} /> : null}
|
|
162
|
+
</SettingsCard>
|
|
163
|
+
</View>
|
|
164
|
+
: <SettingsCard>
|
|
165
|
+
{elsewhere ? <SettingsRow label={elsewhere.title} hint={elsewhere.hint} /> : null}
|
|
166
|
+
<SettingsSwitch label="Pause at the limit" hint={guard.values.enabled ? `On. Sessions pause at ${guard.values.thresholdPct}% of the session window. Works while the app is closed.` : "Off until you turn it on. Turning it off lets frozen workers go; waiting continuations hold until you turn it back on or cancel them."}
|
|
167
|
+
value={guard.values.enabled} disabled={guardBusy} onValueChange={(enabled) => changeGuard({ enabled }, enabled ? "Sessions pause at the limit" : "Pause at the limit off")} />
|
|
168
|
+
<SettingsSelect label="Sessions" hint="Change any one session from its pill."
|
|
169
|
+
value={guard.values.protectByDefault ? "all" : "opt-in"} options={[{ label: "Every session", value: "all" }, { label: "Only ones I turn on", value: "opt-in" }]}
|
|
170
|
+
disabled={guardBusy || !guard.values.enabled} onValueChange={(value) => changeGuard({ protectByDefault: value === "all" }, value === "all" ? "Every session pauses at the limit" : "Sessions pause only when you turn them on")} />
|
|
171
|
+
<SettingsSwitch label="Continue after the reset" hint="Waits for a fresh usage reading below the line, then sends the continuation. Off, you continue each one by hand."
|
|
172
|
+
value={guard.values.autoResume} disabled={guardBusy || !guard.values.enabled} onValueChange={(autoResume) => changeGuard({ autoResume }, autoResume ? "Paused sessions continue after the reset" : "Paused sessions wait for you")} />
|
|
173
|
+
<SettingsSwitch label="Include native subagents" hint="On Linux, freeze Claude and Codex sessions together with their nested workers. Off, only the foreground turn is interrupted."
|
|
174
|
+
value={guard.values.includeNativeSubagents} disabled={guardBusy || !guard.values.enabled} onValueChange={(includeNativeSubagents) => changeGuard({ includeNativeSubagents }, includeNativeSubagents ? "Subagents pause too" : "Only the foreground turn pauses")} />
|
|
175
|
+
<SettingsSwitch label="Label paused workspaces" hint="An amber Paused label in the sidebar until every session in the workspace continues."
|
|
176
|
+
value={guard.values.workspaceLabels} disabled={guardBusy || !guard.values.enabled} onValueChange={(workspaceLabels) => changeGuard({ workspaceLabels }, workspaceLabels ? "Paused workspaces are labelled" : "No workspace labels")} />
|
|
177
|
+
<SettingsAction label="Paused sessions and exceptions" hint={listHint} actionLabel="Open" onPress={openPaused} />
|
|
178
|
+
</SettingsCard>}
|
|
179
|
+
</SettingsSection>
|
|
180
|
+
|
|
181
|
+
<SettingsSection title="Display" info="Hiding the row or the pill never turns a feature off. ⌘K Smart Session still opens the overview.">
|
|
182
|
+
{display.status === "loading" ? <ActivityIndicator accessibilityLabel="Loading display settings" color={c.foregroundMuted} style={{ padding: 16 }} />
|
|
183
|
+
: display.status !== "ready" ? <View style={{ gap: 8 }}>
|
|
184
|
+
<Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{display.error}</Text>
|
|
185
|
+
<SettingsCard>
|
|
186
|
+
<SettingsAction label="Load the saved display settings again" actionLabel="Retry" onPress={() => void display.reload()} />
|
|
187
|
+
{display.status === "invalid" ? <SettingsAction label="Replace the invalid display settings with defaults" actionLabel="Reset" disabled={display.saving} onPress={() => void display.reset()} /> : null}
|
|
188
|
+
</SettingsCard>
|
|
189
|
+
</View>
|
|
190
|
+
: <SettingsCard>
|
|
191
|
+
<SettingsSwitch label="Sidebar row" hint="Smart Session in the sidebar, with the session window's use, or how many sessions are paused."
|
|
192
|
+
value={display.values.showSidebar} disabled={display.saving} onValueChange={(showSidebar) => void display.save({ ...display.values, showSidebar }, display.revision)} />
|
|
193
|
+
<SettingsSwitch label="Pill on every session" hint="The context ring above each message box. A paused session keeps its pill either way."
|
|
194
|
+
value={pillOn} disabled={saving || display.saving} onValueChange={(next) => void setPill(next)} />
|
|
195
|
+
</SettingsCard>}
|
|
196
|
+
{display.status === "ready" && display.saveError ? <Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{display.saveError}</Text> : null}
|
|
197
|
+
</SettingsSection>
|
|
59
198
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
: report.pluginDir === null || report.hooks.length === 0 ? "Not registered, so nothing will ask a session to compact itself."
|
|
68
|
-
: `Registered: ${report.hooks.join(", ")}. Only entries that point at this plugin are touched.`;
|
|
69
|
-
const from = formatCompactTokens(settings.thresholds.largeWindowFrom);
|
|
199
|
+
<SettingsSection title="Claude Code">
|
|
200
|
+
<SettingsCard>
|
|
201
|
+
<SettingsSwitch label="Register the hooks" hint={hooksHint} value={settings.installHooks} disabled={off}
|
|
202
|
+
onValueChange={(installHooks) => void save({ installHooks }, installHooks ? "Hooks registered with Claude Code" : "Hooks removed from Claude Code")} />
|
|
203
|
+
{report?.mcp === "unavailable" ? <SettingsRow label="MCP server not registered" hint="The claude command was not reachable from the daemon." /> : null}
|
|
204
|
+
</SettingsCard>
|
|
205
|
+
</SettingsSection>
|
|
70
206
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
207
|
+
{policy === null ? null : <SettingsSection title="Limit timing" info="How Pause at the limit reads usage and continues sessions. The defaults suit almost everyone.">
|
|
208
|
+
<SettingsCard>
|
|
209
|
+
{timing === null ? <SettingsAction label={`Pause at ${policy.thresholdPct}% of the session window`}
|
|
210
|
+
hint={`Check every ${policy.pollSeconds}s · wait ${policy.resetGraceSeconds}s after the reset · ${policy.resumeSpacingSeconds}s between continuations · ${policy.providerIds.length === 0 ? "every provider" : policy.providerIds.join(", ")}`}
|
|
211
|
+
actionLabel="Edit" disabled={guard.status !== "ready" || guard.saving} onPress={() => { if (guard.status === "ready") { setTiming({ draft: timingOf(guard.values), revision: guard.revision }); setTimingTried(false); } }} /> : <>
|
|
212
|
+
<SettingsInput label="Pause at (%)" hint="Of the session window. 1–100, default 100." initialValue={timing.draft.thresholdPct} error={fieldError("thresholdPct")} disabled={guard.saving}
|
|
213
|
+
onChangeText={(value) => setTiming((t) => t && { ...t, draft: { ...t.draft, thresholdPct: value } })} />
|
|
214
|
+
<SettingsInput label="Check every (seconds)" hint="5–300, default 15. Paseo may cache usage; this can't force a provider refresh." initialValue={timing.draft.pollSeconds} error={fieldError("pollSeconds")} disabled={guard.saving}
|
|
215
|
+
onChangeText={(value) => setTiming((t) => t && { ...t, draft: { ...t.draft, pollSeconds: value } })} />
|
|
216
|
+
<SettingsInput label="Wait after the reset (seconds)" hint="0–600, default 30." initialValue={timing.draft.resetGraceSeconds} error={fieldError("resetGraceSeconds")} disabled={guard.saving}
|
|
217
|
+
onChangeText={(value) => setTiming((t) => t && { ...t, draft: { ...t.draft, resetGraceSeconds: value } })} />
|
|
218
|
+
<SettingsInput label="Between continuations (seconds)" hint="0–300, default 5, so paused sessions don't all start at once." initialValue={timing.draft.resumeSpacingSeconds} error={fieldError("resumeSpacingSeconds")} disabled={guard.saving}
|
|
219
|
+
onChangeText={(value) => setTiming((t) => t && { ...t, draft: { ...t.draft, resumeSpacingSeconds: value } })} />
|
|
220
|
+
{/* A message is prose: it gets the full width and several lines, not a one-line field. */}
|
|
221
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 12, gap: 6 }}>
|
|
222
|
+
<Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20 }}>Continuation message</Text>
|
|
223
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Sent once into the same conversation after the reset is confirmed.</Text>
|
|
224
|
+
<TextInput multiline accessibilityLabel="Continuation message" defaultValue={timing.draft.continuation} editable={!guard.saving}
|
|
225
|
+
onChangeText={(value) => setTiming((t) => t && { ...t, draft: { ...t.draft, continuation: value } })}
|
|
226
|
+
style={{ minHeight: 96, marginTop: 4, padding: 10, borderRadius: 8, borderWidth: 1, borderColor: fieldError("continuation") ? c.statusDanger : c.border, backgroundColor: c.surface2, color: c.foreground, fontSize: 13, lineHeight: 19, textAlignVertical: "top" }} />
|
|
227
|
+
{fieldError("continuation") ? <Text style={{ color: c.statusDanger, fontSize: 12, lineHeight: 17 }}>{fieldError("continuation")}</Text> : null}
|
|
228
|
+
</View>
|
|
229
|
+
<SettingsInput label="Providers" hint="Comma separated, e.g. claude, codex. Empty means every provider that reports session usage." initialValue={timing.draft.providerIds} error={fieldError("providerIds")} disabled={guard.saving}
|
|
230
|
+
onChangeText={(value) => setTiming((t) => t && { ...t, draft: { ...t.draft, providerIds: value } })} />
|
|
231
|
+
<SettingsInput label="Session windows" hint="Which usage window counts as the session limit, first match wins: five_hour is Claude's 5-hour window, session is Codex's." initialValue={timing.draft.windowIds} error={fieldError("windowIds")} disabled={guard.saving}
|
|
232
|
+
onChangeText={(value) => setTiming((t) => t && { ...t, draft: { ...t.draft, windowIds: value } })} />
|
|
233
|
+
<SettingsRow label={timingTried && checked && Object.keys(checked.errors).length > 0 ? "Fix the marked fields to save" : "Save the new timing?"}>
|
|
234
|
+
<View style={{ flexDirection: "row", gap: 6 }}>
|
|
235
|
+
<Button theme={theme} small kind="ghost" title="Discard" disabled={guard.saving} onPress={() => { setTiming(null); setTimingTried(false); }} />
|
|
236
|
+
<Button theme={theme} small title={guard.saving ? "Saving…" : "Save"} disabled={guard.saving} onPress={() => void saveTiming()} />
|
|
237
|
+
</View>
|
|
238
|
+
</SettingsRow>
|
|
239
|
+
</>}
|
|
240
|
+
</SettingsCard>
|
|
241
|
+
{guard.status === "ready" && guard.saveError ? <Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{guard.saveError}</Text> : null}
|
|
242
|
+
</SettingsSection>}
|
|
243
|
+
</ScrollView>;
|
|
244
|
+
};
|
|
105
245
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Verified in an isolated Paseo 0.15 daemon with synthetic workspaces, usage history and sessions, using the app's web runtime at 1440×900 and 390×844, light and dark.
|
|
4
4
|
|
|
5
5
|
- The sidebar row **Smart Session** opens the overview screen; its header reads **Smart Session**, not a raw id. The row trails the session window's use, or how many sessions are paused.
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
- **Display** hides the sidebar row or the
|
|
6
|
+
- Paused sessions are a section of the overview, right under its headline. ⌘K **Smart Session: paused sessions**, a pause card, the pill's popover and settings open the overview at that list; the old `paused-sessions` screen id still resolves to it.
|
|
7
|
+
- One settings screen, **Settings → Plugins → Smart Session → Settings** (id `smart-compact`, which the old first page used), opens from the overview's gear, the feature rows, the pill's popover and Command Center.
|
|
8
|
+
- **Display** in that screen hides the sidebar row or the pill; hiding either never turns a feature off, a paused session keeps its pill, and Command Center still opens the overview.
|
|
9
9
|
- Compact settings have no horizontal overflow and keep accessible native switches.
|
|
10
10
|
|
|
11
|
-
Screenshots: [
|
|
11
|
+
Screenshots: [settings](media/smart-session-settings.png), [phone](media/smart-session-mobile.png). Screenshots contain synthetic data. No main daemon restart or real-agent messages were used.
|
package/docs/session-guard.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pause at the limit
|
|
2
2
|
|
|
3
|
-
Session Guard pauses agents at their provider's session limit and continues the same conversations after the window resets, even while the Paseo app is closed. It began as the standalone [paseo-session-guard](https://github.com/tomgrin10/paseo-session-guard) plugin and now ships inside Smart Session. See the [README](../README.md) for installation.
|
|
3
|
+
Pause at the limit (formerly the Session Guard plugin, and still called Session Guard in the code and data paths) pauses agents at their provider's session limit and continues the same conversations after the window resets, even while the Paseo app is closed. It began as the standalone [paseo-session-guard](https://github.com/tomgrin10/paseo-session-guard) plugin and now ships inside Smart Session. See the [README](../README.md) for installation.
|
|
4
4
|
|
|
5
5
|
## Turning it on
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
It is **off by default**. Turn it on with the **Pause at the limit** switch on the Smart Session overview, or in **Settings → Plugins → Smart Session**. Off, it holds no lock, writes nothing and adds nothing to the pill.
|
|
8
8
|
|
|
9
9
|
### Moving from the standalone plugin
|
|
10
10
|
|
|
11
11
|
Smart Session never runs Session Guard while the standalone plugin is configured **and enabled** on the host, even when it could. Released versions of the standalone plugin resume every suspended session in the shared state when they are disabled or removed, whichever plugin paused it, so two enabled copies cannot share a host safely. Both keep their state in `$PASEO_HOME/plugin-data/session-guard/` and take the same `governor.lock` there, so a disabled copy that is still shutting down is waited out too.
|
|
12
12
|
|
|
13
|
-
- While the standalone plugin is enabled, Smart Session's Session Guard pauses nothing.
|
|
13
|
+
- While the standalone plugin is enabled, Smart Session's Session Guard pauses nothing. Settings, the pill's popover and the overview say so in one line, and pauses in the overview's list are marked read-only, with no controls.
|
|
14
14
|
- **Disable or uninstall Session Guard** (**Settings → Plugins**, `paseo plugin disable paseo-session-guard` or `paseo plugin remove paseo-session-guard`). Smart Session takes over on its next check, about 15 seconds later. Per-session choices and waiting pauses carry over, because they are the same file. The standalone plugin releases native workers when it stops; if the quota is still full, Smart Session normally pauses that work again on its first check.
|
|
15
15
|
- Smart Session's settings start from defaults: the standalone plugin's settings belong to that installation and are deleted with it. Re-apply any custom threshold, timing or continuation message.
|
|
16
16
|
- If the standalone plugin is enabled again while Smart Session is protecting the host, Smart Session releases the sessions it suspended, keeps their confirmed waits, gives up the lock and stops. The standalone plugin does not retry its lock, so reload it to let it take over, or disable it again to hand back to Smart Session. Between enabling it and Smart Session's next check (up to the check interval) both are loaded; avoid disabling it again within that window.
|
|
@@ -19,21 +19,17 @@ Turning Session Guard off releases suspended workers immediately. A message cont
|
|
|
19
19
|
|
|
20
20
|
## Use
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
There is no separate pill. Each session's Smart Session pill (the context ring) carries its protection, and says nothing about it while the session is simply protected. Press it for the **Pause at the limit** switch for that session: it shows whether the session follows your default or was changed, with **Use default** to go back. Turning the switch to match the default removes the override.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
- **Use host default**: remove that override.
|
|
26
|
-
- **Cancel automatic continuation**: leave an interrupted turn for you to continue manually. For suspended native workers, **Release and turn off for this session** resumes them immediately and opts the session out, preventing the next check from pausing it again.
|
|
27
|
-
- **Session Guard settings**: configure the host policy and see paused sessions.
|
|
28
|
-
- **All paused sessions**: open the host-wide overview.
|
|
24
|
+
When a session is paused, its pill becomes the pause: an amber pause glyph and the moment it resumes (**Resumes 5:58 PM**, **Paused · reset due**, or **Check session** in red when it needs a look). It stays on the composer while paused even if the pill is hidden in settings. Its popover leads with what happened and when it continues, with **Cancel continuation** (for an interrupted turn) or **Release and turn off** (for frozen native workers, which also opts the session out so the next check doesn't pause it again), and a link to all paused sessions.
|
|
29
25
|
|
|
30
|
-
The
|
|
26
|
+
The Smart Session overview leads with paused sessions while there are any ("2 sessions resume at 5:58 PM"), and lists them right under the headline, each with the session title, provider, whether native subagents are frozen or the turn was interrupted, **Open session** and the release action. Uncertain stops sort first. Search appears when the list is busy. ⌘K **Smart Session: paused sessions**, a pause card and the settings screen all open the overview at this list. Sessions changed from your default are listed under the **Pause at the limit** row, each with **Use default**. While anything is paused, the Smart Session sidebar row shows the count. Paused workspaces get an amber **Paused** label in the left sidebar until the last paused session in them is released. Turn it off with **Label paused workspaces** in settings; other workspace labels stay intact.
|
|
31
27
|
|
|
32
|
-
Confirmed pauses write a **Paused
|
|
28
|
+
Confirmed pauses write a **Paused at the session limit** timeline entry: the reset time with a live countdown, the provider and how much of the window was used, and whether native subagents are covered. When the pause ends, the entry folds into one line ("Paused at 7:57 PM · resumed 9:41 PM"), so the chat keeps the history without a stale card. Resume, release, and uncertain-stop entries make the recovery history clear. Existing confirmed waits are backfilled when upgrading. Timeline events use the host-owned plugin IPC identity, stay outside model messages, and never start a new turn. Delivery is journaled and retried; a lost acknowledgement is checked against timeline IDs before another append. Timeline delivery failures do not undo pauses and are shown in the overview/settings.
|
|
33
29
|
|
|
34
30
|
Choose **Dismiss pause** on an active pause or restart-recovery timeline item to release it **without sending a message**. This cancels its scheduled continuation, turns protection off for that session, clears its active pause from the overview and workspace label when applicable, and collapses the original card to a short acknowledgement. Existing suspended workers are released and may continue their current work. The dismissal is saved before release so it survives restarts; partial release retries without sending. Historical cards cannot dismiss a newer pause. **Resume now** remains available when you want Guard to continue interrupted work immediately.
|
|
35
31
|
|
|
36
|
-
Settings
|
|
32
|
+
Settings live in the **Pause at the limit** and **Limit timing** sections of **Settings → Plugins → Smart Session** (⌘K **Smart Session: settings**). **Sessions** can be changed from **Every session** to **Only ones I turn on**. Explicit overrides survive that change. Native subagents inherit their parent session's protection and opt-out. Paseo-created child sessions have their own protection and pill, like every other session.
|
|
37
33
|
|
|
38
34
|
| Setting | Default | Range / meaning |
|
|
39
35
|
|---|---|---|
|
|
@@ -57,9 +53,9 @@ Native coverage currently requires **Linux**, access to `/proc`, and a uniquely
|
|
|
57
53
|
|
|
58
54
|
Suspension does not undo an API request or external action already sent. Network calls can time out while paused, and tools can retain locks. Resumption preserves local process state, but a provider's ordinary network/tool error still requires normal recovery; Session Guard automatically recovers only matching quota failures. Outstanding permission prompts remain for the person to answer.
|
|
59
55
|
|
|
60
|
-
The pill's icon carries
|
|
56
|
+
The pill's icon carries the pause: the context ring normally, an amber pause while paused, and a red warning when the session needs a look. A confirmed pause timeline entry offers **Resume now** while that exact pause is still waiting. This resumes owned native workers in place, or sends one manual continuation for an interrupted turn, and opts that session out so a still-full quota cannot immediately pause it again. Restore protection from the pill when ready. Resuming does not restore provider capacity: requests can still be rejected before reset. Historical entries cannot resume a newer pause, and unconfirmed message delivery is left for inspection without a blind retry.
|
|
61
57
|
|
|
62
|
-
A disposable, empty session labelled `session_guard_demo=true` can exercise the real native pause/release path through the `smart-session.guard.demo-pause` RPC (`node probe.mjs rpc smart-session.guard.demo-pause '{"agentId":"…"}'`). It must have no user messages, active turn, or pending permissions. Its timeline explicitly says **
|
|
58
|
+
A disposable, empty session labelled `session_guard_demo=true` can exercise the real native pause/release path through the `smart-session.guard.demo-pause` RPC (`node probe.mjs rpc smart-session.guard.demo-pause '{"agentId":"…"}'`). It must have no user messages, active turn, or pending permissions. Its timeline explicitly says **Test pause**, makes no quota claim, and waits for your action even when capacity resets. The demo never sends a task or changes provider quota reports.
|
|
63
59
|
|
|
64
60
|
## Timing and persistence
|
|
65
61
|
|
|
@@ -77,7 +73,7 @@ If a reload or connection loss interrupts an unconfirmed stop/send, the pill rea
|
|
|
77
73
|
|
|
78
74
|
## With ZeroSub
|
|
79
75
|
|
|
80
|
-
[ZeroSub](https://github.com/kapybara-org/zerosub) runs Claude and Codex agents on several subscription accounts. When an account hits a limit, ZeroSub moves the agent to another account and continues it. It switches only on the CLI's own limit notice, so an agent the guard interrupts or freezes first never switches. Paseo and the recorder also measure only the CLI's own login. When ZeroSub is enabled on the host,
|
|
76
|
+
[ZeroSub](https://github.com/kapybara-org/zerosub) runs Claude and Codex agents on several subscription accounts. When an account hits a limit, ZeroSub moves the agent to another account and continues it. It switches only on the CLI's own limit notice, so an agent the guard interrupts or freezes first never switches. Paseo and the recorder also measure only the CLI's own login. When ZeroSub is enabled on the host, Pause at the limit reads ZeroSub's registry and usage readings from `$PASEO_HOME/zerosub/` and changes its behaviour as follows:
|
|
81
77
|
|
|
82
78
|
- **Each agent is measured on its own account.**
|
|
83
79
|
- An agent on an account ZeroSub added is paused only when *that* account reaches the threshold.
|
|
@@ -100,4 +96,4 @@ Workspace labels support text and color. Session Guard uses the short amber labe
|
|
|
100
96
|
|
|
101
97
|
## Agents
|
|
102
98
|
|
|
103
|
-
Inside Paseo, the `budget_status` tool adds one line about the calling session: whether
|
|
99
|
+
Inside Paseo, the `budget_status` tool adds one line about the calling session: whether it pauses at the limit and continues afterwards, or whether it is paused now. It covers only the caller, never other sessions or continuation text, and it cannot turn protection off or resume a pause. Under ZeroSub, `budget_status` names the Claude account the session runs on. For an account ZeroSub added, it reports ZeroSub's reading of that account in place of the CLI login's history, and it says when ZeroSub has another account with room.
|
package/index.client.tsx
CHANGED
|
@@ -4,45 +4,29 @@ import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
|
4
4
|
|
|
5
5
|
import { observeAgents } from "./client/agents";
|
|
6
6
|
import { contributeChat } from "./client/chat";
|
|
7
|
-
import {
|
|
7
|
+
import { commandItems } from "./client/commands";
|
|
8
|
+
import { contributeGuardTimeline } from "./client/guard/index";
|
|
9
|
+
import { watchGuard } from "./client/guard/status";
|
|
8
10
|
import { createLive } from "./client/live";
|
|
9
|
-
import { OVERVIEW_SCREEN, SMART_COMPACT_SETTINGS, makeOverview } from "./client/overview";
|
|
10
|
-
import { contributeClient
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { budgetStatus, getSettings, setSettings } from "./shared/smart-session";
|
|
11
|
+
import { OVERVIEW_SCREEN, PAUSED_SCREEN, SMART_COMPACT_SETTINGS, makeOverview } from "./client/overview";
|
|
12
|
+
import { contributeClient } from "./client/pill";
|
|
13
|
+
import { makeSettings } from "./client/settings";
|
|
14
|
+
import { budgetStatus } from "./shared/smart-session";
|
|
14
15
|
|
|
15
16
|
export default function contribute(host: PluginClientContext) {
|
|
16
17
|
const visibility = withButtonVisibility(host);
|
|
17
18
|
const client = visibility.client;
|
|
18
19
|
const live = createLive();
|
|
20
|
+
const Overview = makeOverview(client, live);
|
|
19
21
|
const removers = [
|
|
20
|
-
client.addScreen({ id: OVERVIEW_SCREEN, title: "Smart Session", Component:
|
|
21
|
-
|
|
22
|
+
client.addScreen({ id: OVERVIEW_SCREEN, title: "Smart Session", Component: Overview }),
|
|
23
|
+
// The old Paused sessions screen: links to it from older chats and clients land on the list.
|
|
24
|
+
client.addScreen({ id: PAUSED_SCREEN, title: "Smart Session", Component: (props) => <Overview {...props} params={{ ...props.params, focus: "paused" }} /> }),
|
|
25
|
+
client.addSettingsScreen({ id: SMART_COMPACT_SETTINGS, title: "Settings", icon: "Gauge", Component: makeSettings(client) }),
|
|
22
26
|
contributeDisplay(client, live),
|
|
23
|
-
client.addCommandCenterItem(
|
|
24
|
-
id: "smart-session-open",
|
|
25
|
-
title: "Smart Session: plan usage and context",
|
|
26
|
-
icon: "Gauge",
|
|
27
|
-
keywords: ["usage", "budget", "limit", "forecast", "context", "tokens", "compact", "smart session"],
|
|
28
|
-
context: "global",
|
|
29
|
-
onSelect({ openScreen }) {
|
|
30
|
-
openScreen({ screenId: OVERVIEW_SCREEN });
|
|
31
|
-
},
|
|
32
|
-
}),
|
|
33
|
-
client.addCommandCenterItem({
|
|
34
|
-
id: "smart-session-pill",
|
|
35
|
-
title: "Show or hide the context pill",
|
|
36
|
-
icon: "ToggleLeft",
|
|
37
|
-
keywords: ["pill", "compact", "smart", "context", "ring", "enrol"],
|
|
38
|
-
context: "global",
|
|
39
|
-
async onSelect({ rpc }) {
|
|
40
|
-
const { settings } = await rpc(getSettings, {});
|
|
41
|
-
await rpc(setSettings, { showPill: !settings.showPill });
|
|
42
|
-
await refreshPills(() => rpc(enrolmentState, {}));
|
|
43
|
-
},
|
|
44
|
-
}),
|
|
27
|
+
...commandItems().map((item) => client.addCommandCenterItem(item)),
|
|
45
28
|
contributeChat(client),
|
|
29
|
+
contributeGuardTimeline(client),
|
|
46
30
|
];
|
|
47
31
|
|
|
48
32
|
// The sidebar row shows the session window between overview visits.
|
|
@@ -50,13 +34,14 @@ export default function contribute(host: PluginClientContext) {
|
|
|
50
34
|
void readBudget();
|
|
51
35
|
const budgetTimer = setInterval(() => void readBudget(), 2 * 60_000);
|
|
52
36
|
|
|
53
|
-
// One agent observation
|
|
37
|
+
// One agent observation and one protection poll for the one pill.
|
|
54
38
|
const agents = observeAgents(client);
|
|
55
|
-
const
|
|
56
|
-
|
|
39
|
+
const stopGuard = watchGuard(client, live);
|
|
40
|
+
// The pill registers on the host's own client: a paused session keeps it even when pills are hidden.
|
|
41
|
+
const removeClient = contributeClient(host, agents);
|
|
57
42
|
return () => {
|
|
58
43
|
clearInterval(budgetTimer);
|
|
59
|
-
|
|
44
|
+
stopGuard();
|
|
60
45
|
removeClient();
|
|
61
46
|
for (const remove of removers.splice(0)) void remove();
|
|
62
47
|
visibility.dispose();
|
package/package.json
CHANGED
package/paseo-plugin.json
CHANGED