paseo-smart-session 1.2.6 → 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 +45 -134
- 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/button-visibility.ts +32 -0
- package/client/display.tsx +102 -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 +14 -0
- package/client/pill.tsx +12 -124
- package/client/sidebar-visibility.ts +21 -0
- package/client/surface.tsx +88 -3
- package/docs/navigation-verification.md +11 -0
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
- package/docs/session-guard.md +88 -0
- package/index.client.tsx +15 -4
- package/index.server.ts +9 -5
- package/mcp.mjs +32 -3
- package/package.json +6 -5
- package/paseo-plugin.json +2 -1
- 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/install.ts +7 -7
- package/server/recorder.ts +2 -0
- package/server/session-mcp.ts +26 -0
- package/server/store.ts +25 -1
- package/shared/display.ts +11 -0
- 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
- package/shared/smart-session.ts +1 -1
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
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Own one sidebar registration; late reads cannot register after teardown. */
|
|
2
|
+
export function sidebarVisibility(register: () => (() => void) | void) {
|
|
3
|
+
let disposed = false;
|
|
4
|
+
let remove: (() => void) | undefined;
|
|
5
|
+
return {
|
|
6
|
+
set(visible: boolean) {
|
|
7
|
+
if (disposed) return;
|
|
8
|
+
if (visible && !remove) remove = register() ?? (() => {});
|
|
9
|
+
if (!visible && remove) {
|
|
10
|
+
remove();
|
|
11
|
+
remove = undefined;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
dispose() {
|
|
15
|
+
if (disposed) return;
|
|
16
|
+
disposed = true;
|
|
17
|
+
remove?.();
|
|
18
|
+
remove = undefined;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
package/client/surface.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
|
|
1
|
+
import { type PluginSurfaceProps, useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import React, { useEffect, useState } from "react";
|
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
spendSummary,
|
|
18
18
|
} from "../shared/smart-session";
|
|
19
19
|
import { withCompactThreshold } from "../shared/thresholds";
|
|
20
|
+
import { guardPreferences, statusRpc as guardStatus } from "../shared/guard/contracts";
|
|
21
|
+
import { isPending } from "../shared/guard/policy";
|
|
22
|
+
import { governorNotice, pauseOwnerNote, pausedSessions, pausePresentation } from "../shared/guard/presentation";
|
|
23
|
+
import { refreshGuardPills } from "./guard/pills";
|
|
24
|
+
import { GUARD_SURFACES } from "./guard/surfaces";
|
|
20
25
|
|
|
21
26
|
type Theme = PluginSurfaceProps["theme"];
|
|
22
27
|
|
|
@@ -236,7 +241,85 @@ function CompactThreshold({
|
|
|
236
241
|
);
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
|
|
244
|
+
/** A quiet text link, for the places this section points to rather than controls. */
|
|
245
|
+
function Link({ theme, label, onPress }: { theme: Theme; label: string; onPress: () => void }) {
|
|
246
|
+
return (
|
|
247
|
+
<Pressable accessibilityRole="link" onPress={onPress} style={({ pressed }) => ({ minHeight: 32, justifyContent: "center", opacity: pressed ? 0.6 : 1 })}>
|
|
248
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 12, textDecorationLine: "underline" }}>{label}</Text>
|
|
249
|
+
</Pressable>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Session Guard, in brief: whether it is on, who is running it, and what is paused.
|
|
255
|
+
*
|
|
256
|
+
* The full controls live on its own settings screen; this is the switch and the
|
|
257
|
+
* answer to "is anything waiting for a reset", next to the plan limits that cause it.
|
|
258
|
+
*/
|
|
259
|
+
function SessionGuardSection({ theme, openSurface }: { theme: Theme; openSurface?: (id: string) => void }) {
|
|
260
|
+
const settings = useSettings(guardPreferences);
|
|
261
|
+
const readStatus = useRpc(guardStatus);
|
|
262
|
+
const toast = useToast();
|
|
263
|
+
const statusQuery = useQuery({ queryKey: ["smart-session", "guard"], queryFn: () => readStatus({}), refetchInterval: 15_000 });
|
|
264
|
+
const status = statusQuery.data;
|
|
265
|
+
const values = settings.status === "ready" ? settings.values : undefined;
|
|
266
|
+
// A change made here or on another client moves the pills without waiting out their poll.
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (values !== undefined) refreshGuardPills();
|
|
269
|
+
}, [values]);
|
|
270
|
+
if (settings.status !== "ready") return null;
|
|
271
|
+
const policy = settings.values;
|
|
272
|
+
const waiting = status === undefined ? [] : pausedSessions(status.pauses);
|
|
273
|
+
|
|
274
|
+
function toggle() {
|
|
275
|
+
if (settings.status !== "ready") return;
|
|
276
|
+
const next = !settings.values.enabled;
|
|
277
|
+
void settings.save({ ...settings.values, enabled: next }, settings.revision).then((saved) => {
|
|
278
|
+
if (!saved) return toast.error("Session Guard could not be changed. Try again.");
|
|
279
|
+
toast.show(next ? "Session Guard on" : "Session Guard off");
|
|
280
|
+
void statusQuery.refetch();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const owned = status?.governor === "active";
|
|
285
|
+
const owner = status === undefined ? null : pauseOwnerNote(status);
|
|
286
|
+
const notice = status === undefined ? null : governorNotice(status);
|
|
287
|
+
const count = `${waiting.length} paused session${waiting.length === 1 ? "" : "s"}`;
|
|
288
|
+
const detail = !policy.enabled
|
|
289
|
+
? waiting.length > 0 && owned
|
|
290
|
+
? `Off. ${count} still ${waiting.length === 1 ? "waits" : "wait"} for the reset; cancel ${waiting.length === 1 ? "it" : "them"} from Paused sessions, or turn this back on.`
|
|
291
|
+
: waiting.length > 0
|
|
292
|
+
? `Off. ${count} below ${waiting.length === 1 ? "was" : "were"} saved by the standalone Session Guard plugin and ${waiting.length === 1 ? "is" : "are"} not managed here.`
|
|
293
|
+
: `Off. Turn on to pause sessions at ${policy.thresholdPct}% of the provider's session window and continue them in the same conversation after it resets.`
|
|
294
|
+
: !owned && notice !== null && status?.governor !== "off"
|
|
295
|
+
? `On, but not pausing anything here. ${notice.hint}`
|
|
296
|
+
: `On. ${policy.protectByDefault ? "Every session" : "Each session you opt in"} pauses at ${policy.thresholdPct}% of the session window${
|
|
297
|
+
policy.includeNativeSubagents ? ", native subagents included," : ""
|
|
298
|
+
} and ${policy.autoResume ? "continues by itself" : "waits for you"} after the reset.`;
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<Section theme={theme} title="Session Guard">
|
|
302
|
+
<Toggle theme={theme} on={policy.enabled} label="Session Guard" detail={detail} onPress={toggle} disabled={settings.saving} />
|
|
303
|
+
{waiting.map((pause) => {
|
|
304
|
+
const presentation = pausePresentation(pause, status!.policy);
|
|
305
|
+
return (
|
|
306
|
+
<View key={pause.id} style={{ paddingVertical: 6, gap: 2 }}>
|
|
307
|
+
<Text style={{ color: pause.phase === "attention" ? theme.colors.statusDanger : theme.colors.foreground }}>{presentation.title}</Text>
|
|
308
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{`${owner ?? presentation.state} · ${presentation.resetLabel}`}</Text>
|
|
309
|
+
</View>
|
|
310
|
+
);
|
|
311
|
+
})}
|
|
312
|
+
{openSurface === undefined ? null : (
|
|
313
|
+
<View style={{ flexDirection: "row", gap: 16, flexWrap: "wrap", marginTop: 4 }}>
|
|
314
|
+
{status?.pauses.some(isPending) ? <Link theme={theme} label="Paused sessions" onPress={() => openSurface(GUARD_SURFACES.paused)} /> : null}
|
|
315
|
+
<Link theme={theme} label="Session Guard settings" onPress={() => openSurface(GUARD_SURFACES.settings)} />
|
|
316
|
+
</View>
|
|
317
|
+
)}
|
|
318
|
+
</Section>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function SmartSessionSurface({ theme, layout, openSurface }: PluginSurfaceProps & { openSurface?: (id: string) => void }) {
|
|
240
323
|
const budget = useRpc(budgetStatus);
|
|
241
324
|
const context = useRpc(contextStatus);
|
|
242
325
|
const spend = useRpc(spendSummary);
|
|
@@ -536,7 +619,7 @@ export function SmartSessionSurface({ theme, layout }: PluginSurfaceProps) {
|
|
|
536
619
|
: install.pluginDir === null
|
|
537
620
|
? "Not registered. Nothing will ask a session to compact itself."
|
|
538
621
|
: `Registered: ${install.hooks.join(", ")}. Only entries pointing at this plugin are touched; the MCP server is ${
|
|
539
|
-
install.mcp === "unavailable" ? "not registered — the claude CLI was not reachable" : "registered"
|
|
622
|
+
install.mcp === "session" ? "attached when a Paseo session opens" : install.mcp === "unavailable" ? "not registered — the claude CLI was not reachable" : "registered"
|
|
540
623
|
}.`
|
|
541
624
|
}
|
|
542
625
|
onPress={toggleInstallHooks}
|
|
@@ -555,6 +638,8 @@ export function SmartSessionSurface({ theme, layout }: PluginSurfaceProps) {
|
|
|
555
638
|
</Section>
|
|
556
639
|
)}
|
|
557
640
|
|
|
641
|
+
<SessionGuardSection theme={theme} openSurface={openSurface} />
|
|
642
|
+
|
|
558
643
|
{recorder === undefined ? null : (
|
|
559
644
|
<Section theme={theme} title="Recorder">
|
|
560
645
|
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Navigation verification
|
|
2
|
+
|
|
3
|
+
Verified the actual plugin in an isolated Paseo 0.12.0 daemon with synthetic workspace and provider data, using the app web runtime at 1440×1000 and 390×844.
|
|
4
|
+
|
|
5
|
+
- Sidebar main view → Display settings → Overview opens the plugin surfaces directly.
|
|
6
|
+
- Compact settings have no horizontal overflow and preserve accessible native switches.
|
|
7
|
+
- Existing saved sidebar preferences retain their values. Button-bearing plugins expose independent positive enable_ preferences; hiding a button does not stop its feature.
|
|
8
|
+
- Graphite and Transfer header hiding/restoration were exercised through the UI; Transfer settings remained reachable from Command-K while its sidebar was hidden. Follow-up's choice survived a full page reload.
|
|
9
|
+
- Button lifecycle regressions cover registrations discovered while hidden, retaining the latest descriptor, duplicate prevention, restoration and teardown.
|
|
10
|
+
|
|
11
|
+
Screenshots: [desktop](screenshots/display-settings-desktop.png), [compact](screenshots/display-settings-mobile.png). Screenshots contain synthetic data. No main daemon restart or real-agent messages were used.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Session Guard
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Turning it on
|
|
6
|
+
|
|
7
|
+
Session Guard is **off by default**. Turn it on with the **Session Guard** switch on the Smart Session overview, or in **Settings → Plugins → Smart Session → Session Guard**. Off, it holds no lock, writes nothing and draws no pill.
|
|
8
|
+
|
|
9
|
+
### Moving from the standalone plugin
|
|
10
|
+
|
|
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
|
+
|
|
13
|
+
- While the standalone plugin is enabled, Smart Session's Session Guard pauses nothing. Its settings, the paused-sessions list and the overview say so, its pill stays hidden, and pauses in the list are marked as the standalone plugin's, with no controls.
|
|
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
|
+
- 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
|
+
- 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.
|
|
17
|
+
|
|
18
|
+
Turning Session Guard off releases suspended workers immediately. A message continuation that is still waiting keeps the lock until you cancel it or turn Session Guard back on. With nothing left waiting, Smart Session releases the lock.
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
Every live session with a workspace gets a **Session Guard** composer pill while the feature is on. Open its menu to:
|
|
23
|
+
|
|
24
|
+
- **Turn off for this session** / **Protect this session**: persist an explicit session override.
|
|
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.
|
|
29
|
+
|
|
30
|
+
The **Paused sessions** sidebar view gathers every Session Guard pause on the selected host. It shows session titles and identifiers, providers, native coverage, expected reset times, and automatic/manual resume state. Uncertain stops are grouped under **Needs attention**. Open a session or manage its pause directly; search appears when the list is busy. You can also open the overview through Command Center, settings, or a timeline entry. A waiting session's composer pill begins **Paused**. Its workspace also gets an amber **Paused** label in the left sidebar. The label persists through restarts and stays until the last paused session in that workspace is released. Turn it off with **Label paused workspaces** in settings; other workspace labels stay intact.
|
|
31
|
+
|
|
32
|
+
Confirmed pauses write a **Paused for session limit** timeline entry, including quota/reset details and whether native subagents are covered. 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
|
+
|
|
34
|
+
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
|
+
|
|
36
|
+
Settings are also available in **Settings → Plugins → Smart Session → Session Guard** and Command Center. The default can be changed from **Protect all sessions** to **Opt in per session**. 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
|
+
|
|
38
|
+
| Setting | Default | Range / meaning |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Enabled | No | Off releases suspended workers; message continuations stay on hold |
|
|
41
|
+
| Default protection | All sessions | Can switch to opt in |
|
|
42
|
+
| Automatic continuation | Yes | Off lets you continue manually |
|
|
43
|
+
| Include native subagents | Yes | Linux Claude/Codex process-tree suspension; off uses foreground interruption |
|
|
44
|
+
| Pause threshold | 100% | 1–100% |
|
|
45
|
+
| Check interval | 15 seconds | 5–300 seconds |
|
|
46
|
+
| Grace after reset | 30 seconds | 0–600 seconds |
|
|
47
|
+
| Continuation spacing | 5 seconds | 0–300 seconds; delivered on scheduler checks |
|
|
48
|
+
| Providers | All reporting session usage | Optional comma-separated provider IDs |
|
|
49
|
+
| Session windows | `five_hour, session` | IDs in preference order |
|
|
50
|
+
| Continuation message | Resume unfinished work from conversation and workspace | Editable, 1–8,000 characters |
|
|
51
|
+
|
|
52
|
+
With native coverage enabled on Linux, the plugin suspends the session's Claude or Codex provider process and its nested workers/tools using `SIGSTOP`. This preserves the current parent turn and native subagents, including background work under an idle parent. After a confirmed reset, `SIGCONT` resumes that same process tree; no new message is needed. A quota rejection already in flight can still finish the original turn after resumption; that matching failed turn gets one recovery message after reset.
|
|
53
|
+
|
|
54
|
+
Other providers, and Claude/Codex with native coverage turned off, use foreground interruption followed by one continuation message in the existing conversation. The plugin never compacts, clears context, switches models, or creates replacement agents. Interrupted tools may need recovery; the default continuation asks the agent to inspect their state before retrying.
|
|
55
|
+
|
|
56
|
+
Native coverage currently requires **Linux**, access to `/proc`, and a uniquely identified provider child of the connected local daemon. Ownership is checked using the daemon PID and its injected `PASEO_AGENT_ID`; each signal rechecks the process birth identity to avoid PID reuse. Other sessions, the daemon, the plugin, and workers already stopped by a person are excluded. Ambiguous ownership is reported in settings and retried, without claiming native protection succeeded. Turn off native coverage on an unsupported host to use foreground protection. Native work running in external services outside the provider's process tree cannot be suspended.
|
|
57
|
+
|
|
58
|
+
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
|
+
|
|
60
|
+
Like Smart Compact, the pill uses a state-colored icon: green when protected, muted when off, amber when paused, and red when inspection is needed. 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
|
+
|
|
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 **Session Guard demo paused**, 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
|
+
|
|
64
|
+
## Timing and persistence
|
|
65
|
+
|
|
66
|
+
The governor reads `provider.usage.list` through Paseo's own daemon client. For Claude it also uses Smart Session's recorder, which asks Anthropic directly every three minutes: whichever reading the provider produced more recently wins, so a reset is usually confirmed before Paseo's cache refreshes. Claude's `five_hour` and Codex's `session` windows work by default; any other provider reporting a matching window also qualifies. A percentage of remaining capacity is converted when a used percentage is absent. Weekly usage alone does not pause work under the defaults.
|
|
67
|
+
|
|
68
|
+
Paseo caches upstream quota reports (the current service defaults to five minutes). The check interval cannot force an upstream refresh: for providers other than Claude, pause detection and reset confirmation follow the reports Paseo actually exposes. A quota rejection can also register interrupted work if the session window is reported at the threshold. Ordinary errors and sessions stopped by a person do not register continuations.
|
|
69
|
+
|
|
70
|
+
Reset requires a **newer usage observation below the threshold**, plus the configured grace. Millisecond differences in reset timestamps are treated as the same window, using Defer's 60-second tolerance. If the reset time is missing, fresh below-threshold usage after the grace period releases the pause. If usage is unavailable, work waits and checks retry automatically.
|
|
71
|
+
|
|
72
|
+
Confirmed pauses and session overrides persist in `$PASEO_HOME/plugin-data/session-guard/state.json`; host settings use Paseo's versioned settings API. Native process identities are journaled before signals. A plugin crash/restart preserves confirmed suspension; recovery releases an unconfirmed partial freeze, and safely retries an interrupted process release. Graceful plugin unload releases suspended workers immediately and preserves the confirmed wait on disk. A full daemon or VM restart ends the old workers, but keeps the paused session in the overview, its original reset schedule, and the same conversation. Once fresh provider usage confirms the reset, Session Guard sends one continuation to that conversation. Its timeline explains the recovery; Resume now remains available. New user messages, an explicit stop, closed sessions, and opt-outs cancel automatic recovery. In-memory native subagents cannot survive a full restart; the parent conversation resumes its unfinished task. Foreground message continuations also survive reloads and daemon restarts. Confirmed state writes are flushed to disk before actions. Governor ownership uses the VM boot and process birth identity, so a reboot or reused PID does not retain a stale lock. Only one governor can own this data path, whether it is Smart Session, the standalone plugin, or either installed twice under different IDs. Gracefully closed snapshots reopen only when the saved daemon identity changes; closing a session in the same daemon cancels its continuation.
|
|
73
|
+
|
|
74
|
+
A new user message, another running turn, closing/archiving/removing the agent, a provider change, or opting out cancels the old continuation and releases owned suspended workers. Pending permissions are left for the person to answer. Message continuations are sent only into idle sessions, or a quota-error session that the plugin registered. Rechecks immediately precede interruption, suspension, and delivery; Paseo 0.10.1 has no conditional atomic “send only if this turn is unchanged” operation, so a simultaneous user action can still race that final recheck.
|
|
75
|
+
|
|
76
|
+
If a reload or connection loss interrupts an unconfirmed stop/send, the pill reads **Check session**. Delivery acceptance may be ambiguous, so the plugin leaves that session for manual inspection instead of retrying a message that might already have arrived. Confirmed waiting work continues automatically.
|
|
77
|
+
|
|
78
|
+
## Daemon authentication
|
|
79
|
+
|
|
80
|
+
For password-protected daemons, Smart Session reads `PASEO_PASSWORD`, then `PASEO_PASSWORD_FILE`, then `~/paseo-hub/secrets/daemon-password`. It never logs or stores the password. Provider usage comes through Paseo; provider credentials are not read directly.
|
|
81
|
+
|
|
82
|
+
## Client API limits
|
|
83
|
+
|
|
84
|
+
Workspace labels support text and color. Session Guard uses the short amber label **Paused**; a vector pause icon in that label needs Paseo client support. The normal desktop/mobile composer Send action cannot currently be intercepted by a plugin. Automatic “send after reset or unpause?” confirmation is not implemented in this version; adding it requires a Paseo composer send hook. Existing pause, resume, timeline, and workspace-label behavior does not depend on such a hook.
|
|
85
|
+
|
|
86
|
+
## Agents
|
|
87
|
+
|
|
88
|
+
Inside Paseo, the `budget_status` tool adds one line about the calling session: whether Session Guard will pause it at the limit and continue it 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.
|
package/index.client.tsx
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
import { withButtonVisibility } from "./client/button-visibility";
|
|
2
|
+
import { contributeDisplay } from "./client/display";
|
|
3
|
+
import { withNavigation } from "./client/navigation";
|
|
1
4
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
5
|
|
|
6
|
+
import { observeAgents } from "./client/agents";
|
|
7
|
+
import { contributeGuardClient } from "./client/guard/index";
|
|
3
8
|
import { contributeClient, refreshPills } from "./client/pill";
|
|
4
9
|
import { SmartSessionSurface } from "./client/surface";
|
|
5
10
|
import { enrolmentState } from "./shared/governor";
|
|
6
11
|
import { getSettings, setSettings } from "./shared/smart-session";
|
|
7
12
|
|
|
8
|
-
export default function contribute(
|
|
9
|
-
|
|
10
|
-
client.
|
|
13
|
+
export default function contribute(host: PluginClientContext) {
|
|
14
|
+
const visibility = withButtonVisibility(host);
|
|
15
|
+
const client = visibility.client;
|
|
16
|
+
client.addSurface("overview", withNavigation((props) => <SmartSessionSurface {...props} openSurface={client.openSurface} />, client, "overview"));
|
|
17
|
+
const removeDisplay = contributeDisplay(client, {
|
|
11
18
|
id: "smart-session",
|
|
12
19
|
title: "Smart Session",
|
|
13
20
|
icon: "Gauge",
|
|
@@ -38,5 +45,9 @@ export default function contribute(client: PluginClientContext) {
|
|
|
38
45
|
},
|
|
39
46
|
});
|
|
40
47
|
|
|
41
|
-
|
|
48
|
+
// One agent observation for every pill in this bundle.
|
|
49
|
+
const agents = observeAgents(client);
|
|
50
|
+
const removeClient = contributeClient(client, agents);
|
|
51
|
+
const removeGuard = contributeGuardClient(client, agents, "overview");
|
|
52
|
+
return () => { removeGuard(); removeClient(); visibility.dispose(); removeDisplay(); return agents.dispose(); };
|
|
42
53
|
}
|
package/index.server.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { contributeGuard } from "./server/guard/index.ts";
|
|
2
|
+
import { contributeSessionMcp } from "./server/session-mcp.ts";
|
|
3
|
+
import { displayPreferences } from "./shared/display";
|
|
1
4
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
5
|
|
|
3
6
|
import { readAgents, withDaemon } from "./server/daemon.ts";
|
|
@@ -23,7 +26,7 @@ import {
|
|
|
23
26
|
setEnrolment,
|
|
24
27
|
} from "./shared/governor.ts";
|
|
25
28
|
import { burnRatePctPerHour, projectedExhaustion, segmentBlocks } from "./shared/blocks.ts";
|
|
26
|
-
import { lifecycle } from "./shared/lifecycle.ts";
|
|
29
|
+
import { lifecycle, runTeardowns } from "./shared/lifecycle.ts";
|
|
27
30
|
import { profileFor } from "./shared/thresholds.ts";
|
|
28
31
|
import { effectiveAt } from "./shared/usage.ts";
|
|
29
32
|
|
|
@@ -38,6 +41,10 @@ import { queue } from "./server/governor.ts";
|
|
|
38
41
|
import "./server/install-on-load.ts";
|
|
39
42
|
|
|
40
43
|
export default function contribute(server: PluginServerContext) {
|
|
44
|
+
const removeSessionMcp = contributeSessionMcp(server);
|
|
45
|
+
server.registerSettings(displayPreferences);
|
|
46
|
+
// Pauses work at a provider's session limit; off until someone turns it on.
|
|
47
|
+
const removeGuard = contributeGuard(server);
|
|
41
48
|
server.handle(budgetStatus, async () => {
|
|
42
49
|
try {
|
|
43
50
|
const since = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
@@ -188,8 +195,5 @@ export default function contribute(server: PluginServerContext) {
|
|
|
188
195
|
|
|
189
196
|
// The recorder and governor register their timers in this shared lifecycle.
|
|
190
197
|
// Paseo waits for this cleanup before stopping the plugin subprocess.
|
|
191
|
-
return
|
|
192
|
-
const teardowns = lifecycle.teardowns.splice(0);
|
|
193
|
-
for (const teardown of teardowns) await teardown();
|
|
194
|
-
};
|
|
198
|
+
return () => runTeardowns([removeSessionMcp, removeGuard, ...lifecycle.teardowns.splice(0)]);
|
|
195
199
|
}
|
package/mcp.mjs
CHANGED
|
@@ -134,11 +134,21 @@ function findPaseoCli() {
|
|
|
134
134
|
return null;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
function daemonUrl() {
|
|
138
|
+
if (process.env.PASEO_DAEMON_URL) return process.env.PASEO_DAEMON_URL;
|
|
139
|
+
const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
140
|
+
try {
|
|
141
|
+
const config = JSON.parse(readFileSync(join(home, "config.json"), "utf8"));
|
|
142
|
+
if (typeof config.daemon?.listen === "string") return `ws://${config.daemon.listen}/ws`;
|
|
143
|
+
} catch { /* Use the documented default when configuration is absent. */ }
|
|
144
|
+
return "ws://127.0.0.1:6767/ws";
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
async function callPlugin(method, input) {
|
|
138
148
|
const { DaemonClient } = await loadDaemonClient();
|
|
139
149
|
const password = await resolveDaemonPassword();
|
|
140
150
|
const client = new DaemonClient({
|
|
141
|
-
url:
|
|
151
|
+
url: daemonUrl(),
|
|
142
152
|
clientId: "smart-session-mcp",
|
|
143
153
|
clientType: "cli",
|
|
144
154
|
reconnect: { enabled: false },
|
|
@@ -244,6 +254,23 @@ function describeRelative(iso) {
|
|
|
244
254
|
return ms >= 0 ? `in ${text}` : `${text} ago`;
|
|
245
255
|
}
|
|
246
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Session Guard's word on this session, for `budget_status`.
|
|
259
|
+
*
|
|
260
|
+
* Read-only, and only for the session asking: the plugin resolves it from
|
|
261
|
+
* `PASEO_AGENT_ID`, never from anything the model passes. A failure here costs the
|
|
262
|
+
* extra line, not the budget answer.
|
|
263
|
+
*/
|
|
264
|
+
async function guardSummary() {
|
|
265
|
+
if (AGENT_ID === null) return [];
|
|
266
|
+
try {
|
|
267
|
+
const result = await callPlugin("smart-session.guard.agent", { agentId: AGENT_ID });
|
|
268
|
+
return Array.isArray(result?.summary) ? result.summary.filter((line) => typeof line === "string") : [];
|
|
269
|
+
} catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
247
274
|
const TOOLS = {
|
|
248
275
|
context_status: {
|
|
249
276
|
description:
|
|
@@ -291,15 +318,16 @@ const TOOLS = {
|
|
|
291
318
|
|
|
292
319
|
budget_status: {
|
|
293
320
|
description:
|
|
294
|
-
"How much of the Claude plan's rolling 5-hour and weekly limits is already consumed, how fast, and when each window resets. Call this before spawning several subagents, before a long autonomous run, or when deciding whether to use a more expensive model.",
|
|
321
|
+
"How much of the Claude plan's rolling 5-hour and weekly limits is already consumed, how fast, and when each window resets — and, inside Paseo, whether Session Guard will pause this session at the session limit and continue it after the reset. Call this before spawning several subagents, before a long autonomous run, or when deciding whether to use a more expensive model.",
|
|
295
322
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
296
323
|
async run() {
|
|
297
324
|
const result = await callPlugin("smart-session.budget", {});
|
|
298
325
|
if (result.error) return `Could not read plan usage: ${result.error}`;
|
|
326
|
+
const guard = await guardSummary();
|
|
299
327
|
const interesting = (result.windows ?? []).filter(
|
|
300
328
|
(window) => window.id === "five_hour" || window.id === "seven_day" || window.pct > 0,
|
|
301
329
|
);
|
|
302
|
-
if (interesting.length === 0) return "No plan-usage reading recorded yet.";
|
|
330
|
+
if (interesting.length === 0) return ["No plan-usage reading recorded yet.", ...guard].join("\n");
|
|
303
331
|
|
|
304
332
|
const lines = interesting.map((window) => {
|
|
305
333
|
const name = window.id === "five_hour" ? "Session (5h)" : window.id === "seven_day" ? "Week" : window.id;
|
|
@@ -318,6 +346,7 @@ const TOOLS = {
|
|
|
318
346
|
`(Newest reading is ${Math.round(HOURS(recorder.ageSeconds * 1000) * 10) / 10}h old — treat these as a floor, not a current number.)`,
|
|
319
347
|
);
|
|
320
348
|
}
|
|
349
|
+
lines.push(...guard);
|
|
321
350
|
return lines.join("\n");
|
|
322
351
|
},
|
|
323
352
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-smart-session",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
|
|
7
7
|
"repository": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"files": [
|
|
22
22
|
"paseo-plugin.json",
|
|
23
|
+
"assets/",
|
|
23
24
|
"index.client.tsx",
|
|
24
25
|
"index.server.ts",
|
|
25
26
|
"client/",
|
|
@@ -33,13 +34,13 @@
|
|
|
33
34
|
"typecheck": "tsc --noEmit",
|
|
34
35
|
"test": "node --test --experimental-strip-types *.test.ts",
|
|
35
36
|
"check": "node check-bundles.mjs && node check-gitinstall.mjs && node check-teardown.mjs && node check-mcp-resolve.mjs",
|
|
36
|
-
"verify": "npm run typecheck && npm run test && npm run check"
|
|
37
|
+
"verify": "npm run typecheck && npm run test && npm run check && node --test button-visibility.test.mjs"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@babel/parser": "^8.0.4",
|
|
40
|
-
"@getpaseo/client": "0.
|
|
41
|
-
"@getpaseo/plugin": "0.
|
|
42
|
-
"@getpaseo/protocol": "0.
|
|
41
|
+
"@getpaseo/client": "0.14.0",
|
|
42
|
+
"@getpaseo/plugin": "0.14.0",
|
|
43
|
+
"@getpaseo/protocol": "0.14.0",
|
|
43
44
|
"@tanstack/react-query": "^5.90.11",
|
|
44
45
|
"@types/node": "^22.10.2",
|
|
45
46
|
"@types/react": "~19.2.0",
|