paseo-smart-session 3.0.0 → 3.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.
- 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 +27 -16
- package/index.client.tsx +19 -34
- package/index.server.ts +6 -2
- package/mcp.mjs +32 -3
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/engine.ts +37 -5
- package/server/guard/index.ts +27 -14
- package/server/guard/zerosub.ts +82 -0
- package/shared/guard/contracts.ts +2 -0
- package/shared/guard/presentation.ts +71 -11
- package/shared/guard/timeline.ts +3 -3
- package/shared/guard/timing.ts +37 -0
- package/shared/guard/zerosub.ts +276 -0
- package/shared/smart-session.ts +16 -1
- 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/index.server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { contributeGuard } from "./server/guard/index.ts";
|
|
2
|
+
import { zerosubBudgetAccount } from "./server/guard/zerosub.ts";
|
|
2
3
|
import { contributeSessionMcp } from "./server/session-mcp.ts";
|
|
3
4
|
import { displayPreferences } from "./shared/display";
|
|
4
5
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
@@ -45,7 +46,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
45
46
|
server.registerSettings(displayPreferences);
|
|
46
47
|
// Pauses work at a provider's session limit; off until someone turns it on.
|
|
47
48
|
const removeGuard = contributeGuard(server);
|
|
48
|
-
server.handle(budgetStatus, async () => {
|
|
49
|
+
server.handle(budgetStatus, async ({ agentId }) => {
|
|
49
50
|
try {
|
|
50
51
|
const since = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
51
52
|
const samples = await readUsage({ sinceMs: since });
|
|
@@ -73,8 +74,11 @@ export default function contribute(server: PluginServerContext) {
|
|
|
73
74
|
.map((block) => ({ startAt: block.startAt, resetsAt: block.resetsAt, firstSeenAt: block.firstSeenAt, peakPct: block.peakPct }));
|
|
74
75
|
|
|
75
76
|
const newestInstant = newest === null ? null : effectiveAt(newest);
|
|
77
|
+
// Under ZeroSub the agent may run on another account than the one recorded.
|
|
78
|
+
const account = agentId === undefined ? null : await zerosubBudgetAccount(agentId).catch(() => null);
|
|
76
79
|
return {
|
|
77
|
-
windows,
|
|
80
|
+
windows: account?.windows ?? windows,
|
|
81
|
+
...(account ? { account: account.account } : {}),
|
|
78
82
|
past,
|
|
79
83
|
recorder: {
|
|
80
84
|
newestAt: newestInstant,
|
package/mcp.mjs
CHANGED
|
@@ -254,6 +254,32 @@ function describeRelative(iso) {
|
|
|
254
254
|
return ms >= 0 ? `in ${text}` : `${text} ago`;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Which account `budget_status` describes, when ZeroSub runs agents on several.
|
|
259
|
+
*
|
|
260
|
+
* Without ZeroSub the RPC sends no `account`, and nothing changes. A label is shown
|
|
261
|
+
* only when the person chose it; ZeroSub's generated labels come from the email.
|
|
262
|
+
*/
|
|
263
|
+
function accountLines(account) {
|
|
264
|
+
if (account === undefined || account === null) return { head: [], tail: [], fromRecorder: true };
|
|
265
|
+
const name = account.label === null ? "" : ` "${account.label}"`;
|
|
266
|
+
const head = [
|
|
267
|
+
account.kind === "main"
|
|
268
|
+
? `Account: the CLI's own Claude login${name} (ZeroSub routes this session there).`
|
|
269
|
+
: `Account: a Claude account ZeroSub added${name}, not the CLI login. These numbers are ZeroSub's reading of it${
|
|
270
|
+
account.readAt === null ? " (none yet)" : `, taken ${describeRelative(account.readAt)}`
|
|
271
|
+
}; there is no pace history for it.`,
|
|
272
|
+
];
|
|
273
|
+
const tail = [];
|
|
274
|
+
const others = account.otherAccountsWithRoom;
|
|
275
|
+
if (account.autoSwitch && others > 0) {
|
|
276
|
+
tail.push(
|
|
277
|
+
`ZeroSub has ${others} other Claude account${others === 1 ? "" : "s"} with room and moves this session to one when this account hits a limit, so a full window here is not a reason to stop early.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return { head, tail, fromRecorder: account.kind === "main" };
|
|
281
|
+
}
|
|
282
|
+
|
|
257
283
|
/**
|
|
258
284
|
* Session Guard's word on this session, for `budget_status`.
|
|
259
285
|
*
|
|
@@ -321,13 +347,14 @@ const TOOLS = {
|
|
|
321
347
|
"How much of the Claude plan's rolling 5-hour and weekly limits is already consumed, how fast, and when each window resets — and, inside Paseo, whether Session Guard will pause this session at the session limit and continue it after the reset. Call this before spawning several subagents, before a long autonomous run, or when deciding whether to use a more expensive model.",
|
|
322
348
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
323
349
|
async run() {
|
|
324
|
-
const result = await callPlugin("smart-session.budget", {});
|
|
350
|
+
const result = await callPlugin("smart-session.budget", AGENT_ID === null ? {} : { agentId: AGENT_ID });
|
|
325
351
|
if (result.error) return `Could not read plan usage: ${result.error}`;
|
|
326
352
|
const guard = await guardSummary();
|
|
353
|
+
const account = accountLines(result.account);
|
|
327
354
|
const interesting = (result.windows ?? []).filter(
|
|
328
355
|
(window) => window.id === "five_hour" || window.id === "seven_day" || window.pct > 0,
|
|
329
356
|
);
|
|
330
|
-
if (interesting.length === 0) return ["No plan-usage reading recorded yet.", ...guard].join("\n");
|
|
357
|
+
if (interesting.length === 0) return [...account.head, "No plan-usage reading recorded yet.", ...account.tail, ...guard].join("\n");
|
|
331
358
|
|
|
332
359
|
const lines = interesting.map((window) => {
|
|
333
360
|
const name = window.id === "five_hour" ? "Session (5h)" : window.id === "seven_day" ? "Week" : window.id;
|
|
@@ -340,8 +367,10 @@ const TOOLS = {
|
|
|
340
367
|
return `- ${parts.join(", ")}`;
|
|
341
368
|
});
|
|
342
369
|
|
|
370
|
+
lines.unshift(...account.head);
|
|
371
|
+
lines.push(...account.tail);
|
|
343
372
|
const recorder = result.recorder;
|
|
344
|
-
if (recorder?.ageSeconds !== null && recorder?.ageSeconds !== undefined && recorder.ageSeconds > 900) {
|
|
373
|
+
if (account.fromRecorder && recorder?.ageSeconds !== null && recorder?.ageSeconds !== undefined && recorder.ageSeconds > 900) {
|
|
345
374
|
lines.push(
|
|
346
375
|
`(Newest reading is ${Math.round(HOURS(recorder.ageSeconds * 1000) * 10) / 10}h old — treat these as a floor, not a current number.)`,
|
|
347
376
|
);
|
package/package.json
CHANGED
package/paseo-plugin.json
CHANGED
package/server/guard/engine.ts
CHANGED
|
@@ -9,6 +9,12 @@ import type { PublishTimeline } from "./timeline.ts";
|
|
|
9
9
|
* passes one that also consults Smart Session's recorder (see `./usage.ts`).
|
|
10
10
|
*/
|
|
11
11
|
export type ReadUsage = (client: GuardClient) => Promise<Usage>;
|
|
12
|
+
/**
|
|
13
|
+
* Which account each agent runs on, when an account switcher (ZeroSub) is enabled;
|
|
14
|
+
* null otherwise. See `shared/guard/zerosub.ts`.
|
|
15
|
+
*/
|
|
16
|
+
export type ReadAccounts = (usage: Usage, policy: Policy) => Promise<AccountView | null>;
|
|
17
|
+
import type { AccountView } from "../../shared/guard/zerosub.ts";
|
|
12
18
|
import {
|
|
13
19
|
isPending, protects, resetReady, sessionWindow, timestamp, usedPct,
|
|
14
20
|
type Agent, type Pause, type Policy, type State, type Usage,
|
|
@@ -23,6 +29,10 @@ export class Engine {
|
|
|
23
29
|
error: string | null = null;
|
|
24
30
|
timelineError: string | null = null;
|
|
25
31
|
usage: Usage | null = null;
|
|
32
|
+
/** The account view from the last pass, for the agent-facing status. */
|
|
33
|
+
accounts: AccountView | null = null;
|
|
34
|
+
/** Each agent's provider from the last pass. */
|
|
35
|
+
providers = new Map<string, string>();
|
|
26
36
|
private store: Store;
|
|
27
37
|
private policy: () => Promise<Policy>;
|
|
28
38
|
private connect: Connect;
|
|
@@ -31,12 +41,14 @@ export class Engine {
|
|
|
31
41
|
private publish?: PublishTimeline;
|
|
32
42
|
private publishing: Promise<void> | null = null;
|
|
33
43
|
private readUsage: ReadUsage;
|
|
44
|
+
private readAccounts: ReadAccounts;
|
|
34
45
|
constructor(store: Store, policy: () => Promise<Policy>, connect: Connect, now: () => number = Date.now, processes?: ProcessControl, publish?: PublishTimeline,
|
|
35
|
-
readUsage: ReadUsage = client => client.listProviderUsage()) {
|
|
46
|
+
readUsage: ReadUsage = client => client.listProviderUsage(), readAccounts: ReadAccounts = async () => null) {
|
|
36
47
|
this.store = store; this.policy = policy; this.connect = connect; this.now = now;
|
|
37
48
|
this.processes = processes;
|
|
38
49
|
this.publish = publish;
|
|
39
50
|
this.readUsage = readUsage;
|
|
51
|
+
this.readAccounts = readAccounts;
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
private async save(state: State) {
|
|
@@ -378,7 +390,15 @@ export class Engine {
|
|
|
378
390
|
const usage = await this.readUsage(client);
|
|
379
391
|
this.usage = usage;
|
|
380
392
|
this.checkedAt = new Date(this.now()).toISOString();
|
|
393
|
+
// With ZeroSub, each agent is measured on its own account, and an agent it can
|
|
394
|
+
// move to an account with room is left to it: pausing first would stop the CLI
|
|
395
|
+
// from reporting the limit ZeroSub fails over on. A failed read is "no ZeroSub".
|
|
396
|
+
const accounts = await this.readAccounts(usage, policy).catch(() => null);
|
|
397
|
+
this.accounts = accounts;
|
|
398
|
+
const usageOf = (agentId: string, provider: string) => accounts?.usageFor(agentId, provider) ?? usage;
|
|
399
|
+
const failsOver = (agentId: string, provider: string) => accounts?.failsOver(agentId, provider) ?? false;
|
|
381
400
|
const byId = new Map(agents.map(a => [a.id, a]));
|
|
401
|
+
this.providers = new Map(agents.map(a => [a.id, a.provider]));
|
|
382
402
|
const currentDaemon = await this.processes?.daemonIdentity?.();
|
|
383
403
|
let daemonPid: number | null = null;
|
|
384
404
|
const nativeRunning = async (id: string) => client.listProviderSubagents
|
|
@@ -417,6 +437,16 @@ export class Engine {
|
|
|
417
437
|
await this.cancel(pause, "Session changed, was closed, or protection was turned off."); changed = true; continue;
|
|
418
438
|
}
|
|
419
439
|
if (pause.phase !== "waiting") continue;
|
|
440
|
+
if (!pause.demo && !pause.manualResume && accounts) {
|
|
441
|
+
if (pause.wasQuotaFailure && !pause.suspension && accounts.continuedElsewhere(pause.agentId)) {
|
|
442
|
+
// ZeroSub carried this Codex task on in another tab; continuing here too would run it twice.
|
|
443
|
+
await this.cancel(pause, "ZeroSub continued this task on another account, so Session Guard will not continue it here."); changed = true; continue;
|
|
444
|
+
}
|
|
445
|
+
if (pause.suspension && failsOver(pause.agentId, pause.provider)) {
|
|
446
|
+
// Let the turn reach the limit, so ZeroSub moves it to the account with room.
|
|
447
|
+
await this.cancel(pause, "Released for ZeroSub: another account has room, so it switches this session there at the limit."); changed = true; continue;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
420
450
|
if (pause.suspension) {
|
|
421
451
|
if (!this.processes) throw new Error("Native worker protection is unavailable.");
|
|
422
452
|
const workerEnded = await this.leftDaemon(pause.suspension);
|
|
@@ -435,7 +465,7 @@ export class Engine {
|
|
|
435
465
|
if ((agent.activeTurn?.turnId ?? null) !== pause.turnId && !await nativeRunning(agent.id)) {
|
|
436
466
|
await this.cancel(pause, "The suspended turn was stopped or changed outside Session Guard."); changed = true; continue;
|
|
437
467
|
}
|
|
438
|
-
if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !resetReady(pause,
|
|
468
|
+
if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
|
|
439
469
|
const latest = (await client.fetchAgent(pause.agentId))?.agent;
|
|
440
470
|
if (!latest || latest.archivedAt || latest.status === "closed" || latest.provider !== pause.provider ||
|
|
441
471
|
(latest.lastUserMessageAt ?? null) !== pause.lastUserMessageAt ||
|
|
@@ -459,7 +489,7 @@ export class Engine {
|
|
|
459
489
|
}
|
|
460
490
|
const canContinue = (candidate: Agent) => candidate.status === "idle" || (pause.closedOnRestart && candidate.status === "closed") || (pause.wasQuotaFailure && candidate.status === "error");
|
|
461
491
|
if (!canContinue(agent) || agent.pendingPermissions?.length || !policy.autoResume ||
|
|
462
|
-
this.now() < this.nextResumeAt || !resetReady(pause,
|
|
492
|
+
this.now() < this.nextResumeAt || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
|
|
463
493
|
// Re-read just before sending: a person may have continued while usage was fetched.
|
|
464
494
|
const latest = (await client.fetchAgent(pause.agentId))?.agent;
|
|
465
495
|
if (!latest || !canContinue(latest) || latest.archivedAt || latest.pendingPermissions?.length ||
|
|
@@ -494,10 +524,12 @@ export class Engine {
|
|
|
494
524
|
agent.labels?.internal === "true" || !protects(policy, state.overrides, agent.id) ||
|
|
495
525
|
(policy.providerIds.length > 0 && !policy.providerIds.includes(agent.provider)) ||
|
|
496
526
|
state.pauses.some(p => p.agentId === agent.id && isPending(p))) continue;
|
|
497
|
-
const
|
|
527
|
+
const own = usageOf(agent.id, agent.provider);
|
|
528
|
+
const provider = own.providers.find(p => p.providerId === agent.provider);
|
|
498
529
|
const window = sessionWindow(provider, policy);
|
|
499
530
|
if (!window || (usedPct(window) ?? -1) < policy.thresholdPct) continue;
|
|
500
|
-
|
|
531
|
+
if (failsOver(agent.id, agent.provider)) continue;
|
|
532
|
+
const fetchedAt = timestamp(provider?.fetchedAt ?? own.fetchedAt);
|
|
501
533
|
const windowEnd = timestamp(window.resetsAt);
|
|
502
534
|
if (windowEnd !== null && windowEnd <= this.now() && (fetchedAt === null || fetchedAt <= windowEnd)) continue;
|
|
503
535
|
const hasBackground = nativeEnabled && agent.status !== "running" && await nativeRunning(agent.id);
|
package/server/guard/index.ts
CHANGED
|
@@ -35,13 +35,15 @@ import { agentGuardSummary, callerProtection } from "../../shared/guard/presenta
|
|
|
35
35
|
import { withDaemon } from "../daemon.ts";
|
|
36
36
|
import { freshestUsage } from "../store.ts";
|
|
37
37
|
import type { Connect, GuardClient } from "./client.ts";
|
|
38
|
-
import { Engine } from "./engine.ts";
|
|
38
|
+
import { Engine, type ReadAccounts } from "./engine.ts";
|
|
39
39
|
import { WorkspaceLabels } from "./labels.ts";
|
|
40
40
|
import { LinuxProcessControl, type ProcessControl } from "./processes.ts";
|
|
41
41
|
import { standaloneEnabled } from "./standalone.ts";
|
|
42
42
|
import { guardDataDir, LockHeldError, Store } from "./store.ts";
|
|
43
43
|
import { timelinePublisher } from "./timeline.ts";
|
|
44
44
|
import { withRecordedUsage } from "./usage.ts";
|
|
45
|
+
import { readZeroSub } from "./zerosub.ts";
|
|
46
|
+
import { accountView } from "../../shared/guard/zerosub.ts";
|
|
45
47
|
|
|
46
48
|
/** The install id this plugin's own RPCs are reached through. */
|
|
47
49
|
const PLUGIN_ID = "smart-session";
|
|
@@ -50,20 +52,21 @@ const PLUGIN_ID = "smart-session";
|
|
|
50
52
|
const DEFERRED_RETRY_SECONDS = 15;
|
|
51
53
|
|
|
52
54
|
const STANDALONE_MESSAGE =
|
|
53
|
-
"The standalone Session Guard plugin is enabled on this host, so Smart Session leaves
|
|
55
|
+
"The standalone Session Guard plugin is enabled on this host, so Smart Session leaves pausing at the limit to it. " +
|
|
54
56
|
"Disable or uninstall Session Guard to hand over; its per-session choices and waiting pauses carry over.";
|
|
55
57
|
const HANDED_BACK_MESSAGE =
|
|
56
|
-
"The standalone Session Guard plugin was enabled while Smart Session was
|
|
58
|
+
"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. " +
|
|
57
59
|
"Disable or uninstall Session Guard to hand back to Smart Session, or reload Session Guard to let it take over.";
|
|
58
60
|
const LOCK_HELD_MESSAGE =
|
|
59
|
-
"Another process
|
|
60
|
-
const
|
|
61
|
-
const
|
|
61
|
+
"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.";
|
|
62
|
+
const OFF_MESSAGE = "Pause at the limit is off on this host. Turn it on in Smart Session settings.";
|
|
63
|
+
const UNCHECKED_MESSAGE = "Smart Session could not check which plugins are installed, so it is not pausing anything yet. It will retry.";
|
|
62
64
|
|
|
63
65
|
/** The daemon connection and process control, replaceable only by tests. */
|
|
64
66
|
export interface GuardDependencies {
|
|
65
67
|
connect?: Connect;
|
|
66
68
|
processes?: ProcessControl;
|
|
69
|
+
readAccounts?: ReadAccounts;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
export function contributeGuard(server: PluginServerContext, options: GuardDependencies = {}): () => Promise<void> {
|
|
@@ -114,7 +117,12 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
114
117
|
const store = new Store(guardDataDir());
|
|
115
118
|
const readUsage = async (client: GuardClient): Promise<Usage> =>
|
|
116
119
|
withRecordedUsage(await client.listProviderUsage(), await freshestUsage().catch(() => null));
|
|
117
|
-
|
|
120
|
+
// With ZeroSub enabled, agents are measured on their own accounts (`shared/guard/zerosub.ts`).
|
|
121
|
+
const readAccounts = options.readAccounts ?? (async (usage: Usage, policy: Policy) => {
|
|
122
|
+
const snapshot = await readZeroSub();
|
|
123
|
+
return snapshot ? accountView(snapshot, usage, policy, Date.now()) : null;
|
|
124
|
+
});
|
|
125
|
+
const engine = new Engine(store, readPolicy, connect, Date.now, options.processes ?? new LinuxProcessControl(), publish, readUsage, readAccounts);
|
|
118
126
|
const labels = new WorkspaceLabels(store, (work) => engine.exclusive(work), connect, readPolicy);
|
|
119
127
|
|
|
120
128
|
let governor: Governor = "off";
|
|
@@ -126,7 +134,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
126
134
|
release = await store.lock();
|
|
127
135
|
} catch (error) {
|
|
128
136
|
governor = error instanceof LockHeldError ? "deferred" : "error";
|
|
129
|
-
lockError = governor === "deferred" ? LOCK_HELD_MESSAGE : "
|
|
137
|
+
lockError = governor === "deferred" ? LOCK_HELD_MESSAGE : "Pause at the limit could not take its lock. Check its saved state and try again.";
|
|
130
138
|
return;
|
|
131
139
|
}
|
|
132
140
|
try {
|
|
@@ -135,7 +143,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
135
143
|
lockError = null;
|
|
136
144
|
} catch {
|
|
137
145
|
governor = "error";
|
|
138
|
-
lockError = "
|
|
146
|
+
lockError = "Pause at the limit could not read its saved state (session-guard/state.json). Restore it before continuing.";
|
|
139
147
|
const unlock = release;
|
|
140
148
|
release = null;
|
|
141
149
|
await unlock?.();
|
|
@@ -189,7 +197,7 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
189
197
|
policy = await readPolicy();
|
|
190
198
|
settingsError = null;
|
|
191
199
|
} catch {
|
|
192
|
-
settingsError = "
|
|
200
|
+
settingsError = "The Pause at the limit settings are invalid. Reset them in Smart Session settings.";
|
|
193
201
|
}
|
|
194
202
|
if (!policy?.enabled && governor !== "active") {
|
|
195
203
|
governor = "off";
|
|
@@ -264,8 +272,10 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
264
272
|
const readState = async (): Promise<State> => (governor === "active" ? engine.exclusive(() => store.read()) : store.read());
|
|
265
273
|
const readPolicyOrDefaults = () => readPolicy().catch(() => PolicySchema.parse({}));
|
|
266
274
|
const emptyState = (): State => ({ version: 1, overrides: {}, pauses: [] });
|
|
267
|
-
const providerRows = (policy: Policy) =>
|
|
268
|
-
(governor === "active" ? (engine.usage?.providers ?? []) : []).map((
|
|
275
|
+
const providerRows = (policy: Policy, agentId?: string) =>
|
|
276
|
+
(governor === "active" ? (engine.usage?.providers ?? []) : []).map((listed) => {
|
|
277
|
+
// An agent sees its own account's numbers, which under ZeroSub may not be the CLI login's.
|
|
278
|
+
const provider = (agentId !== undefined && engine.accounts?.usageFor(agentId, listed.providerId).providers.find((p) => p.providerId === listed.providerId)) || listed;
|
|
269
279
|
const window = sessionWindow(provider, policy);
|
|
270
280
|
return { providerId: provider.providerId, usedPct: window ? usedPct(window) : null, resetsAt: window?.resetsAt ?? null };
|
|
271
281
|
});
|
|
@@ -301,6 +311,8 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
301
311
|
const policy = await readPolicyOrDefaults();
|
|
302
312
|
const state = await readState().catch(emptyState);
|
|
303
313
|
const protection = callerProtection(governor === "active" && policy.enabled, policy.protectByDefault, state.overrides[agentId]);
|
|
314
|
+
const agentProvider = engine.providers.get(agentId);
|
|
315
|
+
const failsOver = governor === "active" && agentProvider !== undefined && (engine.accounts?.failsOver(agentId, agentProvider) ?? false);
|
|
304
316
|
const pauses = state.pauses
|
|
305
317
|
.filter((pause) => pause.agentId === agentId)
|
|
306
318
|
.map((pause) => ({ id: pause.id, phase: pause.phase, provider: pause.provider, resetsAt: pause.anchorResetsAt }));
|
|
@@ -310,8 +322,9 @@ export function contributeGuard(server: PluginServerContext, options: GuardDepen
|
|
|
310
322
|
thresholdPct: policy.thresholdPct,
|
|
311
323
|
autoResume: policy.autoResume,
|
|
312
324
|
pauses,
|
|
313
|
-
providers: providerRows(policy),
|
|
314
|
-
summary: agentGuardSummary({ governor, protected: protection.protected, thresholdPct: policy.thresholdPct, autoResume: policy.autoResume, pauses }),
|
|
325
|
+
providers: providerRows(policy, agentId),
|
|
326
|
+
summary: agentGuardSummary({ governor, protected: protection.protected, thresholdPct: policy.thresholdPct, autoResume: policy.autoResume, pauses, failsOver }),
|
|
327
|
+
...(failsOver ? { failsOver } : {}),
|
|
315
328
|
};
|
|
316
329
|
});
|
|
317
330
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding and reading ZeroSub (`@kapybara/zerosub`) on this host, read-only.
|
|
3
|
+
*
|
|
4
|
+
* What the readings mean, and why the guard wants them, is in
|
|
5
|
+
* `shared/guard/zerosub.ts`. This file only answers "is ZeroSub enabled here" from
|
|
6
|
+
* Paseo's `config.json`, and reads its registry, usage readings and preferences. It
|
|
7
|
+
* never writes anything of ZeroSub's and never opens an account home, where the
|
|
8
|
+
* credentials are.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { budgetAccount, parseZeroSub, type ZeroSubSnapshot } from "../../shared/guard/zerosub.ts";
|
|
16
|
+
|
|
17
|
+
export const ZEROSUB_ID = "zerosub";
|
|
18
|
+
export const ZEROSUB_PACKAGE = "@kapybara/zerosub";
|
|
19
|
+
|
|
20
|
+
type ReadText = (path: string) => Promise<string>;
|
|
21
|
+
const readText: ReadText = (path) => readFile(path, "utf8");
|
|
22
|
+
|
|
23
|
+
const paseoHome = () => process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
24
|
+
|
|
25
|
+
async function readJson(read: ReadText, path: string): Promise<unknown> {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(await read(path)) as unknown;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const record = (value: unknown): Record<string, unknown> | null =>
|
|
34
|
+
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The install id ZeroSub is enabled under, or null. The id can be anything
|
|
38
|
+
* (`--id`), so the manifest and package name in its directory are checked too.
|
|
39
|
+
*/
|
|
40
|
+
export async function zerosubInstallId(home = paseoHome(), read: ReadText = readText): Promise<string | null> {
|
|
41
|
+
const plugins = record(record(await readJson(read, join(home, "config.json")))?.plugins);
|
|
42
|
+
for (const [id, value] of Object.entries(plugins ?? {})) {
|
|
43
|
+
const entry = record(value);
|
|
44
|
+
if (entry?.enabled !== true) continue;
|
|
45
|
+
if (id === ZEROSUB_ID) return id;
|
|
46
|
+
const path = typeof entry.path === "string" ? entry.path : null;
|
|
47
|
+
if (!path) continue;
|
|
48
|
+
if (record(await readJson(read, join(path, "paseo-plugin.json")))?.id === ZEROSUB_ID) return id;
|
|
49
|
+
if (record(await readJson(read, join(path, "package.json")))?.name === ZEROSUB_PACKAGE) return id;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** ZeroSub's view of accounts, or null when it is not enabled or its registry is unusable. */
|
|
55
|
+
export async function readZeroSub(home = paseoHome(), read: ReadText = readText): Promise<ZeroSubSnapshot | null> {
|
|
56
|
+
const id = await zerosubInstallId(home, read);
|
|
57
|
+
if (id === null) return null;
|
|
58
|
+
const dir = join(home, "zerosub");
|
|
59
|
+
const [state, usage, preferences] = await Promise.all([
|
|
60
|
+
readJson(read, join(dir, "state.json")),
|
|
61
|
+
readJson(read, join(dir, "usage.json")),
|
|
62
|
+
readJson(read, join(home, "plugin-settings", id, "preferences.json")),
|
|
63
|
+
]);
|
|
64
|
+
return parseZeroSub(state, usage, preferences);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What `budget_status` should report for an agent under ZeroSub: which account, and,
|
|
69
|
+
* for an account ZeroSub added, that account's own windows in place of the recorder's
|
|
70
|
+
* (which measure the CLI login). Null without ZeroSub.
|
|
71
|
+
*/
|
|
72
|
+
export async function zerosubBudgetAccount(agentId: string, home = paseoHome(), read: ReadText = readText, now = Date.now()) {
|
|
73
|
+
const snapshot = await readZeroSub(home, read);
|
|
74
|
+
const account = snapshot ? budgetAccount(snapshot, agentId, now) : null;
|
|
75
|
+
if (!account) return null;
|
|
76
|
+
const windows = account.reading?.windows.map((window) => ({ ...window, burnPctPerHour: null, projectedFullAt: null })) ?? null;
|
|
77
|
+
return {
|
|
78
|
+
account: { kind: account.kind, label: account.label, otherAccountsWithRoom: account.otherAccountsWithRoom, autoSwitch: account.autoSwitch, readAt: account.reading?.fetchedAt ?? null },
|
|
79
|
+
// A managed account without a reading yet: say so with no windows rather than show the CLI login's.
|
|
80
|
+
windows: account.kind === "managed" ? (windows ?? []) : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -41,6 +41,8 @@ export const agentStatusRpc = defineRpc({
|
|
|
41
41
|
pauses: z.array(z.object({ id: z.string(), phase: PauseSchema.shape.phase, provider: z.string(), resetsAt: z.string().nullable() })),
|
|
42
42
|
providers: z.array(ProviderSchema),
|
|
43
43
|
summary: z.array(z.string()),
|
|
44
|
+
/** ZeroSub moves this session to another account with room at the limit, so Session Guard leaves it to it. */
|
|
45
|
+
failsOver: z.boolean().optional(),
|
|
44
46
|
}),
|
|
45
47
|
});
|
|
46
48
|
export const setProtectionRpc = defineRpc({
|
|
@@ -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,
|
|
@@ -41,6 +41,8 @@ export function agentGuardSummary(status: {
|
|
|
41
41
|
governor: "off" | "active" | "deferred" | "error";
|
|
42
42
|
protected: boolean; thresholdPct: number; autoResume: boolean;
|
|
43
43
|
pauses: readonly { phase: Pause["phase"]; resetsAt: string | null }[];
|
|
44
|
+
/** ZeroSub can move this session to another account with room (see `shared/guard/zerosub.ts`). */
|
|
45
|
+
failsOver?: boolean;
|
|
44
46
|
}): string[] {
|
|
45
47
|
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
48
|
if (status.governor === "error") return ["Session Guard: status unavailable."];
|
|
@@ -49,6 +51,9 @@ export function agentGuardSummary(status: {
|
|
|
49
51
|
if (pending) {
|
|
50
52
|
return [`Session Guard: this session is paused for the session limit (${pending.phase})${pending.resetsAt ? `; the window resets ${pending.resetsAt}` : ""}.`];
|
|
51
53
|
}
|
|
54
|
+
if (status.failsOver) {
|
|
55
|
+
return ["Session Guard: on, but ZeroSub goes first. When this session's account reaches its limit, ZeroSub moves it to another account with room and continues it — no need to stop early or hand off. Session Guard pauses it only once no account has room."];
|
|
56
|
+
}
|
|
52
57
|
return [`Session Guard: on. At ${status.thresholdPct}% of the session window Paseo pauses this session${status.autoResume
|
|
53
58
|
? " 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
59
|
: "; a person continues it after the reset."}`];
|
|
@@ -57,17 +62,20 @@ export function agentGuardSummary(status: {
|
|
|
57
62
|
type GovernorView = { governor: "off" | "active" | "deferred" | "error"; standalone: boolean | null; error: string | null };
|
|
58
63
|
|
|
59
64
|
/**
|
|
60
|
-
* Why this plugin is not pausing anything, for the
|
|
61
|
-
* 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.
|
|
62
68
|
*/
|
|
63
69
|
export function governorNotice(status: GovernorView): { title: string; hint: string } | null {
|
|
64
70
|
if (status.governor === "active") return null;
|
|
65
|
-
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." };
|
|
66
72
|
if (status.governor === "deferred" && status.standalone !== false) {
|
|
67
|
-
|
|
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}` };
|
|
68
76
|
}
|
|
69
|
-
if (status.governor === "deferred") return { title: "Another process
|
|
70
|
-
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." };
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
/**
|
|
@@ -78,8 +86,60 @@ export function governorNotice(status: GovernorView): { title: string; hint: str
|
|
|
78
86
|
*/
|
|
79
87
|
export function pauseOwnerNote(status: GovernorView): string | null {
|
|
80
88
|
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
|
|
82
|
-
if (status.governor === "deferred" && status.standalone !== false) return "
|
|
83
|
-
if (status.governor === "deferred") return "
|
|
84
|
-
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.";
|
|
85
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
|
+
|