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.
- package/README.md +34 -23
- package/client/chat.tsx +2 -2
- package/client/commands.ts +62 -0
- package/client/display.tsx +9 -62
- package/client/guard/index.tsx +11 -42
- package/client/guard/paused.tsx +91 -120
- package/client/guard/status.ts +113 -0
- package/client/guard/timeline.tsx +61 -56
- package/client/overview.tsx +59 -52
- package/client/pill-store.ts +8 -2
- package/client/pill-view.ts +61 -0
- package/client/pill.tsx +50 -47
- package/client/popover.tsx +147 -31
- package/client/screens.ts +6 -0
- package/client/settings.tsx +223 -83
- package/docs/media/before-after.png +0 -0
- package/docs/media/smart-session-guard.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/media/smart-session-settings.png +0 -0
- package/docs/navigation-verification.md +4 -4
- package/docs/session-guard.md +15 -17
- package/index.client.tsx +19 -34
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/engine.ts +27 -3
- package/server/guard/index.ts +35 -11
- package/shared/guard/limit-notice.ts +87 -0
- package/shared/guard/presentation.ts +66 -11
- package/shared/guard/timeline.ts +3 -3
- package/shared/guard/timing.ts +37 -0
- package/shared/guard/zerosub.ts +92 -10
- package/client/guard/pill-icons.tsx +0 -26
- package/client/guard/pills.ts +0 -132
- package/client/guard/settings.tsx +0 -114
- package/client/guard/surfaces.ts +0 -3
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
package/server/guard/engine.ts
CHANGED
|
@@ -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 || !
|
|
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 || !
|
|
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;
|
package/server/guard/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
62
|
-
const
|
|
63
|
-
const
|
|
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 : "
|
|
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 = "
|
|
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 = "
|
|
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 ? "
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
|
75
|
-
return { title: "
|
|
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
|
|
87
|
-
if (status.governor === "deferred" && status.standalone !== false) return "
|
|
88
|
-
if (status.governor === "deferred") return "
|
|
89
|
-
return "
|
|
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;
|
package/shared/guard/timeline.ts
CHANGED
|
@@ -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.
|
|
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
|
|
28
|
-
if (data.demo) return { title: "
|
|
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
|
+
|
package/shared/guard/zerosub.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
};
|