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
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);
|
|
@@ -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,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
|
@@ -3,6 +3,8 @@ import { contributeDisplay } from "./client/display";
|
|
|
3
3
|
import { withNavigation } from "./client/navigation";
|
|
4
4
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
5
5
|
|
|
6
|
+
import { observeAgents } from "./client/agents";
|
|
7
|
+
import { contributeGuardClient } from "./client/guard/index";
|
|
6
8
|
import { contributeClient, refreshPills } from "./client/pill";
|
|
7
9
|
import { SmartSessionSurface } from "./client/surface";
|
|
8
10
|
import { enrolmentState } from "./shared/governor";
|
|
@@ -11,7 +13,7 @@ import { getSettings, setSettings } from "./shared/smart-session";
|
|
|
11
13
|
export default function contribute(host: PluginClientContext) {
|
|
12
14
|
const visibility = withButtonVisibility(host);
|
|
13
15
|
const client = visibility.client;
|
|
14
|
-
client.addSurface("overview", withNavigation(SmartSessionSurface
|
|
16
|
+
client.addSurface("overview", withNavigation((props) => <SmartSessionSurface {...props} openSurface={client.openSurface} />, client, "overview"));
|
|
15
17
|
const removeDisplay = contributeDisplay(client, {
|
|
16
18
|
id: "smart-session",
|
|
17
19
|
title: "Smart Session",
|
|
@@ -43,6 +45,9 @@ export default function contribute(host: PluginClientContext) {
|
|
|
43
45
|
},
|
|
44
46
|
});
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
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(); };
|
|
48
53
|
}
|
package/index.server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contributeGuard } from "./server/guard/index.ts";
|
|
1
2
|
import { contributeSessionMcp } from "./server/session-mcp.ts";
|
|
2
3
|
import { displayPreferences } from "./shared/display";
|
|
3
4
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
setEnrolment,
|
|
26
27
|
} from "./shared/governor.ts";
|
|
27
28
|
import { burnRatePctPerHour, projectedExhaustion, segmentBlocks } from "./shared/blocks.ts";
|
|
28
|
-
import { lifecycle } from "./shared/lifecycle.ts";
|
|
29
|
+
import { lifecycle, runTeardowns } from "./shared/lifecycle.ts";
|
|
29
30
|
import { profileFor } from "./shared/thresholds.ts";
|
|
30
31
|
import { effectiveAt } from "./shared/usage.ts";
|
|
31
32
|
|
|
@@ -42,6 +43,8 @@ import "./server/install-on-load.ts";
|
|
|
42
43
|
export default function contribute(server: PluginServerContext) {
|
|
43
44
|
const removeSessionMcp = contributeSessionMcp(server);
|
|
44
45
|
server.registerSettings(displayPreferences);
|
|
46
|
+
// Pauses work at a provider's session limit; off until someone turns it on.
|
|
47
|
+
const removeGuard = contributeGuard(server);
|
|
45
48
|
server.handle(budgetStatus, async () => {
|
|
46
49
|
try {
|
|
47
50
|
const since = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
@@ -192,9 +195,5 @@ export default function contribute(server: PluginServerContext) {
|
|
|
192
195
|
|
|
193
196
|
// The recorder and governor register their timers in this shared lifecycle.
|
|
194
197
|
// Paseo waits for this cleanup before stopping the plugin subprocess.
|
|
195
|
-
return
|
|
196
|
-
removeSessionMcp();
|
|
197
|
-
const teardowns = lifecycle.teardowns.splice(0);
|
|
198
|
-
for (const teardown of teardowns) await teardown();
|
|
199
|
-
};
|
|
198
|
+
return () => runTeardowns([removeSessionMcp, removeGuard, ...lifecycle.teardowns.splice(0)]);
|
|
200
199
|
}
|
package/mcp.mjs
CHANGED
|
@@ -254,6 +254,23 @@ function describeRelative(iso) {
|
|
|
254
254
|
return ms >= 0 ? `in ${text}` : `${text} ago`;
|
|
255
255
|
}
|
|
256
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
|
+
|
|
257
274
|
const TOOLS = {
|
|
258
275
|
context_status: {
|
|
259
276
|
description:
|
|
@@ -301,15 +318,16 @@ const TOOLS = {
|
|
|
301
318
|
|
|
302
319
|
budget_status: {
|
|
303
320
|
description:
|
|
304
|
-
"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.",
|
|
305
322
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
306
323
|
async run() {
|
|
307
324
|
const result = await callPlugin("smart-session.budget", {});
|
|
308
325
|
if (result.error) return `Could not read plan usage: ${result.error}`;
|
|
326
|
+
const guard = await guardSummary();
|
|
309
327
|
const interesting = (result.windows ?? []).filter(
|
|
310
328
|
(window) => window.id === "five_hour" || window.id === "seven_day" || window.pct > 0,
|
|
311
329
|
);
|
|
312
|
-
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");
|
|
313
331
|
|
|
314
332
|
const lines = interesting.map((window) => {
|
|
315
333
|
const name = window.id === "five_hour" ? "Session (5h)" : window.id === "seven_day" ? "Week" : window.id;
|
|
@@ -328,6 +346,7 @@ const TOOLS = {
|
|
|
328
346
|
`(Newest reading is ${Math.round(HOURS(recorder.ageSeconds * 1000) * 10) / 10}h old — treat these as a floor, not a current number.)`,
|
|
329
347
|
);
|
|
330
348
|
}
|
|
349
|
+
lines.push(...guard);
|
|
331
350
|
return lines.join("\n");
|
|
332
351
|
},
|
|
333
352
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-smart-session",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.
|
|
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/",
|
package/paseo-plugin.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Agent, Usage } from "../../shared/guard/policy.ts";
|
|
2
|
+
import type { ListedPlugin } from "./standalone.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The slice of Paseo's daemon client that Session Guard uses, typed.
|
|
6
|
+
*
|
|
7
|
+
* It is the same short-lived client `server/daemon.ts` opens for the recorder; this
|
|
8
|
+
* is only a narrower, typed view of it. The optional members arrived in later daemon
|
|
9
|
+
* versions, and the engine checks for them before relying on them.
|
|
10
|
+
*/
|
|
11
|
+
export interface GuardClient {
|
|
12
|
+
fetchAgents(options?: { filter?: { includeArchived?: boolean }; page?: { limit: number; cursor?: string } }): Promise<{
|
|
13
|
+
entries: readonly { agent: Agent }[];
|
|
14
|
+
pageInfo?: { hasMore: boolean; nextCursor?: string | null };
|
|
15
|
+
}>;
|
|
16
|
+
fetchAgent(agentId: string): Promise<{ agent: Agent } | null>;
|
|
17
|
+
listProviderUsage(): Promise<Usage>;
|
|
18
|
+
setWorkspaceLabel?(options: { workspaceId: string; label: { name: string; color: "amber" }; assigned: boolean }): Promise<unknown>;
|
|
19
|
+
invokePluginRpc?(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
|
20
|
+
getDaemonStatus?(): Promise<{ pid: number }>;
|
|
21
|
+
listPlugins?(): Promise<readonly ListedPlugin[]>;
|
|
22
|
+
listProviderSubagents?(agentId: string): Promise<{ subagents: readonly { status: string }[] }>;
|
|
23
|
+
cancelAgent(agentId: string): Promise<void>;
|
|
24
|
+
sendMessage(agentId: string, text: string, options?: { messageId: string; activeTurnBehavior: "steer" }): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Connect = <T>(work: (client: GuardClient) => Promise<T>) => Promise<T>;
|