paseo-smart-session 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +55 -130
  2. package/assets/LICENSE.lucide +43 -0
  3. package/assets/icon.png +0 -0
  4. package/assets/icon.svg +1 -0
  5. package/client/agents.ts +200 -0
  6. package/client/charts.tsx +277 -152
  7. package/client/chat.tsx +122 -0
  8. package/client/display.tsx +55 -33
  9. package/client/guard/index.tsx +48 -0
  10. package/client/guard/paused.tsx +164 -0
  11. package/client/guard/pill-icons.tsx +26 -0
  12. package/client/guard/pills.ts +132 -0
  13. package/client/guard/settings.tsx +114 -0
  14. package/client/guard/surfaces.ts +3 -0
  15. package/client/guard/timeline.tsx +154 -0
  16. package/client/kit.tsx +144 -0
  17. package/client/live.ts +49 -0
  18. package/client/motion.tsx +94 -0
  19. package/client/overview.tsx +373 -0
  20. package/client/pill-store.ts +77 -0
  21. package/client/pill.tsx +91 -380
  22. package/client/popover.tsx +113 -0
  23. package/client/settings.tsx +105 -0
  24. package/client/theme.ts +56 -0
  25. package/docs/media/smart-session-chat.png +0 -0
  26. package/docs/media/smart-session-flow.gif +0 -0
  27. package/docs/media/smart-session-guard.png +0 -0
  28. package/docs/media/smart-session-hero-light.png +0 -0
  29. package/docs/media/smart-session-hero.png +0 -0
  30. package/docs/media/smart-session-mobile.png +0 -0
  31. package/docs/media/smart-session-pill.png +0 -0
  32. package/docs/navigation-verification.md +6 -6
  33. package/docs/screenshots/display-settings-desktop.png +0 -0
  34. package/docs/screenshots/display-settings-mobile.png +0 -0
  35. package/docs/session-guard.md +88 -0
  36. package/index.client.tsx +52 -35
  37. package/index.server.ts +19 -11
  38. package/mcp.mjs +22 -3
  39. package/package.json +2 -1
  40. package/paseo-plugin.json +11 -0
  41. package/server/guard/client.ts +27 -0
  42. package/server/guard/engine.ts +554 -0
  43. package/server/guard/index.ts +395 -0
  44. package/server/guard/labels.ts +77 -0
  45. package/server/guard/processes.ts +147 -0
  46. package/server/guard/standalone.ts +61 -0
  47. package/server/guard/store.ts +113 -0
  48. package/server/guard/timeline.ts +24 -0
  49. package/server/guard/usage.ts +57 -0
  50. package/server/recorder.ts +2 -0
  51. package/server/settings.ts +10 -0
  52. package/server/store.ts +25 -1
  53. package/shared/blocks.ts +15 -0
  54. package/shared/chat.ts +174 -0
  55. package/shared/forecast.ts +233 -0
  56. package/shared/format.ts +47 -1
  57. package/shared/governor.ts +3 -0
  58. package/shared/guard/contracts.ts +65 -0
  59. package/shared/guard/policy.ts +128 -0
  60. package/shared/guard/presentation.ts +85 -0
  61. package/shared/guard/timeline.ts +34 -0
  62. package/shared/lifecycle.ts +18 -0
  63. package/shared/smart-session.ts +16 -0
  64. package/client/navigation.tsx +0 -14
  65. package/client/surface.tsx +0 -574
  66. package/docs/screenshots/smart-compact-handoff.png +0 -0
@@ -0,0 +1,105 @@
1
+ import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSwitch, useToast } from "@getpaseo/plugin/client/ui";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { useEffect, useState } from "react";
5
+ import { ActivityIndicator, ScrollView, Text, View } from "react-native";
6
+
7
+ import { formatCompactTokens } from "../shared/format";
8
+ import { enrolmentState } from "../shared/governor";
9
+ import { getSettings, installStatus, setSettings } from "../shared/smart-session";
10
+ import { withCompactThreshold } from "../shared/thresholds";
11
+ import { errorText } from "./kit";
12
+ import { refreshPills } from "./pill-store";
13
+
14
+ /** A whole number from 1 to 99, or null. */
15
+ function percent(value: string): number | null {
16
+ const parsed = Number(value.trim());
17
+ return Number.isInteger(parsed) && parsed >= 1 && parsed <= 99 ? parsed : null;
18
+ }
19
+
20
+ /**
21
+ * Settings → Plugins → Smart Session → Smart Compact. Everything that tunes when a
22
+ * session is asked to compact itself; the overview keeps only the on/off switch.
23
+ */
24
+ export function SmartCompactSettings({ theme, layout }: PluginSurfaceProps) {
25
+ const c = theme.colors;
26
+ const toast = useToast();
27
+ const queryClient = useQueryClient();
28
+ const read = useRpc(getSettings);
29
+ const write = useRpc(setSettings);
30
+ const readEnrolment = useRpc(enrolmentState);
31
+ const readInstall = useRpc(installStatus);
32
+ const query = useQuery({ queryKey: ["smart-session", "settings"], queryFn: () => read({}), refetchInterval: 60_000 });
33
+ const install = useQuery({ queryKey: ["smart-session", "install"], queryFn: () => readInstall({}), refetchInterval: 5 * 60_000 });
34
+ const [saving, setSaving] = useState(false);
35
+ const [draft, setDraft] = useState<{ large: string; small: string } | null>(null);
36
+ const [inputKey, setInputKey] = useState(0);
37
+ const settings = query.data?.settings;
38
+ useEffect(() => { setDraft(null); }, [settings?.thresholds.large.compact, settings?.thresholds.small.compact]);
39
+
40
+ if (query.isError && settings === undefined) return <View style={{ padding: 16, gap: 12 }}>
41
+ <Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{errorText(query.error)}</Text>
42
+ <SettingsCard><SettingsAction label="Load the settings again" actionLabel="Retry" onPress={() => void query.refetch()} /></SettingsCard>
43
+ </View>;
44
+ if (settings === undefined) return <ActivityIndicator accessibilityLabel="Loading Smart Compact settings" color={c.foregroundMuted} style={{ padding: 24 }} />;
45
+
46
+ async function save(patch: Parameters<typeof write>[0], message: string) {
47
+ setSaving(true);
48
+ try {
49
+ await write(patch);
50
+ await refreshPills(() => readEnrolment({}));
51
+ await queryClient.invalidateQueries({ queryKey: ["smart-session"] });
52
+ toast.show(message);
53
+ } catch (error) {
54
+ toast.error(errorText(error));
55
+ } finally {
56
+ setSaving(false);
57
+ }
58
+ }
59
+
60
+ const large = draft === null ? settings.thresholds.large.compact : percent(draft.large);
61
+ const small = draft === null ? settings.thresholds.small.compact : percent(draft.small);
62
+ const changed = draft !== null && large !== null && small !== null && (large !== settings.thresholds.large.compact || small !== settings.thresholds.small.compact);
63
+ const off = !settings.enabled || saving;
64
+ const report = install.data?.report;
65
+ const hooksHint = report === undefined ? "Keeps this plugin's context and compaction hooks in ~/.claude/settings.json."
66
+ : report.error !== null ? report.error
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);
70
+
71
+ return <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 8, gap: 24 }}>
72
+ <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.">
73
+ <SettingsCard>
74
+ <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."}
75
+ value={settings.enabled} disabled={saving} onValueChange={(enabled) => void save({ enabled }, enabled ? "Smart Compact on" : "Smart Compact off")} />
76
+ <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."
77
+ value={settings.autoEnrol} disabled={off} onValueChange={(autoEnrol) => void save({ autoEnrol }, autoEnrol ? "Sessions enrol themselves" : "Sessions are enrolled one at a time")} />
78
+ <SettingsSwitch label="Context pill on every session" hint="A ring above the message box with the session's context and its compact line. Press it to enrol or remove the session."
79
+ value={settings.showPill} disabled={off} onValueChange={(showPill) => void save({ showPill }, showPill ? "Pill shown on every session" : "Pill hidden")} />
80
+ </SettingsCard>
81
+ </SettingsSection>
82
+ <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.">
83
+ <SettingsCard>
84
+ <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.`}
85
+ initialValue={String(settings.thresholds.large.compact)} disabled={off} error={draft !== null && large === null ? "Enter a whole number from 1 to 99." : null}
86
+ onChangeText={(value) => setDraft((previous) => ({ large: value, small: previous?.small ?? String(settings.thresholds.small.compact) }))} />
87
+ <SettingsInput key={`small-${inputKey}`} label={`Smaller windows (%)`} hint={`Asked at ${small ?? "—"}% of a window under ${from}. Default 85.`}
88
+ initialValue={String(settings.thresholds.small.compact)} disabled={off} error={draft !== null && small === null ? "Enter a whole number from 1 to 99." : null}
89
+ onChangeText={(value) => setDraft((previous) => ({ small: value, large: previous?.large ?? String(settings.thresholds.large.compact) }))} />
90
+ {draft !== null ? <>
91
+ <SettingsAction label="Apply the new lines" hint={changed ? `Large ${large}% · small ${small}%` : "Nothing to apply yet"} actionLabel="Save" disabled={!changed || off}
92
+ 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}%`)} />
93
+ <SettingsAction label="Keep the saved lines" actionLabel="Discard" disabled={saving} onPress={() => { setDraft(null); setInputKey((k) => k + 1); }} />
94
+ </> : null}
95
+ </SettingsCard>
96
+ </SettingsSection>
97
+ <SettingsSection title="Claude Code">
98
+ <SettingsCard>
99
+ <SettingsSwitch label="Register the hooks" hint={hooksHint} value={settings.installHooks} disabled={off}
100
+ onValueChange={(installHooks) => void save({ installHooks }, installHooks ? "Hooks registered with Claude Code" : "Hooks removed from Claude Code")} />
101
+ {report?.mcp === "unavailable" ? <SettingsRow label="MCP server not registered" hint="The claude command was not reachable from the daemon." /> : null}
102
+ </SettingsCard>
103
+ </SettingsSection>
104
+ </ScrollView>;
105
+ }
@@ -0,0 +1,56 @@
1
+ import type { PluginHostProps } from "@getpaseo/plugin/client";
2
+ import type { ViewStyle } from "react-native";
3
+ import type { Tone } from "../shared/forecast";
4
+
5
+ /**
6
+ * Theme-aware depth and tint, shared with the rest of the plugin family
7
+ * (Follow-up's client/theme.ts). Only host tokens are used: the helpers derive
8
+ * alphas from them rather than introducing colours of their own.
9
+ */
10
+ export type Theme = PluginHostProps["theme"];
11
+ export type Colors = Theme["colors"];
12
+
13
+ function channels(color: string | undefined): number[] | null {
14
+ if (typeof color !== "string") return null;
15
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
16
+ if (hex) return (hex[1].length === 3 ? [...hex[1]].map((d) => d + d) : hex[1].match(/../g)!).map((d) => parseInt(d, 16));
17
+ const fn = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(color);
18
+ return fn ? fn.slice(1, 4).map(Number) : null;
19
+ }
20
+
21
+ /** Light when the page ground is bright; unknown colours count as dark, Paseo's default. */
22
+ export function isLight(colors: Colors): boolean {
23
+ const rgb = channels(colors.surface0);
24
+ if (!rgb) return false;
25
+ const [r, g, b] = rgb.map((v) => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; });
26
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.4;
27
+ }
28
+
29
+ export function withAlpha(color: string, alpha: number): string {
30
+ const rgb = channels(color);
31
+ return rgb ? `rgba(${rgb.join(", ")}, ${alpha})` : color;
32
+ }
33
+
34
+ /** A tag fill in the same hue as its text: 12% on light grounds, 16% on dark ones. */
35
+ export function tint(color: string, colors: Colors): string {
36
+ return withAlpha(color, isLight(colors) ? 0.12 : 0.16);
37
+ }
38
+
39
+ /** Card depth: a hairline ring plus a soft lift in light themes, a single faint ring in dark ones. */
40
+ export function raised(colors: Colors): ViewStyle {
41
+ return isLight(colors)
42
+ ? ({ boxShadow: "0px 0px 0px 1px rgba(0, 0, 0, 0.07), 0px 1px 2px -1px rgba(0, 0, 0, 0.06), 0px 2px 6px 0px rgba(0, 0, 0, 0.04)" } as ViewStyle)
43
+ : ({ boxShadow: "0px 0px 0px 1px rgba(255, 255, 255, 0.08)" } as ViewStyle);
44
+ }
45
+
46
+ /** State colours: amber when a limit is near, red when it bites, green only for a resumed/finished outcome. */
47
+ export function toneColor(colors: Colors, tone: Tone | "success" | "muted"): string {
48
+ switch (tone) {
49
+ case "success": return colors.statusSuccess;
50
+ case "danger": return colors.statusDanger;
51
+ case "warning": return colors.statusWarning;
52
+ default: return colors.foregroundMuted;
53
+ }
54
+ }
55
+
56
+ export const tabular = { fontVariant: ["tabular-nums" as const] };
@@ -1,11 +1,11 @@
1
1
  # Navigation verification
2
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.
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
- - 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.
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
+ - **Paused sessions** is its own screen with its own title, opened from the overview, the Session Guard pill's menu, a pause card or Command Center.
7
+ - Smart Compact, Session Guard and Display settings live in **Settings → Plugins → Smart Session** and open from the overview's gear, the feature cards and Command Center.
8
+ - **Display** hides the sidebar row or the composer pills; hiding either never turns a feature off, and Command Center still opens the overview.
9
+ - Compact settings have no horizontal overflow and keep accessible native switches.
10
10
 
11
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.
@@ -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** screen gathers every Session Guard pause on the selected host, each led by the time its window resets and a countdown, with the session title, provider and whether native subagents are frozen or the turn was interrupted. Uncertain stops are grouped under **Needs attention**. Open a session or manage its pause directly; search appears when the list is busy. Open it from the Smart Session overview (the **2 paused** line under the headline, or the Session Guard card), the pill's menu, Command Center, settings, or a pause card. While anything is paused, the Smart Session sidebar row shows the count. 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: 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
+
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
+ The pill's icon carries its state: a shield when protected, a muted crossed-out shield when off, 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
+
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,48 +1,65 @@
1
1
  import { withButtonVisibility } from "./client/button-visibility";
2
2
  import { contributeDisplay } from "./client/display";
3
- import { withNavigation } from "./client/navigation";
4
3
  import type { PluginClientContext } from "@getpaseo/plugin/client";
5
4
 
5
+ import { observeAgents } from "./client/agents";
6
+ import { contributeChat } from "./client/chat";
7
+ import { contributeGuardClient } from "./client/guard/index";
8
+ import { createLive } from "./client/live";
9
+ import { OVERVIEW_SCREEN, SMART_COMPACT_SETTINGS, makeOverview } from "./client/overview";
6
10
  import { contributeClient, refreshPills } from "./client/pill";
7
- import { SmartSessionSurface } from "./client/surface";
11
+ import { SmartCompactSettings } from "./client/settings";
8
12
  import { enrolmentState } from "./shared/governor";
9
- import { getSettings, setSettings } from "./shared/smart-session";
13
+ import { budgetStatus, getSettings, setSettings } from "./shared/smart-session";
10
14
 
11
15
  export default function contribute(host: PluginClientContext) {
12
16
  const visibility = withButtonVisibility(host);
13
17
  const client = visibility.client;
14
- client.addSurface("overview", withNavigation(SmartSessionSurface, client, "overview"));
15
- const removeDisplay = contributeDisplay(client, {
16
- id: "smart-session",
17
- title: "Smart Session",
18
- icon: "Gauge",
19
- surface: "overview",
20
- });
18
+ const live = createLive();
19
+ const removers = [
20
+ client.addScreen({ id: OVERVIEW_SCREEN, title: "Smart Session", Component: makeOverview(client, live) }),
21
+ client.addSettingsScreen({ id: SMART_COMPACT_SETTINGS, title: "Smart Compact", icon: "FoldVertical", Component: SmartCompactSettings }),
22
+ 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
+ }),
45
+ contributeChat(client),
46
+ ];
21
47
 
22
- client.addCommandCenterItem({
23
- id: "smart-session-open",
24
- title: "Show plan usage and agent context",
25
- icon: "Gauge",
26
- keywords: ["usage", "budget", "limit", "context", "tokens", "compact"],
27
- context: "global",
28
- onSelect({ openSurface }) {
29
- openSurface("overview");
30
- },
31
- });
48
+ // The sidebar row shows the session window between overview visits.
49
+ const readBudget = () => client.rpc(budgetStatus, {}).then((budget) => live.setBudget(budget)).catch(() => undefined);
50
+ void readBudget();
51
+ const budgetTimer = setInterval(() => void readBudget(), 2 * 60_000);
32
52
 
33
- client.addCommandCenterItem({
34
- id: "smart-session-pill",
35
- title: "Show or hide the smart-compact pill",
36
- icon: "ToggleLeft",
37
- keywords: ["pill", "compact", "smart", "governor", "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
- });
45
-
46
- const removeClient = contributeClient(client);
47
- return () => { visibility.dispose(); removeDisplay(); return removeClient(); };
53
+ // One agent observation for every pill in this bundle.
54
+ const agents = observeAgents(client);
55
+ const removeClient = contributeClient(client, agents);
56
+ const removeGuard = contributeGuardClient(client, agents, live);
57
+ return () => {
58
+ clearInterval(budgetTimer);
59
+ removeGuard();
60
+ removeClient();
61
+ for (const remove of removers.splice(0)) void remove();
62
+ visibility.dispose();
63
+ return agents.dispose();
64
+ };
48
65
  }
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";
@@ -6,7 +7,7 @@ import { readAgents, withDaemon } from "./server/daemon.ts";
6
7
  import { contextGrowth, projectFull } from "./server/growth.ts";
7
8
  import { install } from "./server/install.ts";
8
9
  import { summarizeSpend } from "./server/spend.ts";
9
- import { countEnrolled, listEnrolment, readSettings, setEnrolled, writeSettings } from "./server/settings.ts";
10
+ import { countEnrolled, listEnrolment, readSettings, setEnrolled, stateWrittenAt, writeSettings } from "./server/settings.ts";
10
11
  import { dataDir, newestUsage, readUsage } from "./server/store.ts";
11
12
  import {
12
13
  budgetStatus,
@@ -24,8 +25,8 @@ import {
24
25
  requestCompaction,
25
26
  setEnrolment,
26
27
  } from "./shared/governor.ts";
27
- import { burnRatePctPerHour, projectedExhaustion, segmentBlocks } from "./shared/blocks.ts";
28
- import { lifecycle } from "./shared/lifecycle.ts";
28
+ import { burnRatePctPerHour, projectedExhaustion, segmentBlocks, thinPoints } from "./shared/blocks.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;
@@ -49,8 +52,9 @@ export default function contribute(server: PluginServerContext) {
49
52
  const newest = await newestUsage();
50
53
 
51
54
  // One row per window, taken from its currently open block.
52
- const open = new Map<string, ReturnType<typeof segmentBlocks>[number]>();
53
- for (const block of segmentBlocks(samples)) open.set(block.windowId, block);
55
+ const blocks = segmentBlocks(samples);
56
+ const open = new Map<string, (typeof blocks)[number]>();
57
+ for (const block of blocks) open.set(block.windowId, block);
54
58
 
55
59
  const windows = [...open.values()]
56
60
  .map((block) => ({
@@ -59,12 +63,19 @@ export default function contribute(server: PluginServerContext) {
59
63
  resetsAt: block.resetsAt,
60
64
  burnPctPerHour: burnRatePctPerHour(block),
61
65
  projectedFullAt: projectedExhaustion(block),
66
+ startAt: block.startAt,
67
+ history: thinPoints(block.points, 96),
62
68
  }))
63
69
  .sort((a, b) => b.pct - a.pct);
70
+ const current = open.get("five_hour");
71
+ const past = blocks
72
+ .filter((block) => block.windowId === "five_hour" && block !== current)
73
+ .map((block) => ({ startAt: block.startAt, resetsAt: block.resetsAt, firstSeenAt: block.firstSeenAt, peakPct: block.peakPct }));
64
74
 
65
75
  const newestInstant = newest === null ? null : effectiveAt(newest);
66
76
  return {
67
77
  windows,
78
+ past,
68
79
  recorder: {
69
80
  newestAt: newestInstant,
70
81
  ageSeconds:
@@ -113,6 +124,7 @@ export default function contribute(server: PluginServerContext) {
113
124
  growthTokensPerHour: growth.get(row.id) ?? null,
114
125
  projectedFullAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds),
115
126
  compactAtPct: profileFor(row.maxTokens!, thresholds).compact,
127
+ stateWrittenAt: stateWrittenAt(row.id),
116
128
  }))
117
129
  .sort((a, b) => b.usedPct - a.usedPct);
118
130
  return { agents, error: null };
@@ -183,7 +195,7 @@ export default function contribute(server: PluginServerContext) {
183
195
 
184
196
  server.handle(enrolmentState, async () => {
185
197
  const settings = await readSettings();
186
- return { showPill: settings.showPill, enabled: settings.enabled, agents: await listEnrolment() };
198
+ return { showPill: settings.showPill, enabled: settings.enabled, agents: await listEnrolment(), thresholds: settings.thresholds };
187
199
  });
188
200
 
189
201
  server.handle(setEnrolment, async ({ agentId, enrolled }) => ({
@@ -192,9 +204,5 @@ export default function contribute(server: PluginServerContext) {
192
204
 
193
205
  // The recorder and governor register their timers in this shared lifecycle.
194
206
  // Paseo waits for this cleanup before stopping the plugin subprocess.
195
- return async () => {
196
- removeSessionMcp();
197
- const teardowns = lifecycle.teardowns.splice(0);
198
- for (const teardown of teardowns) await teardown();
199
- };
207
+ return () => runTeardowns([removeSessionMcp, removeGuard, ...lifecycle.teardowns.splice(0)]);
200
208
  }
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
  },
@@ -435,7 +454,7 @@ const TOOLS = {
435
454
  const agentId = requireAgent();
436
455
  const settings = readSettings();
437
456
  if (!settings.enabled) {
438
- return "Smart Compact is switched off for this Paseo install, so nothing would deliver this request. Compact by hand if you need to, or ask the user to turn Smart Compact on in the Smart Session surface.";
457
+ return "Smart Compact is switched off for this Paseo install, so nothing would deliver this request. Compact by hand if you need to, or ask the user to turn Smart Compact on in the Smart Session screen or Settings → Plugins → Smart Session.";
439
458
  }
440
459
 
441
460
  // Default to this session's own state file: the whole point is that the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-smart-session",
3
3
  "type": "module",
4
- "version": "2.0.0",
4
+ "version": "2.2.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
@@ -1,5 +1,16 @@
1
1
  {
2
2
  "id": "smart-session",
3
+ "name": "Smart Session",
4
+ "icon": "assets/icon.png",
5
+ "description": "See when your plan runs out at this pace, how full each session's context is, and let agents checkpoint and compact themselves. Session Guard pauses work at the session limit and continues it after the reset.",
6
+ "media": [
7
+ "docs/media/smart-session-hero.png",
8
+ "docs/media/smart-session-flow.gif",
9
+ "docs/media/smart-session-pill.png",
10
+ "docs/media/smart-session-chat.png",
11
+ "docs/media/smart-session-guard.png",
12
+ "docs/media/smart-session-mobile.png"
13
+ ],
3
14
  "requirements": {
4
15
  "paseo": ">=0.14.0"
5
16
  }
@@ -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>;