paseo-smart-session 3.1.0 → 3.3.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 (38) hide show
  1. package/README.md +34 -23
  2. package/client/chat.tsx +2 -2
  3. package/client/commands.ts +62 -0
  4. package/client/display.tsx +9 -62
  5. package/client/guard/index.tsx +11 -42
  6. package/client/guard/paused.tsx +91 -120
  7. package/client/guard/status.ts +113 -0
  8. package/client/guard/timeline.tsx +61 -56
  9. package/client/overview.tsx +59 -52
  10. package/client/pill-store.ts +8 -2
  11. package/client/pill-view.ts +61 -0
  12. package/client/pill.tsx +50 -47
  13. package/client/popover.tsx +147 -31
  14. package/client/screens.ts +6 -0
  15. package/client/settings.tsx +223 -83
  16. package/docs/media/before-after.png +0 -0
  17. package/docs/media/smart-session-guard.png +0 -0
  18. package/docs/media/smart-session-mobile.png +0 -0
  19. package/docs/media/smart-session-pill.png +0 -0
  20. package/docs/media/smart-session-settings.png +0 -0
  21. package/docs/navigation-verification.md +4 -4
  22. package/docs/session-guard.md +15 -17
  23. package/index.client.tsx +19 -34
  24. package/package.json +1 -1
  25. package/paseo-plugin.json +1 -0
  26. package/server/guard/engine.ts +27 -3
  27. package/server/guard/index.ts +35 -11
  28. package/shared/guard/limit-notice.ts +87 -0
  29. package/shared/guard/presentation.ts +66 -11
  30. package/shared/guard/timeline.ts +3 -3
  31. package/shared/guard/timing.ts +37 -0
  32. package/shared/guard/zerosub.ts +92 -10
  33. package/client/guard/pill-icons.tsx +0 -26
  34. package/client/guard/pills.ts +0 -132
  35. package/client/guard/settings.tsx +0 -114
  36. package/client/guard/surfaces.ts +0 -3
  37. package/docs/screenshots/display-settings-desktop.png +0 -0
  38. package/docs/screenshots/display-settings-mobile.png +0 -0
@@ -166,6 +166,15 @@ export class Engine {
166
166
  this.quotaFailures.set(agentId, { at: new Date(this.now()).toISOString(), turnId });
167
167
  }
168
168
  }
169
+ /**
170
+ * The agent's finished turn ended on the CLI's limit notice (`shared/guard/limit-notice.ts`).
171
+ * Claude completes such a turn rather than failing it, so this is how a pause is recorded
172
+ * when the CLI reaches the limit before the guard's reading does. Only reported while
173
+ * ZeroSub is enabled, whose own detection this mirrors.
174
+ */
175
+ limitReached(agentId: string, turnId: string | null) {
176
+ this.quotaFailures.set(agentId, { at: new Date(this.now()).toISOString(), turnId });
177
+ }
169
178
  private async leftDaemon(suspension: NonNullable<Pause["suspension"]>) {
170
179
  return !!this.processes && ((this.processes.alive && !await this.processes.alive(suspension)) ||
171
180
  (this.processes.owned && !await this.processes.owned(suspension))) === true;
@@ -327,7 +336,7 @@ export class Engine {
327
336
  pause.phase = "cancelled"; pause.note = note; pause.settledAt = new Date(this.now()).toISOString();
328
337
  }
329
338
  /** A normally completed turn racing cancel must never become a continuation. */
330
- async turnEnded(agentId: string, turnId: string | null, outcome: string) {
339
+ async turnEnded(agentId: string, turnId: string | null, outcome: string, endedOnLimit = false) {
331
340
  return this.exclusive(async () => {
332
341
  if (!["completed", "canceled"].includes(outcome)) return;
333
342
  const state = await this.store.read();
@@ -342,6 +351,12 @@ export class Engine {
342
351
  }
343
352
  if (outcome !== "completed") continue;
344
353
  if (turnId !== null && pause.turnId === turnId) {
354
+ if (endedOnLimit) {
355
+ // It completed on the limit notice, not with the work done: the same pause
356
+ // stands, now as the quota stop it is, rather than a release and a second pause.
357
+ if (!pause.wasQuotaFailure) { pause.wasQuotaFailure = true; changed = true; }
358
+ continue;
359
+ }
345
360
  await this.cancel(pause, "The interrupted turn completed before the pause."); changed = true;
346
361
  }
347
362
  }
@@ -397,6 +412,10 @@ export class Engine {
397
412
  this.accounts = accounts;
398
413
  const usageOf = (agentId: string, provider: string) => accounts?.usageFor(agentId, provider) ?? usage;
399
414
  const failsOver = (agentId: string, provider: string) => accounts?.failsOver(agentId, provider) ?? false;
415
+ // A pause can continue once its own account resets, or — with ZeroSub — once any
416
+ // account it can use has room, shown by a reading newer than the pause.
417
+ const ready = (pause: Pause) => resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now()) ||
418
+ (!!accounts && accounts.roomSince(pause.agentId, pause.provider, timestamp(pause.pausedAt)!) !== null);
400
419
  const byId = new Map(agents.map(a => [a.id, a]));
401
420
  this.providers = new Map(agents.map(a => [a.id, a.provider]));
402
421
  const currentDaemon = await this.processes?.daemonIdentity?.();
@@ -465,7 +484,7 @@ export class Engine {
465
484
  if ((agent.activeTurn?.turnId ?? null) !== pause.turnId && !await nativeRunning(agent.id)) {
466
485
  await this.cancel(pause, "The suspended turn was stopped or changed outside Session Guard."); changed = true; continue;
467
486
  }
468
- if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
487
+ if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !ready(pause)) continue;
469
488
  const latest = (await client.fetchAgent(pause.agentId))?.agent;
470
489
  if (!latest || latest.archivedAt || latest.status === "closed" || latest.provider !== pause.provider ||
471
490
  (latest.lastUserMessageAt ?? null) !== pause.lastUserMessageAt ||
@@ -488,8 +507,10 @@ export class Engine {
488
507
  await this.cancel(pause, "Work was continued outside Session Guard."); changed = true; continue;
489
508
  }
490
509
  const canContinue = (candidate: Agent) => candidate.status === "idle" || (pause.closedOnRestart && candidate.status === "closed") || (pause.wasQuotaFailure && candidate.status === "error");
510
+ // With ZeroSub, room on another account also continues it: the continuation meets the
511
+ // limit on this account, and ZeroSub fails it over there and carries it on.
491
512
  if (!canContinue(agent) || agent.pendingPermissions?.length || !policy.autoResume ||
492
- this.now() < this.nextResumeAt || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
513
+ this.now() < this.nextResumeAt || !ready(pause)) continue;
493
514
  // Re-read just before sending: a person may have continued while usage was fetched.
494
515
  const latest = (await client.fetchAgent(pause.agentId))?.agent;
495
516
  if (!latest || !canContinue(latest) || latest.archivedAt || latest.pendingPermissions?.length ||
@@ -529,6 +550,9 @@ export class Engine {
529
550
  const window = sessionWindow(provider, policy);
530
551
  if (!window || (usedPct(window) ?? -1) < policy.thresholdPct) continue;
531
552
  if (failsOver(agent.id, agent.provider)) continue;
553
+ // Past the limit notice it is ZeroSub that switches, by its own rule: leave it be
554
+ // while that rule has an account to move to.
555
+ if (quotaFailed && accounts?.switchesAtLimit(agent.id, agent.provider)) continue;
532
556
  const fetchedAt = timestamp(provider?.fetchedAt ?? own.fetchedAt);
533
557
  const windowEnd = timestamp(window.resetsAt);
534
558
  if (windowEnd !== null && windowEnd <= this.now() && (fetchedAt === null || fetchedAt <= windowEnd)) continue;
@@ -16,7 +16,7 @@
16
16
  * pass, and gives the lock back once no pause is left waiting on it.
17
17
  */
18
18
 
19
- import type { PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
19
+ import type { PluginHandlerContext, PluginHookContext, PluginServerContext } from "@getpaseo/plugin/server";
20
20
 
21
21
  import {
22
22
  agentStatusRpc,
@@ -44,6 +44,18 @@ import { timelinePublisher } from "./timeline.ts";
44
44
  import { withRecordedUsage } from "./usage.ts";
45
45
  import { readZeroSub } from "./zerosub.ts";
46
46
  import { accountView } from "../../shared/guard/zerosub.ts";
47
+ import { limitNotice, turnRows, type TurnRow } from "../../shared/guard/limit-notice.ts";
48
+
49
+ /** How many timeline rows to read back for a finished turn's limit notice (as ZeroSub). */
50
+ const NOTICE_ROWS = 40;
51
+
52
+ /** Whether a finished turn ended on the CLI's limit notice, read from the agent's timeline tail. */
53
+ async function readLimitNotice(context: PluginHookContext, agentId: string, provider: string, turnId: string): Promise<boolean> {
54
+ if (provider !== "claude" && provider !== "codex") return false;
55
+ const page = await context.paseo.agents.ref(agentId).timeline.refetch({ direction: "tail", limit: NOTICE_ROWS });
56
+ const entries = page.entries as unknown as readonly { item: TurnRow; turnId?: string }[];
57
+ return limitNotice(provider, turnRows(entries, turnId)) !== null;
58
+ }
47
59
 
48
60
  /** The install id this plugin's own RPCs are reached through. */
49
61
  const PLUGIN_ID = "smart-session";
@@ -52,15 +64,15 @@ const PLUGIN_ID = "smart-session";
52
64
  const DEFERRED_RETRY_SECONDS = 15;
53
65
 
54
66
  const STANDALONE_MESSAGE =
55
- "The standalone Session Guard plugin is enabled on this host, so Smart Session leaves session protection to it and pauses nothing. " +
67
+ "The standalone Session Guard plugin is enabled on this host, so Smart Session leaves pausing at the limit to it. " +
56
68
  "Disable or uninstall Session Guard to hand over; its per-session choices and waiting pauses carry over.";
57
69
  const HANDED_BACK_MESSAGE =
58
- "The standalone Session Guard plugin was enabled while Smart Session was protecting this host, so Smart Session released its paused sessions and stopped. " +
70
+ "The standalone Session Guard plugin was enabled while Smart Session was pausing at the limit here, so Smart Session released its paused sessions and stopped. " +
59
71
  "Disable or uninstall Session Guard to hand back to Smart Session, or reload Session Guard to let it take over.";
60
72
  const LOCK_HELD_MESSAGE =
61
- "Another process is already running Session Guard on this host, most likely a second Smart Session install. Nothing is paused from here until it stops.";
62
- const UNCHECKED_MESSAGE = "Session Guard could not check which plugins are installed, so it is not pausing anything yet. It will retry.";
63
- const OFF_MESSAGE = "Session Guard is off on this host. Turn it on in Smart Session's Session Guard settings.";
73
+ "Another process already pauses sessions at the limit on this host, most likely a second Smart Session install. Nothing is paused from here until it stops.";
74
+ const OFF_MESSAGE = "Pause at the limit is off on this host. Turn it on in Smart Session settings.";
75
+ const UNCHECKED_MESSAGE = "Smart Session could not check which plugins are installed, so it is not pausing anything yet. It will retry.";
64
76
 
65
77
  /** The daemon connection and process control, replaceable only by tests. */
66
78
  export interface GuardDependencies {
@@ -134,7 +146,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
134
146
  release = await store.lock();
135
147
  } catch (error) {
136
148
  governor = error instanceof LockHeldError ? "deferred" : "error";
137
- lockError = governor === "deferred" ? LOCK_HELD_MESSAGE : "Session Guard could not take its lock. Check its saved state and try again.";
149
+ lockError = governor === "deferred" ? LOCK_HELD_MESSAGE : "Pause at the limit could not take its lock. Check its saved state and try again.";
138
150
  return;
139
151
  }
140
152
  try {
@@ -143,7 +155,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
143
155
  lockError = null;
144
156
  } catch {
145
157
  governor = "error";
146
- lockError = "Session Guard could not read its saved state. Restore state.json before continuing.";
158
+ lockError = "Pause at the limit could not read its saved state (session-guard/state.json). Restore it before continuing.";
147
159
  const unlock = release;
148
160
  release = null;
149
161
  await unlock?.();
@@ -197,7 +209,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
197
209
  policy = await readPolicy();
198
210
  settingsError = null;
199
211
  } catch {
200
- settingsError = "Session Guard settings are invalid. Reset them in Session Guard settings.";
212
+ settingsError = "The Pause at the limit settings are invalid. Reset them in Smart Session settings.";
201
213
  }
202
214
  if (!policy?.enabled && governor !== "active") {
203
215
  governor = "off";
@@ -366,12 +378,24 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
366
378
  const removeTurnStarted = server.on("agent.turn_started", () => {
367
379
  if (governor !== "off") void refresh();
368
380
  });
369
- const removeTurnEnded = server.on("agent.turn_ended", (event) => {
381
+ const removeTurnEnded = server.on("agent.turn_ended", (event, context) => {
370
382
  if (governor !== "active") return;
371
383
  // Never await a governor tick inside a lifecycle hook: cancelAgent itself causes
372
384
  // turn_ended, and awaiting both would deadlock.
373
- if (!stopped) void engine.turnEnded(event.agent.id, event.turnId, event.outcome.kind).catch(() => undefined);
374
385
  if (event.outcome.kind === "failed") engine.quotaFailure(event.agent.id, event.outcome.error.message, event.turnId);
386
+ // Claude completes a turn at the limit with a notice instead of failing it. Under
387
+ // ZeroSub, when no account has room, that notice is all there is: read it so the
388
+ // agent is paused and continued after a reset, and so a pause racing it is kept
389
+ // rather than cancelled as finished work. Without ZeroSub nothing changes.
390
+ if (event.outcome.kind === "completed" && event.turnId && engine.accounts && !stopped) {
391
+ const turnId = event.turnId;
392
+ void readLimitNotice(context, event.agent.id, event.agent.provider, turnId).catch(() => false).then(async (found) => {
393
+ if (stopped) return;
394
+ if (found) engine.limitReached(event.agent.id, turnId);
395
+ await engine.turnEnded(event.agent.id, turnId, "completed", found).catch(() => undefined);
396
+ void refresh();
397
+ });
398
+ } else if (!stopped) void engine.turnEnded(event.agent.id, event.turnId, event.outcome.kind).catch(() => undefined);
375
399
  void refresh();
376
400
  });
377
401
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Whether a finished turn ended on the CLI's limit notice.
3
+ *
4
+ * Claude Code does not fail a turn at the limit: it completes it with a synthetic reply,
5
+ * "You've hit your 5-hour limit · resets 3pm (…)". Paseo reports that as an ordinary
6
+ * completed turn, so the guard's failed-turn path never sees it. ZeroSub reads the same
7
+ * reply to fail over (`server/limits.ts` in `@kapybara/zerosub` 1.5.0); when it finds no
8
+ * account with room it stops there, and nothing continues the agent after the reset
9
+ * unless Session Guard records a pause. These are ZeroSub's patterns, so the guard
10
+ * treats exactly the turns ZeroSub treats as limits as limits.
11
+ *
12
+ * Only the first line of each candidate counts, and only CLI-generated places are read:
13
+ * error rows, warning notifications, `[System Error]` replies and, for Claude, the
14
+ * final two replies. Prose earlier in a turn never matches.
15
+ */
16
+
17
+ export interface TurnRow {
18
+ readonly type: string;
19
+ readonly text?: string;
20
+ readonly message?: string;
21
+ readonly level?: string;
22
+ }
23
+
24
+ const END = String.raw`(?=\s*(?:·|\.|$))`;
25
+ const APOSTROPHE = "['’]";
26
+ const CLAUDE: readonly RegExp[] = [
27
+ new RegExp(`^You${APOSTROPHE}ve hit your (?:5-hour |session |weekly |Opus |Sonnet |Fable )?limit${END}`, "i"),
28
+ new RegExp(`^You${APOSTROPHE}ve reached your (?:Fable|Opus|Sonnet|Haiku)(?: [\\w.]{1,10})? limit${END}`),
29
+ /Claude AI usage limit reached\|\d{9,}/,
30
+ new RegExp(
31
+ `^You${APOSTROPHE}ve hit your (?:usage limit|usage credit limit|team${APOSTROPHE}s shared budget|(?:monthly|individual) spend limit|individual usage limit|(?:org|channel)${APOSTROPHE}s monthly (?:usage|spend) limit)${END}`,
32
+ "i",
33
+ ),
34
+ new RegExp(`^You${APOSTROPHE}re out of (?:usage credits|extra usage)${END}`, "i"),
35
+ /^Your org is out of usage\s*·/i,
36
+ ];
37
+ const CODEX: readonly RegExp[] = [
38
+ new RegExp(`^You${APOSTROPHE}ve hit your usage limit\\b`, "i"),
39
+ /^The usage limit has been reached\b/i,
40
+ /"type"\s*:\s*"usage_limit_reached"/,
41
+ /^Your workspace is out of credits\b/i,
42
+ /^You hit your spend cap\b/i,
43
+ ];
44
+
45
+ const SYSTEM_ERROR = /^\[System Error\]\s*/;
46
+ const MAX_NOTICE = 400;
47
+ const clean = (text: string) => text.replace(SYSTEM_ERROR, "").replace(/^API Error:\s*/i, "").trim();
48
+ const firstLine = (text: string) => (text.split("\n")[0] ?? text).trim();
49
+
50
+ /** The limit notice a finished turn ended on, given that turn's rows oldest first; null when none. */
51
+ export function limitNotice(provider: string, rows: readonly TurnRow[]): string | null {
52
+ const patterns = provider === "claude" ? CLAUDE : provider === "codex" ? CODEX : null;
53
+ if (!patterns) return null;
54
+ const found: string[] = [];
55
+ let replies = 0;
56
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
57
+ const row = rows[index]!;
58
+ if (row.type === "user_message") break;
59
+ if (row.type === "error" && row.message) found.push(row.message);
60
+ else if (row.type === "notification" && row.level !== "info" && row.message) found.push(row.message);
61
+ else if (row.type === "assistant_message" && typeof row.text === "string") {
62
+ if (SYSTEM_ERROR.test(row.text) || (provider === "claude" && replies < 2)) found.push(row.text);
63
+ replies += 1;
64
+ }
65
+ }
66
+ for (const text of found.map(clean)) {
67
+ if (text.length === 0 || text.length > MAX_NOTICE) continue;
68
+ const line = firstLine(text);
69
+ if (patterns.some((pattern) => pattern.test(provider === "claude" ? line : text))) return line;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * One turn's rows from a tail page of the agent's timeline, oldest first. Providers can
76
+ * reuse a turn id, so reading back from the turn's last row, another turn's prompt is
77
+ * where this one began (as ZeroSub's `turnRows`).
78
+ */
79
+ export function turnRows<T extends TurnRow>(entries: readonly { item: T; turnId?: string }[], turnId: string): T[] {
80
+ const rows: T[] = [];
81
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
82
+ const entry = entries[index]!;
83
+ if (entry.turnId === turnId) rows.push(entry.item);
84
+ else if (rows.length > 0 && entry.turnId && entry.item.type === "user_message") break;
85
+ }
86
+ return rows.reverse();
87
+ }
@@ -15,7 +15,7 @@ export function pausePresentation(pause: Pause, policy: Policy, now = Date.now()
15
15
  ? "Reset due · waiting for fresh usage"
16
16
  : `Reset ${new Date(reset).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`;
17
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"
18
+ : pause.phase === "resuming" ? "Resuming" : pause.demo ? "Demo · resume when ready" : !policy.enabled ? "Protection off · continuation on hold"
19
19
  : !policy.autoResume ? "Automatic resume off" : "Will resume automatically after reset";
20
20
  return {
21
21
  title: pause.title?.trim() || "Untitled session", state, resetLabel,
@@ -62,17 +62,20 @@ export function agentGuardSummary(status: {
62
62
  type GovernorView = { governor: "off" | "active" | "deferred" | "error"; standalone: boolean | null; error: string | null };
63
63
 
64
64
  /**
65
- * Why this plugin is not pausing anything, for the top of each Session Guard view.
66
- * Null while it is the host's governor.
65
+ * Why this plugin is not pausing anything, for the Protection part of each view.
66
+ * Null while it is the host's governor. The standalone plugin keeps its own name:
67
+ * it is a different plugin the person installed, not a part of this one.
67
68
  */
68
69
  export function governorNotice(status: GovernorView): { title: string; hint: string } | null {
69
70
  if (status.governor === "active") return null;
70
- 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." };
71
+ if (status.governor === "off") return { title: "Pause at the limit is off", hint: "Nothing pauses at the session limit. Turn it on in Smart Session settings." };
71
72
  if (status.governor === "deferred" && status.standalone !== false) {
72
- 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." };
73
+ // One calm line; the server's longer story only adds what needs a look.
74
+ const stuck = status.error?.includes("could not be released") ? " Some paused sessions could not be released; check them." : "";
75
+ return { title: "The standalone Session Guard plugin handles pausing here", hint: `Disable or uninstall it to hand back; its per-session choices and waiting pauses carry over.${stuck}` };
73
76
  }
74
- if (status.governor === "deferred") return { title: "Another process is running Session Guard", hint: status.error ?? "Nothing is paused from here until it stops." };
75
- return { title: "Session Guard is not running", hint: status.error ?? "It will retry automatically." };
77
+ if (status.governor === "deferred") return { title: "Another process handles pausing here", hint: status.error ?? "Nothing is paused from here until it stops." };
78
+ return { title: "Pause at the limit isn't running", hint: status.error ?? "It will retry automatically." };
76
79
  }
77
80
 
78
81
  /**
@@ -83,8 +86,60 @@ export function governorNotice(status: GovernorView): { title: string; hint: str
83
86
  */
84
87
  export function pauseOwnerNote(status: GovernorView): string | null {
85
88
  if (status.governor === "active") return null;
86
- 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.";
87
- if (status.governor === "deferred" && status.standalone !== false) return "Managed by the standalone Session Guard plugin; change it there.";
88
- if (status.governor === "deferred") return "Managed by another Session Guard process; it can be changed only from there.";
89
- return "Session Guard is not running here, so this pause cannot be changed until it recovers.";
89
+ if (status.governor === "off") return "Saved by the standalone Session Guard plugin. Smart Session takes it over once Pause at the limit is on here and that plugin is disabled or uninstalled.";
90
+ if (status.governor === "deferred" && status.standalone !== false) return "Handled by the standalone Session Guard plugin; change it there.";
91
+ if (status.governor === "deferred") return "Handled by another process on this host; change it there.";
92
+ return "Pause at the limit isn't running here, so this pause can't be changed until it recovers.";
90
93
  }
94
+
95
+ /**
96
+ * A paused session's composer pill: the loud state, so the label is the moment it
97
+ * comes back. A clock rather than a countdown, so the label changes once instead of
98
+ * every minute (each change is a registration update).
99
+ */
100
+ export function pausePill(pause: Pause, policy: Policy, now = Date.now(), clock: (iso: string) => string = (iso) => new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })) {
101
+ const reset = timestamp(pause.anchorResetsAt);
102
+ // The title is also the phone sheet's title: one short sentence, the detail is in the sheet.
103
+ if (pause.phase === "attention") return { label: "Check session", title: "Paused, then something changed · check it", tone: "danger" as const };
104
+ if (pause.phase === "pausing") return { label: "Pausing…", title: "Pausing at the session limit", tone: "warning" as const };
105
+ if (pause.phase === "resuming") return { label: "Resuming…", title: "The window reset · continuing now", tone: "warning" as const };
106
+ if (pause.demo) return { label: "Paused · test", title: "Test pause · waits for you", tone: "warning" as const };
107
+ if (!policy.enabled) return { label: "Paused · on hold", title: "Paused · on hold while Pause at the limit is off", tone: "warning" as const };
108
+ if (reset === null) return { label: "Paused", title: "Paused at the limit · reset time not reported yet", tone: "warning" as const };
109
+ const at = clock(pause.anchorResetsAt!);
110
+ if (reset <= now) return { label: "Paused · reset due", title: `Paused at the limit · reset was ${at}`, tone: "warning" as const };
111
+ return policy.autoResume
112
+ ? { label: `Resumes ${at}`, title: `Paused at the limit · resumes ${at}`, tone: "warning" as const }
113
+ : { label: `Paused · resets ${at}`, title: `Paused at the limit · resets ${at}, continue by hand`, tone: "warning" as const };
114
+ }
115
+
116
+ /**
117
+ * The lead for a set of pending pauses: when they come back, or that one needs a
118
+ * look. Null when nothing is paused. `shared` is the one reset they all wait for,
119
+ * when they share it, so rows only repeat a time that differs.
120
+ */
121
+ export function pausedLead(pending: readonly Pause[], autoResume: boolean, now: number, clock: (ms: number) => string): { title: string; tone: "warning" | "danger"; shared: number | null; next: number | null } | null {
122
+ if (pending.length === 0) return null;
123
+ const waiting = pending.filter((p) => p.phase !== "attention");
124
+ const stuck = pending.length - waiting.length;
125
+ const resets = waiting.map((p) => timestamp(p.anchorResetsAt)).filter((v): v is number => v !== null && v > now).sort((a, b) => a - b);
126
+ const shared = resets.length === waiting.length && resets.length > 0 && resets[resets.length - 1]! - resets[0]! < 60_000 ? resets[0]! : null;
127
+ const n = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;
128
+ const tone = stuck > 0 ? "danger" as const : "warning" as const;
129
+ const next = resets[0] ?? null;
130
+ if (waiting.length === 0) return { title: `${n(stuck, "paused session needs", "paused sessions need")} a look`, tone, shared, next };
131
+ const look = stuck > 0 ? ` · ${stuck} ${stuck === 1 ? "needs" : "need"} a look` : "";
132
+ if (shared !== null) return { title: (autoResume ? `${n(waiting.length, "session resumes", "sessions resume")} at ${clock(shared)}` : `${n(waiting.length, "session waits", "sessions wait")} for the ${clock(shared)} reset`) + look, tone, shared, next };
133
+ if (next !== null) return { title: `${n(waiting.length, "session", "sessions")} paused · next ${autoResume ? "resumes" : "reset"} at ${clock(next)}${look}`, tone, shared, next };
134
+ return { title: `${n(waiting.length, "session waits", "sessions wait")} for a fresh reading${look}`, tone, shared, next };
135
+ }
136
+
137
+ /**
138
+ * The one action set for a waiting pause, wherever it shows: the chat card, the
139
+ * pill's popover and the overview's list. Same words, same order, same hint.
140
+ */
141
+ export const PAUSE_ACTIONS = {
142
+ resume: { label: "Resume now", busy: "Resuming…", icon: "Play", hint: "Continues before the reset and stops this session pausing at the limit. The provider may still refuse work until the reset." },
143
+ dismiss: { label: "Dismiss pause", busy: "Dismissing…", hint: "Lets the session go without sending a message and stops it pausing at the limit." },
144
+ note: "Resume now stops this session pausing at the limit · Dismiss sends nothing",
145
+ } as const;
@@ -17,15 +17,15 @@ export function timelinePresentation(data: TimelineData) {
17
17
  if (data.event === "released" && data.dismissed) return { title: "Pause dismissed", icon: "ShieldOff", tone: "muted" as const,
18
18
  detail: "No message was sent. Protection is off for this session." + (data.native ? " Existing workers were released; their current work may continue." : "") };
19
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."
20
+ detail: data.manualResume ? "Resumed by you. Pause at the limit is off for this session; turn it back on from the composer pill."
21
21
  : data.native ? "Session and native subagents resumed after the quota reset." : "A continuation was sent after the quota reset." };
22
22
  if (data.event === "released") return { title: data.native ? "Session released" : "Automatic resume cancelled", icon: "ShieldOff", tone: "muted" as const,
23
23
  detail: data.note ?? (data.native ? "Suspended workers were released." : "Continue this session manually when ready.") };
24
24
  if (data.event === "attention") return { title: "Pause needs attention", icon: "TriangleAlert", tone: "danger" as const,
25
25
  detail: data.note ?? "Check this session before continuing manually." };
26
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,
27
+ detail: `The original workers ended, but this pause is saved. ${data.autoResume ? "Smart Session continues this conversation after the window resets." : "Automatic resume is off; continue by hand when ready."} Your workspace and conversation remain in place.` };
28
+ if (data.demo) return { title: "Test pause", icon: "Pause", tone: "warning" as const,
29
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
30
  const reset = data.resetAt ? Date.parse(data.resetAt) : NaN;
31
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.";
@@ -0,0 +1,37 @@
1
+ import { PolicySchema, type Policy } from "./policy.ts";
2
+
3
+ /** The limit-timing fields, as typed. Nothing is saved until every one parses. */
4
+ export type Timing = { thresholdPct: string; pollSeconds: string; resetGraceSeconds: string; resumeSpacingSeconds: string; continuation: string; providerIds: string; windowIds: string };
5
+ const NUMERIC = ["thresholdPct", "pollSeconds", "resetGraceSeconds", "resumeSpacingSeconds"] as const;
6
+ const LISTS = ["providerIds", "windowIds"] as const;
7
+
8
+ export function timingOf(policy: Policy): Timing {
9
+ return {
10
+ thresholdPct: String(policy.thresholdPct), pollSeconds: String(policy.pollSeconds), resetGraceSeconds: String(policy.resetGraceSeconds),
11
+ resumeSpacingSeconds: String(policy.resumeSpacingSeconds), continuation: policy.continuation, providerIds: policy.providerIds.join(", "), windowIds: policy.windowIds.join(", "),
12
+ };
13
+ }
14
+ const list = (value: string) => [...new Set(value.split(",").map((s) => s.trim()).filter(Boolean))];
15
+
16
+ /** Each field checked on its own, so the message sits under the field it is about. */
17
+ export function checkTiming(draft: Timing): { values: Partial<Policy>; errors: Partial<Record<keyof Timing, string>> } {
18
+ const values: Record<string, unknown> = {};
19
+ const errors: Partial<Record<keyof Timing, string>> = {};
20
+ const range: Record<(typeof NUMERIC)[number], string> = {
21
+ thresholdPct: "Enter a number from 1 to 100.", pollSeconds: "Enter whole seconds from 5 to 300.",
22
+ resetGraceSeconds: "Enter whole seconds from 0 to 600.", resumeSpacingSeconds: "Enter whole seconds from 0 to 300.",
23
+ };
24
+ for (const key of NUMERIC) {
25
+ const raw = draft[key].trim();
26
+ const parsed = PolicySchema.shape[key].safeParse(raw === "" ? NaN : Number(raw));
27
+ if (parsed.success) values[key] = parsed.data; else errors[key] = range[key];
28
+ }
29
+ const continuation = PolicySchema.shape.continuation.safeParse(draft.continuation);
30
+ if (continuation.success) values.continuation = continuation.data; else errors.continuation = "Write a message of 1 to 8,000 characters.";
31
+ for (const key of LISTS) {
32
+ const parsed = PolicySchema.shape[key].safeParse(list(draft[key]));
33
+ if (parsed.success) values[key] = parsed.data; else errors[key] = "Name at least one window, e.g. five_hour.";
34
+ }
35
+ return { values: values as Partial<Policy>, errors };
36
+ }
37
+
@@ -174,22 +174,63 @@ const limited = (account: ZeroSubAccount, now: number) => {
174
174
 
175
175
  /**
176
176
  * One account's usage as a provider row the guard reads, or an unavailable row when
177
- * there is no trustworthy reading. A limit the CLI reported is the freshest word on a
178
- * window, so it shows as full until it lifts.
177
+ * there is no trustworthy reading. Whether the account is out is `withOut`'s job.
179
178
  */
180
- export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount, now: number): ProviderUsage {
179
+ export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount): ProviderUsage {
181
180
  const reading = snapshot.readings[account.id];
182
181
  if (!reading || reading.windows.length === 0) return { providerId: account.family, status: "unavailable", fetchedAt: null, windows: [] };
183
- const full = limited(account, now) && account.limitKind === "window";
184
182
  const windows: UsageWindow[] = reading.windows.map((window) => ({
185
183
  id: paseoWindowId(account.family, window),
186
- usedPct: full ? Math.max(100, window.usedPct) : window.usedPct,
184
+ usedPct: window.usedPct,
187
185
  remainingPct: null,
188
186
  resetsAt: window.resetsAt,
189
187
  }));
190
188
  return { providerId: account.family, status: "available", fetchedAt: reading.fetchedAt, windows };
191
189
  }
192
190
 
191
+ const pctOf = (window: UsageWindow): number | null =>
192
+ typeof window.usedPct === "number" ? window.usedPct : typeof window.remainingPct === "number" ? 100 - window.remainingPct : null;
193
+
194
+ /**
195
+ * When an account is out, the time it gets room again (ms); null while it has room.
196
+ *
197
+ * Out means ZeroSub recorded a limit on it (the CLI's notice, or a full reading), or a
198
+ * window it reads that has not rolled over yet is full. That includes the weekly
199
+ * window: the session window the guard watches can show room while the account is
200
+ * out for days. A recorded budget limit counts too: the account takes no turn.
201
+ */
202
+ function outUntil(row: ProviderUsage, account: ZeroSubAccount, now: number): number | null {
203
+ const ends: number[] = [];
204
+ const until = time(account.limitedUntil);
205
+ if (until !== null && until > now) ends.push(until);
206
+ for (const window of row.status === "available" ? row.windows : []) {
207
+ const resets = time(window.resetsAt);
208
+ if (resets !== null && resets <= now) continue; // Rolled over since that reading.
209
+ if ((pctOf(window) ?? 0) >= 100) ends.push(resets ?? now);
210
+ }
211
+ return ends.length ? Math.max(...ends) : null;
212
+ }
213
+
214
+ /**
215
+ * An out account's row, with the guard's session windows shown full until it gets room
216
+ * again. The guard pauses and anchors on its session window alone, so this is how an
217
+ * account out on another window, or at a limit no reading shows yet, reads as out; the
218
+ * anchor is when the account as a whole has room again.
219
+ */
220
+ function withOut(row: ProviderUsage, account: ZeroSubAccount, now: number, sessionWindowIds: readonly string[]): ProviderUsage {
221
+ const until = outUntil(row, account, now);
222
+ if (until === null || row.status !== "available") return row;
223
+ let changed = false;
224
+ const windows = row.windows.map((window) => {
225
+ const resets = time(window.resetsAt);
226
+ // Already full until the account has room again: the reading says so itself.
227
+ if (!sessionWindowIds.includes(window.id) || ((pctOf(window) ?? 0) >= 100 && resets !== null && resets >= until)) return window;
228
+ changed = true;
229
+ return { ...window, usedPct: Math.max(100, pctOf(window) ?? 100), remainingPct: null, resetsAt: new Date(Math.max(until, resets ?? 0)).toISOString() };
230
+ });
231
+ return changed ? { ...row, windows } : row;
232
+ }
233
+
193
234
  /**
194
235
  * Whether an account could take an agent right now: usable by ZeroSub's own rules
195
236
  * (signed in, not set aside, no recorded limit) and, stricter than ZeroSub, with a
@@ -214,12 +255,32 @@ export function accountsWithRoom(snapshot: ZeroSubSnapshot, current: ZeroSubAcco
214
255
  account.family === current.family && account.id !== current.id && hasRoom(snapshot, account, thresholdPct, sessionWindowIds, now)).length;
215
256
  }
216
257
 
258
+ /**
259
+ * Whether ZeroSub would move the agent to another account at the limit, by ZeroSub's
260
+ * own rule (`isUsable` in its routing): automatic switching on and another account of
261
+ * the provider signed in, not set aside and with no recorded limit. Looser than
262
+ * `hasRoom`, deliberately: once the CLI has reported the limit it is ZeroSub that
263
+ * decides, and this is what it decides with.
264
+ */
265
+ export function switchesAtLimit(snapshot: ZeroSubSnapshot, current: ZeroSubAccount, now: number): boolean {
266
+ return snapshot.autoSwitch && snapshot.accounts.some((account) =>
267
+ account.family === current.family && account.id !== current.id && account.signedIn && !account.disabled && !limited(account, now));
268
+ }
269
+
217
270
  /** The view Session Guard's engine asks, once per pass. */
218
271
  export interface AccountView {
219
- /** Usage as the agent's own account sees it. */
272
+ /** Usage as the agent's own account sees it, with an out account's session windows shown full. */
220
273
  usageFor(agentId: string, provider: string): Usage;
221
274
  /** ZeroSub will move this agent to another account with room when its account hits the limit. */
222
275
  failsOver(agentId: string, provider: string): boolean;
276
+ /** After the CLI reported the limit: ZeroSub is moving this agent to another account itself. */
277
+ switchesAtLimit(agentId: string, provider: string): boolean;
278
+ /**
279
+ * Whether an account this agent can use has room, shown by a reading taken after
280
+ * `since` (a pause): its own account, or — when ZeroSub would fail it over there —
281
+ * another one. Null while every account is still out.
282
+ */
283
+ roomSince(agentId: string, provider: string, since: number): "own" | "elsewhere" | null;
223
284
  /** ZeroSub already carries this agent's Codex task on in another tab. */
224
285
  continuedElsewhere(agentId: string): boolean;
225
286
  }
@@ -230,19 +291,40 @@ export function accountView(
230
291
  policy: { thresholdPct: number; windowIds: readonly string[] },
231
292
  now: number,
232
293
  ): AccountView {
294
+ const roomAfter = (account: ZeroSubAccount, since: number) => {
295
+ const fetched = time(snapshot.readings[account.id]?.fetchedAt);
296
+ return fetched !== null && fetched > since && hasRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now);
297
+ };
233
298
  return {
234
299
  usageFor(agentId, provider) {
235
300
  const account = accountOf(snapshot, agentId, provider);
236
- // The CLI login is what Paseo and the recorder already measure, and measure more often.
237
- if (!account || account.kind === "main") return usage;
238
- const own = accountUsage(snapshot, account, now);
239
- return { ...usage, providers: [...usage.providers.filter((p) => p.providerId !== provider), { ...own, providerId: provider }] };
301
+ if (!account) return usage;
302
+ // The CLI login is what Paseo and the recorder already measure, and measure more
303
+ // often; ZeroSub only adds a limit it recorded on it.
304
+ const own = account.kind === "main"
305
+ ? usage.providers.find((p) => p.providerId === provider)
306
+ : { ...accountUsage(snapshot, account), providerId: provider };
307
+ if (!own) return usage;
308
+ const shown = withOut(own, account, now, policy.windowIds);
309
+ if (shown === own && account.kind === "main") return usage;
310
+ return { ...usage, providers: [...usage.providers.filter((p) => p.providerId !== provider), shown] };
240
311
  },
241
312
  failsOver(agentId, provider) {
242
313
  if (!snapshot.autoSwitch) return false;
243
314
  const account = accountOf(snapshot, agentId, provider);
244
315
  return account !== null && accountsWithRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now) > 0;
245
316
  },
317
+ switchesAtLimit(agentId, provider) {
318
+ const account = accountOf(snapshot, agentId, provider);
319
+ return account !== null && switchesAtLimit(snapshot, account, now);
320
+ },
321
+ roomSince(agentId, provider, since) {
322
+ const account = accountOf(snapshot, agentId, provider);
323
+ if (!account) return null;
324
+ if (roomAfter(account, since)) return "own";
325
+ if (!snapshot.autoSwitch) return null;
326
+ return snapshot.accounts.some((other) => other.family === account.family && other.id !== account.id && roomAfter(other, since)) ? "elsewhere" : null;
327
+ },
246
328
  continuedElsewhere: (agentId) => snapshot.continuedElsewhere.has(agentId),
247
329
  };
248
330
  }
@@ -1,26 +0,0 @@
1
- import type { PluginButtonIcon, PluginButtonIconProps } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
3
-
4
- // Like Smart Compact, keep the host's pill chrome and color the icon slot.
5
- function GuardOnIcon({ theme, size }: PluginButtonIconProps) {
6
- return <Icon name="ShieldCheck" size={size} color={theme.colors.foreground} />;
7
- }
8
-
9
- function GuardOffIcon({ theme, size }: PluginButtonIconProps) {
10
- return <Icon name="ShieldOff" size={size} color={theme.colors.foregroundMuted} />;
11
- }
12
-
13
- function GuardPausedIcon({ theme, size }: PluginButtonIconProps) {
14
- return <Icon name="Pause" size={size} color={theme.colors.statusWarning} />;
15
- }
16
-
17
- function GuardAttentionIcon({ theme, size }: PluginButtonIconProps) {
18
- return <Icon name="TriangleAlert" size={size} color={theme.colors.statusDanger} />;
19
- }
20
-
21
- export const guardPillIcons: Readonly<Record<string, PluginButtonIcon>> = {
22
- ShieldCheck: GuardOnIcon,
23
- ShieldOff: GuardOffIcon,
24
- Pause: GuardPausedIcon,
25
- TriangleAlert: GuardAttentionIcon,
26
- };