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,233 @@
1
+ /**
2
+ * Turning a usage reading into the moment it matters.
3
+ *
4
+ * Plain functions over the budget and context RPC outputs, shared by the overview,
5
+ * the pill and the checks, so every view says the same thing about one window: when
6
+ * it resets, whether it runs out first at this pace, and how much is left per hour.
7
+ */
8
+
9
+ import { formatClock, formatDuration, windowLabel } from "./format.ts";
10
+
11
+ export type Tone = "default" | "warning" | "danger";
12
+
13
+ const HOUR = 3_600_000;
14
+ const FIVE_HOURS = 5 * HOUR;
15
+ const WEEK = 7 * 24 * HOUR;
16
+
17
+ /** A window's nominal length, or null for a cap whose length is not known. */
18
+ export function windowLength(id: string): number | null {
19
+ if (id === "five_hour") return FIVE_HOURS;
20
+ if (id === "seven_day" || id.startsWith("weekly")) return WEEK;
21
+ return null;
22
+ }
23
+
24
+ export interface WindowReading {
25
+ readonly id: string;
26
+ readonly pct: number;
27
+ readonly resetsAt: string | null;
28
+ /** Percentage points per hour over the recent past; null when it cannot be measured. */
29
+ readonly burnPctPerHour: number | null;
30
+ }
31
+
32
+ export interface WindowForecast {
33
+ readonly id: string;
34
+ readonly label: string;
35
+ readonly pct: number;
36
+ readonly resetsAt: string | null;
37
+ /** The window's start, when its length is known. */
38
+ readonly startAt: string | null;
39
+ /** How far through the window we are, 0–1: where an even pace would put the meter. */
40
+ readonly elapsed: number | null;
41
+ /** The rate the forecast uses: the last half hour for the session, the window's average for a week. */
42
+ readonly ratePerHour: number | null;
43
+ /** Where the window ends up at the reset at that rate, uncapped. */
44
+ readonly atResetPct: number | null;
45
+ /** When it reaches 100% at that rate, if that comes before the reset. */
46
+ readonly runsOutAt: string | null;
47
+ /** How long before the reset it runs out. */
48
+ readonly shortMs: number | null;
49
+ /** Percentage points per hour that would last exactly until the reset. */
50
+ readonly budgetPerHour: number | null;
51
+ readonly state: "full" | "short" | "ok" | "idle" | "unknown";
52
+ readonly tone: Tone;
53
+ }
54
+
55
+ /** Rates below this are a window nobody is using right now. */
56
+ const IDLE_RATE = 0.2;
57
+
58
+ export function forecastWindow(window: WindowReading, now = Date.now()): WindowForecast {
59
+ const length = windowLength(window.id);
60
+ const reset = window.resetsAt === null ? NaN : Date.parse(window.resetsAt);
61
+ const known = Number.isFinite(reset) && reset > now;
62
+ const start = known && length !== null ? reset - length : null;
63
+ const elapsed = start === null || length === null ? null : Math.min(1, Math.max(0, (now - start) / length));
64
+ const hoursLeft = known ? (reset - now) / HOUR : null;
65
+ const base = {
66
+ id: window.id,
67
+ label: windowLabel(window.id),
68
+ pct: window.pct,
69
+ resetsAt: known ? new Date(reset).toISOString() : window.resetsAt,
70
+ startAt: start === null ? null : new Date(start).toISOString(),
71
+ elapsed,
72
+ };
73
+ const budgetPerHour = hoursLeft === null || hoursLeft <= 0 ? null : Math.max(0, 100 - window.pct) / hoursLeft;
74
+ const none = { ratePerHour: null, atResetPct: null, runsOutAt: null, shortMs: null, budgetPerHour };
75
+
76
+ if (window.pct >= 99.5) return { ...base, ...none, state: "full", tone: "danger" };
77
+ if (hoursLeft === null) return { ...base, ...none, state: "unknown", tone: window.pct >= 90 ? "warning" : "default" };
78
+
79
+ // A week is bursty: half an hour of work extrapolated over four days says nothing.
80
+ // Its pace is the window's own average so far.
81
+ const elapsedHours = elapsed === null || length === null ? null : (elapsed * length) / HOUR;
82
+ const rate = length !== null && length > FIVE_HOURS
83
+ ? (elapsedHours !== null && elapsedHours >= 1 ? window.pct / elapsedHours : null)
84
+ : window.burnPctPerHour;
85
+ if (rate === null || !Number.isFinite(rate) || rate < IDLE_RATE) {
86
+ return { ...base, ...none, ratePerHour: rate === null ? null : Math.max(0, rate), state: "idle", tone: window.pct >= 90 ? "warning" : "default" };
87
+ }
88
+ const atResetPct = window.pct + rate * hoursLeft;
89
+ if (atResetPct < 100) {
90
+ return { ...base, ratePerHour: rate, atResetPct, runsOutAt: null, shortMs: null, budgetPerHour, state: "ok", tone: window.pct >= 90 ? "warning" : "default" };
91
+ }
92
+ const runsOut = now + ((100 - window.pct) / rate) * HOUR;
93
+ const shortMs = reset - runsOut;
94
+ const soon = runsOut - now <= 30 * 60_000 || window.pct >= 95;
95
+ return {
96
+ ...base, ratePerHour: rate, atResetPct, runsOutAt: new Date(runsOut).toISOString(), shortMs, budgetPerHour,
97
+ state: "short", tone: soon ? "danger" : "warning",
98
+ };
99
+ }
100
+
101
+ const rounded = (value: number) => (value >= 10 ? Math.round(value) : Math.round(value * 10) / 10);
102
+ const until = (iso: string, now: number) => formatDuration(Date.parse(iso) - now);
103
+
104
+ /** A clock within the next day, else a weekday and clock: "9:21 PM", "Sat 11:00 PM". */
105
+ export function formatMoment(iso: string, now = Date.now(), hour12?: boolean): string {
106
+ const at = Date.parse(iso);
107
+ const clock = formatClock(iso, hour12);
108
+ const sameDay = new Date(at).toDateString() === new Date(now).toDateString();
109
+ if (sameDay || (at > now && at - now < 12 * HOUR)) return clock;
110
+ try {
111
+ return `${new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(new Date(at))} ${clock}`;
112
+ } catch {
113
+ return clock;
114
+ }
115
+ }
116
+
117
+ /** "Resets 9:21 PM · in 1h 57m". */
118
+ export function resetLine(f: WindowForecast, now = Date.now(), hour12?: boolean): string {
119
+ if (f.resetsAt === null) return "Reset time not reported";
120
+ const at = Date.parse(f.resetsAt);
121
+ if (!Number.isFinite(at)) return "Reset time not reported";
122
+ // Past its reset but no newer reading yet: name the moment rather than "due".
123
+ if (at <= now) return `Reset was ${formatMoment(f.resetsAt, now, hour12)}`;
124
+ return `Resets ${formatMoment(f.resetsAt, now, hour12)} · in ${until(f.resetsAt, now)}`;
125
+ }
126
+
127
+ /** The short pace phrase on a meter row: "runs out ~8:47 PM", "on pace for ~88%". */
128
+ export function paceLine(f: WindowForecast, now = Date.now(), hour12?: boolean): string {
129
+ switch (f.state) {
130
+ case "full": return "Limit reached";
131
+ case "short": return `Runs out ~${formatMoment(f.runsOutAt!, now, hour12)} at ${rounded(f.ratePerHour!)}%/h`;
132
+ case "ok": return `On pace for ~${Math.round(f.atResetPct!)}% by the reset`;
133
+ case "idle": return f.ratePerHour === null ? "No pace yet" : "Not in use right now";
134
+ default: return "";
135
+ }
136
+ }
137
+
138
+ /** When a window runs short: the pace that would still last until the reset. */
139
+ export function budgetLine(f: WindowForecast): string | null {
140
+ if (f.budgetPerHour === null || f.state !== "short") return null;
141
+ return `${rounded(f.budgetPerHour)}%/h would last until the reset`;
142
+ }
143
+
144
+ /** The overview's status headline and the sentence under it. */
145
+ export function headline(f: WindowForecast | null, now = Date.now(), hour12?: boolean): { title: string; detail: string; tone: Tone } {
146
+ if (f === null) return { title: "No plan usage recorded yet", detail: "The recorder samples every minute while Paseo runs.", tone: "default" };
147
+ const name = f.id === "five_hour" ? "Session" : f.label;
148
+ const pct = `${Math.round(f.pct)}% used`;
149
+ switch (f.state) {
150
+ case "full": {
151
+ const at = f.resetsAt === null ? NaN : Date.parse(f.resetsAt);
152
+ const detail = !Number.isFinite(at) ? "Waiting for the provider to report the reset"
153
+ : at <= now ? `${resetLine(f, now, hour12)} · waiting for the provider to report the new window`
154
+ : `Back at the ${formatMoment(f.resetsAt!, now, hour12)} reset · in ${until(f.resetsAt!, now)}`;
155
+ return { title: `${name} limit reached`, detail, tone: "danger" };
156
+ }
157
+ case "short":
158
+ return {
159
+ title: `${name} runs out around ${formatMoment(f.runsOutAt!, now, hour12)}`,
160
+ detail: `${formatDuration(f.shortMs!)} before the ${formatMoment(f.resetsAt!, now, hour12)} reset · ${pct} · ${rounded(f.ratePerHour!)}%/h`,
161
+ tone: f.tone,
162
+ };
163
+ case "ok":
164
+ return { title: `${name} lasts until the ${formatMoment(f.resetsAt!, now, hour12)} reset`, detail: `${pct} · on pace for ~${Math.round(f.atResetPct!)}%`, tone: f.tone };
165
+ case "idle":
166
+ return {
167
+ title: `${name} at ${Math.round(f.pct)}%`,
168
+ detail: f.resetsAt === null ? "Nothing used in the last half hour" : `Nothing used in the last half hour · ${resetLine(f, now, hour12).replace(/^R/, "r")}`,
169
+ tone: f.tone,
170
+ };
171
+ default:
172
+ return { title: `${name} at ${Math.round(f.pct)}%`, detail: "The provider has not reported a reset time", tone: f.tone };
173
+ }
174
+ }
175
+
176
+ /** The window the overview leads with: the session, else whichever is fullest. */
177
+ export function leadWindow(windows: readonly WindowForecast[]): WindowForecast | null {
178
+ return windows.find((window) => window.id === "five_hour") ?? [...windows].sort((a, b) => b.pct - a.pct)[0] ?? null;
179
+ }
180
+
181
+ export interface ContextReading {
182
+ readonly usedTokens: number;
183
+ readonly maxTokens: number;
184
+ readonly compactAtPct: number;
185
+ readonly growthTokensPerHour?: number | null;
186
+ }
187
+
188
+ export interface ContextOutlook {
189
+ readonly pct: number;
190
+ readonly compactAt: number;
191
+ /** Tokens at the compact line. */
192
+ readonly compactTokens: number;
193
+ readonly past: boolean;
194
+ /** Time until the compact line at the measured growth; null when not growing or already past. */
195
+ readonly untilCompactMs: number | null;
196
+ readonly tone: Tone;
197
+ }
198
+
199
+ export function contextOutlook(reading: ContextReading): ContextOutlook {
200
+ const pct = reading.maxTokens > 0 ? (reading.usedTokens / reading.maxTokens) * 100 : 0;
201
+ const compactTokens = Math.round((reading.compactAtPct / 100) * reading.maxTokens);
202
+ const past = pct >= reading.compactAtPct;
203
+ const growth = reading.growthTokensPerHour ?? null;
204
+ const untilCompactMs = past || growth === null || growth <= 0 ? null : ((compactTokens - reading.usedTokens) / growth) * HOUR;
205
+ return { pct, compactAt: reading.compactAtPct, compactTokens, past, untilCompactMs, tone: pct >= 90 ? "danger" : past ? "warning" : "default" };
206
+ }
207
+
208
+ /**
209
+ * The context ring: a faint full track, an arc for the share of the window used, and a
210
+ * tick across the ring at the compact line. Inline SVG in the surface's own colour, so
211
+ * the host `Icon` can draw it anywhere a Lucide glyph fits. Quantized to 1/48 so a
212
+ * ticking view rarely redraws.
213
+ */
214
+ export function contextRing(fraction: number, mark: number | null, track = 0.28): string {
215
+ const steps = 48;
216
+ const f = Math.round(Math.min(1, Math.max(0, fraction)) * steps) / steps;
217
+ const r = 8.5;
218
+ const c = 12;
219
+ const point = (t: number, radius: number) => [(c + radius * Math.sin(t * 2 * Math.PI)).toFixed(2), (c - radius * Math.cos(t * 2 * Math.PI)).toFixed(2)];
220
+ let arc = "";
221
+ if (f >= 1) arc = `<circle cx="${c}" cy="${c}" r="${r}"/>`;
222
+ else if (f > 0) {
223
+ const [x, y] = point(f, r);
224
+ arc = `<path d="M${c} ${c - r}A${r} ${r} 0 ${f > 0.5 ? 1 : 0} 1 ${x} ${y}"/>`;
225
+ }
226
+ let tick = "";
227
+ if (mark !== null && mark > 0 && mark < 1) {
228
+ const [x1, y1] = point(mark, r - 3.6);
229
+ const [x2, y2] = point(mark, r + 3.2);
230
+ tick = `<path d="M${x1} ${y1}L${x2} ${y2}" stroke-width="1.8"/>`;
231
+ }
232
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><circle cx="${c}" cy="${c}" r="${r}" stroke-opacity="${track}"/>${arc}${tick}</svg>`;
233
+ }
package/shared/format.ts CHANGED
@@ -21,6 +21,42 @@ export function formatRelative(iso: string | null, now = Date.now()): string {
21
21
  return ms >= now ? `in ${formatDuration(ms - now)}` : `${formatDuration(now - ms)} ago`;
22
22
  }
23
23
 
24
+ let cachedHour12: boolean | null = null;
25
+
26
+ /** Whether this device writes times as "8:50 PM" rather than "20:50". */
27
+ export function uses12HourClock(): boolean {
28
+ if (cachedHour12 !== null) return cachedHour12;
29
+ try {
30
+ const parts = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).formatToParts(new Date(2020, 0, 1, 13, 0, 0));
31
+ cachedHour12 = parts.some((part) => part.type === "dayPeriod");
32
+ } catch {
33
+ cachedHour12 = false;
34
+ }
35
+ return cachedHour12;
36
+ }
37
+
38
+ /** Local wall-clock time in the device's own convention: "20:50" or "8:50 PM". */
39
+ export function formatClock(iso: string | null, hour12: boolean = uses12HourClock()): string {
40
+ if (iso === null) return "—";
41
+ const date = new Date(iso);
42
+ if (Number.isNaN(date.getTime())) return "—";
43
+ const hours = date.getHours();
44
+ const minutes = String(date.getMinutes()).padStart(2, "0");
45
+ if (!hour12) return `${String(hours).padStart(2, "0")}:${minutes}`;
46
+ // A non-breaking space, so "AM" never wraps onto a line of its own.
47
+ return `${hours % 12 === 0 ? 12 : hours % 12}:${minutes}\u00a0${hours < 12 ? "AM" : "PM"}`;
48
+ }
49
+
50
+ /** "Oct 22" — a short local date. */
51
+ export function formatDay(iso: string): string {
52
+ const date = new Date(iso);
53
+ try {
54
+ return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
55
+ } catch {
56
+ return iso.slice(5, 10);
57
+ }
58
+ }
59
+
24
60
  /** "261.7k" — token counts, which are always large and never need units. */
25
61
  export function formatTokens(count: number): string {
26
62
  if (count < 1000) return String(count);
@@ -28,9 +64,19 @@ export function formatTokens(count: number): string {
28
64
  return `${(count / 1_000_000).toFixed(2)}M`;
29
65
  }
30
66
 
67
+ /** "1M", "200k", "88.6M" — a window size or a total, without trailing zeros. */
68
+ export function formatCompactTokens(count: number): string {
69
+ if (count >= 1_000_000) {
70
+ const value = count / 1_000_000;
71
+ return `${value >= 10 ? Math.round(value) : Number(value.toFixed(1))}M`;
72
+ }
73
+ if (count >= 1000) return `${Math.round(count / 1000)}k`;
74
+ return String(Math.round(count));
75
+ }
76
+
31
77
  /** A window id turned into something worth putting on a row. */
32
78
  export function windowLabel(id: string): string {
33
- if (id === "five_hour") return "Session (5h)";
79
+ if (id === "five_hour") return "Session · 5h";
34
80
  if (id === "seven_day") return "Week";
35
81
  const scoped = /^weekly_(model|surface):(.+)$/.exec(id);
36
82
  if (scoped !== null) {
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { defineRpc } from "@getpaseo/plugin";
12
12
  import { z } from "zod";
13
+ import { ThresholdsSchema } from "./smart-session.ts";
13
14
 
14
15
  export const CompactionStateSchema = z.enum(["pending", "sending", "sent", "failed", "cancelled"]);
15
16
 
@@ -93,6 +94,8 @@ export const EnrolmentStateSchema = z.object({
93
94
  enabled: z.boolean(),
94
95
  /** Only agents with a state file or an explicit answer. Anyone absent is off. */
95
96
  agents: z.array(AgentEnrolmentSchema),
97
+ /** The compact bands, so a pill can mark where its session will be asked. Optional for older daemons. */
98
+ thresholds: ThresholdsSchema.optional(),
96
99
  });
97
100
  export type EnrolmentState = z.infer<typeof EnrolmentStateSchema>;
98
101
 
@@ -0,0 +1,65 @@
1
+ import { defineRpc, defineSettings } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+ import { PauseSchema, PolicySchema } from "./policy.ts";
4
+
5
+ /** Host-scoped, so every client of this daemon sees one Session Guard policy. */
6
+ export const guardPreferences = defineSettings({ id: "session-guard", scope: "host", version: 1, schema: PolicySchema });
7
+
8
+ /**
9
+ * Who is running Session Guard on this host.
10
+ *
11
+ * - `off`: the feature is disabled here, and this plugin holds no lock.
12
+ * - `active`: this plugin is the host's one governor.
13
+ * - `deferred`: the standalone `paseo-session-guard` plugin is enabled, or another live
14
+ * process holds the governor lock. Nothing is paused from here until that changes.
15
+ * - `error`: the saved state or the lock could not be read.
16
+ */
17
+ export const GovernorSchema = z.enum(["off", "active", "deferred", "error"]);
18
+ export type Governor = z.infer<typeof GovernorSchema>;
19
+
20
+ const ProviderSchema = z.object({ providerId: z.string(), usedPct: z.number().nullable(), resetsAt: z.string().nullable() });
21
+
22
+ export const statusRpc = defineRpc({
23
+ name: "smart-session.guard.status", input: z.object({}),
24
+ output: z.object({
25
+ governor: GovernorSchema,
26
+ /** Whether the standalone plugin is configured and enabled; null when unknown or not checked. */
27
+ standalone: z.boolean().nullable(),
28
+ checkedAt: z.string().nullable(), error: z.string().nullable(),
29
+ pauses: z.array(PauseSchema), overrides: z.record(z.string(), z.boolean()),
30
+ policy: PolicySchema,
31
+ providers: z.array(ProviderSchema),
32
+ }),
33
+ });
34
+ /** One agent's view, for the agent itself: its own protection and pauses, nobody else's. */
35
+ export const agentStatusRpc = defineRpc({
36
+ name: "smart-session.guard.agent", input: z.object({ agentId: z.string().min(1) }),
37
+ output: z.object({
38
+ governor: GovernorSchema,
39
+ enabled: z.boolean(), protected: z.boolean(), override: z.boolean().nullable(),
40
+ thresholdPct: z.number(), autoResume: z.boolean(),
41
+ pauses: z.array(z.object({ id: z.string(), phase: PauseSchema.shape.phase, provider: z.string(), resetsAt: z.string().nullable() })),
42
+ providers: z.array(ProviderSchema),
43
+ summary: z.array(z.string()),
44
+ }),
45
+ });
46
+ export const setProtectionRpc = defineRpc({
47
+ name: "smart-session.guard.protection", input: z.object({ agentId: z.string().min(1), enabled: z.boolean().nullable() }),
48
+ output: z.object({ ok: z.boolean() }),
49
+ });
50
+ export const cancelResumeRpc = defineRpc({
51
+ name: "smart-session.guard.cancel", input: z.object({ agentId: z.string().min(1) }), output: z.object({ ok: z.boolean() }),
52
+ });
53
+ export const resumeNowRpc = defineRpc({
54
+ name: "smart-session.guard.resume-now", input: z.object({ agentId: z.string().min(1), pauseId: z.string().min(1) }),
55
+ output: z.object({ ok: z.boolean() }),
56
+ });
57
+ export const dismissPauseRpc = defineRpc({
58
+ name: "smart-session.guard.dismiss-pause", input: z.object({ agentId: z.string().min(1), pauseId: z.string().min(1) }),
59
+ output: z.object({ ok: z.boolean() }),
60
+ });
61
+ export const demoPauseRpc = defineRpc({
62
+ name: "smart-session.guard.demo-pause", input: z.object({ agentId: z.string().min(1) }), output: z.object({ ok: z.boolean() }),
63
+ });
64
+ // Obtains the host-owned IPC API; network clients cannot impersonate plugins.
65
+ export const runtimeRpc = defineRpc({ name: "smart-session.guard.runtime", input: z.object({}), output: z.object({ ok: z.boolean() }) });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Session Guard: pausing work at a provider's session limit and continuing it after
3
+ * the window resets.
4
+ *
5
+ * This module, and the rest of `shared/guard/`, `server/guard/` and `client/guard/`,
6
+ * was folded in from the standalone `paseo-session-guard` plugin. The pause record and
7
+ * state file are deliberately unchanged (see `server/guard/store.ts`): during the
8
+ * transition both plugins can be installed on one host, and sharing one state file and
9
+ * one governor lock is what guarantees a session is never paused twice.
10
+ */
11
+ import { z } from "zod";
12
+ import { NotificationSchema } from "./timeline.ts";
13
+
14
+ export const PolicySchema = z.object({
15
+ /**
16
+ * Off by default, unlike the standalone plugin. Pausing a running agent is a much
17
+ * bigger thing to do unasked than drawing a chart, and an existing Smart Session
18
+ * install must not start freezing processes on upgrade.
19
+ */
20
+ enabled: z.boolean().default(false),
21
+ protectByDefault: z.boolean().default(true),
22
+ thresholdPct: z.number().min(1).max(100).default(100),
23
+ pollSeconds: z.number().int().min(5).max(300).default(15),
24
+ resetGraceSeconds: z.number().int().min(0).max(600).default(30),
25
+ resumeSpacingSeconds: z.number().int().min(0).max(300).default(5),
26
+ autoResume: z.boolean().default(true),
27
+ includeNativeSubagents: z.boolean().default(true),
28
+ workspaceLabels: z.boolean().default(true),
29
+ providerIds: z.array(z.string().min(1)).default([]),
30
+ windowIds: z.array(z.string().min(1)).min(1).default(["five_hour", "session"]),
31
+ continuation: z.string().trim().min(1).max(8000).default(
32
+ "Your previous turn was paused because the provider's session usage limit was reached. " +
33
+ "The usage window has now reset. Continue the unfinished task from where you left off, " +
34
+ "using this conversation and the current workspace state. Check any interrupted operation " +
35
+ "before retrying it. If the task is already complete, do not repeat it.",
36
+ ),
37
+ });
38
+ export type Policy = z.infer<typeof PolicySchema>;
39
+
40
+ export const PauseSchema = z.object({
41
+ id: z.string(), agentId: z.string(), workspaceId: z.string().optional(), provider: z.string(), title: z.string().nullable(),
42
+ windowId: z.string(), anchorResetsAt: z.string().nullable(),
43
+ pausedAt: z.string(), lastUserMessageAt: z.string().nullable(), turnId: z.string().nullable(),
44
+ phase: z.enum(["pausing", "waiting", "resuming", "resumed", "cancelled", "attention"]),
45
+ note: z.string().nullable(), settledAt: z.string().nullable(),
46
+ wasQuotaFailure: z.boolean().default(false),
47
+ resumedInPlace: z.boolean().optional(),
48
+ manualResume: z.boolean().optional(),
49
+ dismissed: z.boolean().optional(),
50
+ restartRecovery: z.boolean().optional(),
51
+ daemonIdentity: z.string().optional(),
52
+ closedOnRestart: z.boolean().optional(),
53
+ releasedForUnload: z.boolean().optional(),
54
+ demo: z.boolean().optional(),
55
+ stoppedByUser: z.boolean().optional(),
56
+ notifications: z.array(NotificationSchema).optional(),
57
+ suspension: z.object({
58
+ rootPid: z.number().int().positive(),
59
+ processes: z.array(z.object({ pid: z.number().int().positive(), startTime: z.string() })),
60
+ }).optional(),
61
+ });
62
+ export type Pause = z.infer<typeof PauseSchema>;
63
+ export const StateSchema = z.object({
64
+ version: z.literal(1),
65
+ overrides: z.record(z.string(), z.boolean()),
66
+ pauses: z.array(PauseSchema),
67
+ labelledWorkspaces: z.array(z.string()).optional(),
68
+ workspaceLabelName: z.string().optional(),
69
+ });
70
+ export type State = z.infer<typeof StateSchema>;
71
+ export const isPending = (pause: Pause) => ["pausing", "waiting", "resuming", "attention"].includes(pause.phase);
72
+ export const protects = (policy: Policy, overrides: State["overrides"], id: string) => overrides[id] ?? policy.protectByDefault;
73
+
74
+ export interface Agent {
75
+ id: string; provider: string; status: string; title?: string | null;
76
+ workspaceId?: string; lastUserMessageAt?: string | null; archivedAt?: string | null;
77
+ activeTurn?: { turnId: string } | null;
78
+ pendingPermissions?: readonly unknown[];
79
+ labels?: Record<string, string>;
80
+ lastError?: string;
81
+ }
82
+ export interface UsageWindow {
83
+ id: string; usedPct?: number | null; remainingPct?: number | null; resetsAt?: string | null;
84
+ }
85
+ export interface ProviderUsage {
86
+ providerId: string; status: string; fetchedAt?: string | null; windows: readonly UsageWindow[];
87
+ }
88
+ export interface Usage {
89
+ fetchedAt: string; providers: readonly ProviderUsage[];
90
+ }
91
+
92
+ export function timestamp(value: string | null | undefined): number | null {
93
+ if (!value) return null;
94
+ const ms = Date.parse(value);
95
+ return Number.isFinite(ms) ? ms : null;
96
+ }
97
+ export function usedPct(window: UsageWindow): number | null {
98
+ if (typeof window.usedPct === "number" && Number.isFinite(window.usedPct)) return window.usedPct;
99
+ if (typeof window.remainingPct === "number" && Number.isFinite(window.remainingPct)) return 100 - window.remainingPct;
100
+ return null;
101
+ }
102
+ export function sessionWindow(provider: ProviderUsage | undefined, policy: Policy): UsageWindow | undefined {
103
+ if (provider?.status !== "available") return undefined;
104
+ return policy.windowIds.map(id => provider.windows.find(window => window.id === id)).find(Boolean);
105
+ }
106
+
107
+ // Reused from paseo-defer's scheduler: upstream re-reads of one window differ
108
+ // by milliseconds. Only a materially later reset is a rollover.
109
+ export const SAME_WINDOW_MS = 60_000;
110
+ export function resetReady(pause: Pause, usage: Usage, policy: Policy, now: number): boolean {
111
+ const provider = usage.providers.find(p => p.providerId === pause.provider);
112
+ if (provider?.status !== "available") return false;
113
+ const window = provider.windows.find(w => w.id === pause.windowId);
114
+ if (!window) return false;
115
+ const pct = usedPct(window);
116
+ if (pct === null || pct >= policy.thresholdPct) return false;
117
+ // A later reset with an old cached percentage must never release a pause.
118
+ const fetched = timestamp(provider.fetchedAt ?? usage.fetchedAt);
119
+ const paused = timestamp(pause.pausedAt)!;
120
+ if (fetched === null || fetched <= paused || fetched > now + SAME_WINDOW_MS) return false;
121
+ const anchor = timestamp(pause.anchorResetsAt);
122
+ const current = timestamp(window.resetsAt);
123
+ const grace = policy.resetGraceSeconds * 1000;
124
+ if (anchor === null) return fetched >= paused + grace;
125
+ // Millisecond jitter is the same window; a real rollover moves it hours.
126
+ if (current !== null && current - anchor > SAME_WINDOW_MS) return fetched >= paused + grace;
127
+ return now >= anchor + grace && fetched >= anchor + grace;
128
+ }
@@ -0,0 +1,85 @@
1
+ import { isPending, timestamp, type Pause, type Policy } from "./policy.ts";
2
+
3
+ export function pausedSessions(pauses: readonly Pause[], query = "") {
4
+ const needle = query.trim().toLocaleLowerCase();
5
+ return pauses.filter(isPending).filter(p => !needle ||
6
+ `${p.title ?? "Untitled session"} ${p.provider} ${p.agentId} ${p.suspension ? "native subagents" : "interrupted turn"}`.toLocaleLowerCase().includes(needle))
7
+ .sort((a, b) => {
8
+ if ((a.phase === "attention") !== (b.phase === "attention")) return a.phase === "attention" ? -1 : 1;
9
+ return (timestamp(a.anchorResetsAt) ?? Infinity) - (timestamp(b.anchorResetsAt) ?? Infinity) || a.pausedAt.localeCompare(b.pausedAt);
10
+ });
11
+ }
12
+ export function pausePresentation(pause: Pause, policy: Policy, now = Date.now()) {
13
+ const reset = timestamp(pause.anchorResetsAt);
14
+ const resetLabel = pause.demo ? "Waiting for your test action" : reset === null ? "Reset time unavailable" : reset <= now
15
+ ? "Reset due · waiting for fresh usage"
16
+ : `Reset ${new Date(reset).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`;
17
+ const state = pause.phase === "attention" ? "Needs attention" : pause.phase === "pausing" ? "Pausing"
18
+ : pause.phase === "resuming" ? "Resuming" : pause.demo ? "Demo · resume when ready" : !policy.enabled ? "Guard off · continuation on hold"
19
+ : !policy.autoResume ? "Automatic resume off" : "Will resume automatically after reset";
20
+ return {
21
+ title: pause.title?.trim() || "Untitled session", state, resetLabel,
22
+ coverage: pause.phase === "attention" ? "Check session state" : pause.suspension ? "Includes native subagents" : pause.restartRecovery ? "Saved conversation · waiting after restart" : "Interrupted turn",
23
+ actionLabel: pause.suspension ? "Release and turn off" : "Cancel continuation",
24
+ actionHint: pause.suspension ? "Resume these workers now and exclude this session from protection."
25
+ : "Leave this session for you to continue manually.",
26
+ };
27
+ }
28
+
29
+ /** The host master switch always outranks a session's saved preference. */
30
+ export function callerProtection(enabled: boolean, protectByDefault: boolean, override: boolean | undefined) {
31
+ return { enabled, protected: enabled && (override ?? protectByDefault), override: override ?? null };
32
+ }
33
+
34
+ /**
35
+ * What an agent is told about Session Guard, in the words it reads.
36
+ *
37
+ * Read-only by design: nothing here lets an agent opt itself out or resume a pause,
38
+ * so it says what will happen rather than offering a way round it.
39
+ */
40
+ export function agentGuardSummary(status: {
41
+ governor: "off" | "active" | "deferred" | "error";
42
+ protected: boolean; thresholdPct: number; autoResume: boolean;
43
+ pauses: readonly { phase: Pause["phase"]; resetsAt: string | null }[];
44
+ }): string[] {
45
+ if (status.governor === "deferred") return ["Session Guard: run by another plugin on this host (likely the standalone Session Guard); this session's protection is not visible from here."];
46
+ if (status.governor === "error") return ["Session Guard: status unavailable."];
47
+ if (status.governor === "off" || !status.protected) return ["Session Guard: off for this session. Reaching the session limit will not pause it here; the provider will simply refuse requests until reset."];
48
+ const pending = status.pauses.find(pause => ["pausing", "waiting", "resuming", "attention"].includes(pause.phase));
49
+ if (pending) {
50
+ return [`Session Guard: this session is paused for the session limit (${pending.phase})${pending.resetsAt ? `; the window resets ${pending.resetsAt}` : ""}.`];
51
+ }
52
+ return [`Session Guard: on. At ${status.thresholdPct}% of the session window Paseo pauses this session${status.autoResume
53
+ ? " and continues it in this same conversation once the window resets — no need to stop early or hand off because of the session limit."
54
+ : "; a person continues it after the reset."}`];
55
+ }
56
+
57
+ type GovernorView = { governor: "off" | "active" | "deferred" | "error"; standalone: boolean | null; error: string | null };
58
+
59
+ /**
60
+ * Why this plugin is not pausing anything, for the top of each Session Guard view.
61
+ * Null while it is the host's governor.
62
+ */
63
+ export function governorNotice(status: GovernorView): { title: string; hint: string } | null {
64
+ if (status.governor === "active") return null;
65
+ if (status.governor === "off") return { title: "Session Guard is off", hint: "Nothing is paused from Smart Session. Turn it on in Session Guard settings to pause sessions at the provider's session limit." };
66
+ if (status.governor === "deferred" && status.standalone !== false) {
67
+ return { title: "The standalone Session Guard plugin is in charge", hint: status.error ?? "Disable or uninstall Session Guard to hand over; its per-session choices and waiting pauses carry over." };
68
+ }
69
+ if (status.governor === "deferred") return { title: "Another process is running Session Guard", hint: status.error ?? "Nothing is paused from here until it stops." };
70
+ return { title: "Session Guard is not running", hint: status.error ?? "It will retry automatically." };
71
+ }
72
+
73
+ /**
74
+ * Who a pause in the shared state belongs to, when it is not this plugin.
75
+ *
76
+ * Both plugins read one state file, so a list here can show pauses only the other
77
+ * plugin can act on. Null means this plugin governs and can act on them.
78
+ */
79
+ export function pauseOwnerNote(status: GovernorView): string | null {
80
+ if (status.governor === "active") return null;
81
+ if (status.governor === "off") return "Saved by the standalone Session Guard plugin. Smart Session takes it over once Session Guard is on here and the standalone plugin is disabled or uninstalled.";
82
+ if (status.governor === "deferred" && status.standalone !== false) return "Managed by the standalone Session Guard plugin; change it there.";
83
+ if (status.governor === "deferred") return "Managed by another Session Guard process; it can be changed only from there.";
84
+ return "Session Guard is not running here, so this pause cannot be changed until it recovers.";
85
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+
3
+ export const TimelineDataSchema = z.object({
4
+ pauseId: z.string(), event: z.enum(["paused", "resumed", "released", "attention", "recovery"]),
5
+ at: z.string(), provider: z.string(), native: z.boolean(), autoResume: z.boolean(),
6
+ resetAt: z.string().nullable(), usedPct: z.number().nullable(), note: z.string().nullable(),
7
+ manualResume: z.boolean().optional(), demo: z.boolean().optional(), dismissed: z.boolean().optional(),
8
+ });
9
+ export type TimelineData = z.infer<typeof TimelineDataSchema>;
10
+ export const NotificationSchema = z.object({
11
+ id: z.string(), data: TimelineDataSchema, delivery: z.enum(["pending", "sending", "sent"]),
12
+ });
13
+ export type Notification = z.infer<typeof NotificationSchema>;
14
+
15
+ export function timelinePresentation(data: TimelineData) {
16
+ const provider = data.provider === "claude" ? "Claude" : data.provider === "codex" ? "Codex" : data.provider;
17
+ if (data.event === "released" && data.dismissed) return { title: "Pause dismissed", icon: "ShieldOff", tone: "muted" as const,
18
+ detail: "No message was sent. Protection is off for this session." + (data.native ? " Existing workers were released; their current work may continue." : "") };
19
+ if (data.event === "resumed") return { title: "Session resumed", icon: "Play", tone: "success" as const,
20
+ detail: data.manualResume ? "Resumed by you. Protection is off for this session; restore it from the composer pill when ready."
21
+ : data.native ? "Session and native subagents resumed after the quota reset." : "A continuation was sent after the quota reset." };
22
+ if (data.event === "released") return { title: data.native ? "Session released" : "Automatic resume cancelled", icon: "ShieldOff", tone: "muted" as const,
23
+ detail: data.note ?? (data.native ? "Suspended workers were released." : "Continue this session manually when ready.") };
24
+ if (data.event === "attention") return { title: "Pause needs attention", icon: "TriangleAlert", tone: "danger" as const,
25
+ detail: data.note ?? "Check this session before continuing manually." };
26
+ if (data.event === "recovery") return { title: "Waiting after Paseo restart", icon: "Pause", tone: "warning" as const,
27
+ detail: `The original workers ended, but this pause is saved. ${data.autoResume ? "Session Guard will continue this conversation after the quota resets." : "Automatic resume is off; continue manually when ready."} Your workspace and conversation remain in place.` };
28
+ if (data.demo) return { title: "Session Guard demo paused", icon: "Pause", tone: "warning" as const,
29
+ detail: "This empty test session is suspended so you can try Resume now. No provider quota was changed, and no task was sent. It will wait for you to resume it." };
30
+ const reset = data.resetAt ? Date.parse(data.resetAt) : NaN;
31
+ const resetLabel = Number.isFinite(reset) ? `Expected reset ${new Date(reset).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}.` : "The provider has not reported a reset time yet.";
32
+ return { title: "Paused for session limit", icon: "Pause", tone: "warning" as const,
33
+ detail: `${provider}${data.usedPct === null ? "" : ` · ${data.usedPct}% used`}. ${data.native ? "This session and its native subagents are paused." : "The active turn was interrupted."} ${resetLabel} ${data.autoResume ? "Automatic resume was scheduled after reset confirmation." : "Automatic resume was off; continue manually when ready."}` };
34
+ }
@@ -15,3 +15,21 @@ export type Teardown = () => void | Promise<void>;
15
15
  * exits and the plugin wedges at "Stopping" for the life of the daemon.
16
16
  */
17
17
  export const lifecycle: { teardowns: Teardown[] } = { teardowns: [] };
18
+
19
+ /**
20
+ * Runs every teardown even when one fails, then reports the first failure.
21
+ *
22
+ * One feature's cleanup throwing must not leave another's timer running: the
23
+ * subprocess would never exit and Paseo's stop step would hang.
24
+ */
25
+ export async function runTeardowns(teardowns: readonly Teardown[]): Promise<void> {
26
+ let failure: unknown = null;
27
+ for (const teardown of teardowns) {
28
+ try {
29
+ await teardown();
30
+ } catch (error) {
31
+ failure ??= error;
32
+ }
33
+ }
34
+ if (failure !== null) throw failure;
35
+ }