paseo-smart-session 2.2.0 → 3.1.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/docs/session-guard.md +16 -1
- package/index.server.ts +6 -2
- package/mcp.mjs +32 -3
- package/package.json +1 -1
- package/server/guard/engine.ts +37 -5
- package/server/guard/index.ts +19 -6
- package/server/guard/zerosub.ts +82 -0
- package/shared/guard/contracts.ts +2 -0
- package/shared/guard/presentation.ts +5 -0
- package/shared/guard/zerosub.ts +276 -0
- package/shared/smart-session.ts +16 -1
package/docs/session-guard.md
CHANGED
|
@@ -75,6 +75,21 @@ A new user message, another running turn, closing/archiving/removing the agent,
|
|
|
75
75
|
|
|
76
76
|
If a reload or connection loss interrupts an unconfirmed stop/send, the pill reads **Check session**. Delivery acceptance may be ambiguous, so the plugin leaves that session for manual inspection instead of retrying a message that might already have arrived. Confirmed waiting work continues automatically.
|
|
77
77
|
|
|
78
|
+
## With ZeroSub
|
|
79
|
+
|
|
80
|
+
[ZeroSub](https://github.com/kapybara-org/zerosub) runs Claude and Codex agents on several subscription accounts. When an account hits a limit, ZeroSub moves the agent to another account and continues it. It switches only on the CLI's own limit notice, so an agent the guard interrupts or freezes first never switches. Paseo and the recorder also measure only the CLI's own login. When ZeroSub is enabled on the host, Session Guard reads ZeroSub's registry and usage readings from `$PASEO_HOME/zerosub/` and changes its behaviour as follows:
|
|
81
|
+
|
|
82
|
+
- **Each agent is measured on its own account.**
|
|
83
|
+
- An agent on an account ZeroSub added is paused only when *that* account reaches the threshold.
|
|
84
|
+
- The CLI login keeps Paseo's and the recorder's reading.
|
|
85
|
+
- **ZeroSub goes first.** The guard doesn't pause an agent while ZeroSub's automatic switching is on and another account of the same provider has room. Room means signed in, not disabled, no recorded limit, and a reading under 30 minutes old that is below the threshold.
|
|
86
|
+
- A session the guard already froze is released when such an account appears, so the CLI reaches the limit and ZeroSub can switch it.
|
|
87
|
+
- Once no account has room, the guard pauses and continues as before.
|
|
88
|
+
- **A moved session continues sooner.** An interrupted session that ZeroSub moves to an account with room is continued as soon as that account's reading confirms the room. It doesn't wait for the old account's reset.
|
|
89
|
+
- **Codex tasks aren't continued twice.** When ZeroSub carries a Codex task on in another tab, the old tab's waiting continuation is cancelled.
|
|
90
|
+
|
|
91
|
+
The guard only reads account ids, flags, timestamps and percentages. It never writes ZeroSub's files and never opens an account's credential home. Without ZeroSub, or when its files can't be read, nothing changes.
|
|
92
|
+
|
|
78
93
|
## Daemon authentication
|
|
79
94
|
|
|
80
95
|
For password-protected daemons, Smart Session reads `PASEO_PASSWORD`, then `PASEO_PASSWORD_FILE`, then `~/paseo-hub/secrets/daemon-password`. It never logs or stores the password. Provider usage comes through Paseo; provider credentials are not read directly.
|
|
@@ -85,4 +100,4 @@ Workspace labels support text and color. Session Guard uses the short amber labe
|
|
|
85
100
|
|
|
86
101
|
## Agents
|
|
87
102
|
|
|
88
|
-
Inside Paseo, the `budget_status` tool adds one line about the calling session: whether Session Guard will pause it at the limit and continue it afterwards, or whether it is paused now. It covers only the caller, never other sessions or continuation text, and it cannot turn protection off or resume a pause.
|
|
103
|
+
Inside Paseo, the `budget_status` tool adds one line about the calling session: whether Session Guard will pause it at the limit and continue it afterwards, or whether it is paused now. It covers only the caller, never other sessions or continuation text, and it cannot turn protection off or resume a pause. Under ZeroSub, `budget_status` names the Claude account the session runs on. For an account ZeroSub added, it reports ZeroSub's reading of that account in place of the CLI login's history, and it says when ZeroSub has another account with room.
|
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/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";
|
|
@@ -64,6 +66,7 @@ const OFF_MESSAGE = "Session Guard is off on this host. Turn it on in Smart Sess
|
|
|
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";
|
|
@@ -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({
|
|
@@ -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."}`];
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working alongside ZeroSub, which runs Claude and Codex agents on several
|
|
3
|
+
* subscription accounts and moves an agent to another account when the CLI reports a
|
|
4
|
+
* limit.
|
|
5
|
+
*
|
|
6
|
+
* Without this, Session Guard measures only the CLI login's usage (all Paseo and the
|
|
7
|
+
* recorder can read) and applies it to every agent of that provider. Under ZeroSub
|
|
8
|
+
* that pauses agents on accounts with room, and its interrupt or SIGSTOP stops the CLI
|
|
9
|
+
* from ever printing the limit notice ZeroSub fails over on. So, when ZeroSub is
|
|
10
|
+
* enabled, the guard reads each agent's account and that account's usage from
|
|
11
|
+
* ZeroSub's own files, and leaves an agent alone while ZeroSub can move it to another
|
|
12
|
+
* account with room.
|
|
13
|
+
*
|
|
14
|
+
* ZeroSub's files are an internal format, so everything here is parsed defensively:
|
|
15
|
+
* anything unexpected reads as "no ZeroSub view", which is exactly the guard's
|
|
16
|
+
* behaviour without ZeroSub. Only ids, flags, timestamps and percentages are kept —
|
|
17
|
+
* never emails, and never anything from a credential file (none is opened).
|
|
18
|
+
*/
|
|
19
|
+
import type { ProviderUsage, Usage, UsageWindow } from "./policy.ts";
|
|
20
|
+
|
|
21
|
+
export type ZeroSubFamily = "claude" | "codex";
|
|
22
|
+
|
|
23
|
+
export interface ZeroSubAccount {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly family: ZeroSubFamily;
|
|
26
|
+
readonly kind: "main" | "managed";
|
|
27
|
+
/** A name the person chose, or null when ZeroSub derived it from the email. */
|
|
28
|
+
readonly label: string | null;
|
|
29
|
+
readonly signedIn: boolean;
|
|
30
|
+
readonly disabled: boolean;
|
|
31
|
+
readonly limitedUntil: string | null;
|
|
32
|
+
readonly limitKind: "window" | "budget" | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ZeroSubWindow {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly usedPct: number;
|
|
38
|
+
readonly resetsAt: string | null;
|
|
39
|
+
readonly minutes: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ZeroSubReading {
|
|
43
|
+
readonly fetchedAt: string;
|
|
44
|
+
readonly cached: boolean;
|
|
45
|
+
readonly windows: readonly ZeroSubWindow[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ZeroSubSnapshot {
|
|
49
|
+
readonly accounts: readonly ZeroSubAccount[];
|
|
50
|
+
/** Which account each agent's live session was opened on. */
|
|
51
|
+
readonly sessions: Readonly<Record<string, string>>;
|
|
52
|
+
/** Agents whose Codex task carries on in another tab (ZeroSub continuation chains). */
|
|
53
|
+
readonly continuedElsewhere: ReadonlySet<string>;
|
|
54
|
+
readonly readings: Readonly<Record<string, ZeroSubReading>>;
|
|
55
|
+
/** ZeroSub's "Automatic switching" preference. */
|
|
56
|
+
readonly autoSwitch: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** ZeroSub's ids for the CLI's own login (`server/state.ts` `MAIN_ACCOUNT_ID`). */
|
|
60
|
+
const MAIN_ACCOUNT: Record<ZeroSubFamily, string> = { claude: "claude-main", codex: "codex-main" };
|
|
61
|
+
|
|
62
|
+
/** ZeroSub reads usage at least every half hour for an account in use. Older is not "room". */
|
|
63
|
+
export const FRESH_READING_MS = 30 * 60_000;
|
|
64
|
+
|
|
65
|
+
const record = (value: unknown): Record<string, unknown> | null =>
|
|
66
|
+
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
67
|
+
const text = (value: unknown): string | null => (typeof value === "string" && value !== "" ? value : null);
|
|
68
|
+
const finite = (value: unknown): number | null => (typeof value === "number" && Number.isFinite(value) ? value : null);
|
|
69
|
+
const family = (value: unknown): ZeroSubFamily | null => (value === "claude" || value === "codex" ? value : null);
|
|
70
|
+
const time = (value: string | null | undefined): number | null => {
|
|
71
|
+
if (!value) return null;
|
|
72
|
+
const ms = Date.parse(value);
|
|
73
|
+
return Number.isFinite(ms) ? ms : null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function parseAccount(value: unknown): ZeroSubAccount | null {
|
|
77
|
+
const raw = record(value);
|
|
78
|
+
const id = text(raw?.id);
|
|
79
|
+
const fam = family(raw?.family);
|
|
80
|
+
if (!raw || !id || !fam) return null;
|
|
81
|
+
const label = text(raw.label);
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
family: fam,
|
|
85
|
+
kind: raw.kind === "managed" ? "managed" : "main",
|
|
86
|
+
// A generated label is the email, or part of it: never repeat it.
|
|
87
|
+
label: raw.autoLabel === false && label !== null && !label.includes("@") ? label : null,
|
|
88
|
+
signedIn: raw.signedIn !== false,
|
|
89
|
+
disabled: raw.disabled === true,
|
|
90
|
+
limitedUntil: text(raw.limitedUntil),
|
|
91
|
+
limitKind: raw.limitKind === "window" || raw.limitKind === "budget" ? raw.limitKind : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseReading(value: unknown): ZeroSubReading | null {
|
|
96
|
+
const raw = record(value);
|
|
97
|
+
const fetchedAt = text(raw?.fetchedAt);
|
|
98
|
+
if (!raw || !fetchedAt || time(fetchedAt) === null || !Array.isArray(raw.windows)) return null;
|
|
99
|
+
const windows: ZeroSubWindow[] = [];
|
|
100
|
+
for (const entry of raw.windows) {
|
|
101
|
+
const window = record(entry);
|
|
102
|
+
const id = text(window?.id);
|
|
103
|
+
const usedPct = finite(window?.usedPercent);
|
|
104
|
+
if (!window || !id || usedPct === null) continue;
|
|
105
|
+
windows.push({ id, usedPct, resetsAt: text(window.resetsAt), minutes: finite(window.minutes) });
|
|
106
|
+
}
|
|
107
|
+
return { fetchedAt, cached: raw.cached === true, windows };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* ZeroSub's registry (`state.json`), usage readings (`usage.json`) and preferences, as
|
|
112
|
+
* read from disk. Null when the registry is unusable; a missing usage file or
|
|
113
|
+
* preferences file only means "no readings" and "defaults".
|
|
114
|
+
*/
|
|
115
|
+
export function parseZeroSub(state: unknown, usage: unknown, preferences: unknown): ZeroSubSnapshot | null {
|
|
116
|
+
const raw = record(state);
|
|
117
|
+
if (!raw || !Array.isArray(raw.accounts)) return null;
|
|
118
|
+
const accounts = raw.accounts.map(parseAccount).filter((account): account is ZeroSubAccount => account !== null);
|
|
119
|
+
if (accounts.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
const sessions: Record<string, string> = {};
|
|
122
|
+
for (const [agentId, entry] of Object.entries(record(raw.sessions) ?? {})) {
|
|
123
|
+
const accountId = text(record(entry)?.accountId);
|
|
124
|
+
if (accountId) sessions[agentId] = accountId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A Codex task that moved on to another tab: every member but the active one.
|
|
128
|
+
const continuedElsewhere = new Set<string>();
|
|
129
|
+
for (const entry of Object.values(record(raw.continuations) ?? {})) {
|
|
130
|
+
const chain = record(entry);
|
|
131
|
+
const active = text(chain?.activeAgentId);
|
|
132
|
+
if (!chain || !active) continue;
|
|
133
|
+
for (const member of Object.keys(record(chain.members) ?? {})) if (member !== active) continuedElsewhere.add(member);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const readings: Record<string, ZeroSubReading> = {};
|
|
137
|
+
for (const [accountId, entry] of Object.entries(record(usage) ?? {})) {
|
|
138
|
+
const reading = parseReading(entry);
|
|
139
|
+
if (reading) readings[accountId] = reading;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Paseo stores plugin settings as `{ version, values }`; missing means ZeroSub's default (on).
|
|
143
|
+
const values = record(record(preferences)?.values);
|
|
144
|
+
return { accounts, sessions, continuedElsewhere, readings, autoSwitch: values?.autoSwitch !== false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The ZeroSub family a Paseo provider belongs to, if ZeroSub manages it. */
|
|
148
|
+
export function familyOf(provider: string): ZeroSubFamily | null {
|
|
149
|
+
return provider === "claude" || provider === "codex" ? provider : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The account an agent runs on: its live session's, else the CLI login, as ZeroSub decides. */
|
|
153
|
+
export function accountOf(snapshot: ZeroSubSnapshot, agentId: string, provider: string): ZeroSubAccount | null {
|
|
154
|
+
const fam = familyOf(provider);
|
|
155
|
+
if (!fam) return null;
|
|
156
|
+
const id = snapshot.sessions[agentId] ?? MAIN_ACCOUNT[fam];
|
|
157
|
+
return snapshot.accounts.find((account) => account.id === id && account.family === fam) ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A ZeroSub window under the id Paseo uses for the same window, which is what the
|
|
162
|
+
* guard's `windowIds` and pause anchors name. Codex windows are told apart by length.
|
|
163
|
+
*/
|
|
164
|
+
export function paseoWindowId(fam: ZeroSubFamily, window: ZeroSubWindow): string {
|
|
165
|
+
if (fam === "claude") return window.id === "seven_day" ? "weekly" : window.id;
|
|
166
|
+
if (window.minutes !== null) return window.minutes <= 24 * 60 ? "session" : "weekly";
|
|
167
|
+
return window.id === "primary" ? "session" : window.id === "secondary" ? "weekly" : window.id;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const limited = (account: ZeroSubAccount, now: number) => {
|
|
171
|
+
const until = time(account.limitedUntil);
|
|
172
|
+
return until !== null && until > now;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
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.
|
|
179
|
+
*/
|
|
180
|
+
export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount, now: number): ProviderUsage {
|
|
181
|
+
const reading = snapshot.readings[account.id];
|
|
182
|
+
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
|
+
const windows: UsageWindow[] = reading.windows.map((window) => ({
|
|
185
|
+
id: paseoWindowId(account.family, window),
|
|
186
|
+
usedPct: full ? Math.max(100, window.usedPct) : window.usedPct,
|
|
187
|
+
remainingPct: null,
|
|
188
|
+
resetsAt: window.resetsAt,
|
|
189
|
+
}));
|
|
190
|
+
return { providerId: account.family, status: "available", fetchedAt: reading.fetchedAt, windows };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether an account could take an agent right now: usable by ZeroSub's own rules
|
|
195
|
+
* (signed in, not set aside, no recorded limit) and, stricter than ZeroSub, with a
|
|
196
|
+
* fresh reading that shows room in every window. An account with no reading is not
|
|
197
|
+
* counted: deferring to a switch onto an account that may be just as full would only
|
|
198
|
+
* trade a pause for a failed turn.
|
|
199
|
+
*/
|
|
200
|
+
export function hasRoom(snapshot: ZeroSubSnapshot, account: ZeroSubAccount, thresholdPct: number, sessionWindowIds: readonly string[], now: number): boolean {
|
|
201
|
+
if (!account.signedIn || account.disabled || limited(account, now)) return false;
|
|
202
|
+
const reading = snapshot.readings[account.id];
|
|
203
|
+
const fetched = time(reading?.fetchedAt);
|
|
204
|
+
if (!reading || fetched === null || now - fetched > FRESH_READING_MS || reading.windows.length === 0) return false;
|
|
205
|
+
return reading.windows.every((window) => {
|
|
206
|
+
const limit = sessionWindowIds.includes(paseoWindowId(account.family, window)) ? thresholdPct : 100;
|
|
207
|
+
return window.usedPct < limit;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Other accounts of the agent's provider that ZeroSub could move it to now. */
|
|
212
|
+
export function accountsWithRoom(snapshot: ZeroSubSnapshot, current: ZeroSubAccount, thresholdPct: number, sessionWindowIds: readonly string[], now: number): number {
|
|
213
|
+
return snapshot.accounts.filter((account) =>
|
|
214
|
+
account.family === current.family && account.id !== current.id && hasRoom(snapshot, account, thresholdPct, sessionWindowIds, now)).length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The view Session Guard's engine asks, once per pass. */
|
|
218
|
+
export interface AccountView {
|
|
219
|
+
/** Usage as the agent's own account sees it. */
|
|
220
|
+
usageFor(agentId: string, provider: string): Usage;
|
|
221
|
+
/** ZeroSub will move this agent to another account with room when its account hits the limit. */
|
|
222
|
+
failsOver(agentId: string, provider: string): boolean;
|
|
223
|
+
/** ZeroSub already carries this agent's Codex task on in another tab. */
|
|
224
|
+
continuedElsewhere(agentId: string): boolean;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function accountView(
|
|
228
|
+
snapshot: ZeroSubSnapshot,
|
|
229
|
+
usage: Usage,
|
|
230
|
+
policy: { thresholdPct: number; windowIds: readonly string[] },
|
|
231
|
+
now: number,
|
|
232
|
+
): AccountView {
|
|
233
|
+
return {
|
|
234
|
+
usageFor(agentId, provider) {
|
|
235
|
+
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 }] };
|
|
240
|
+
},
|
|
241
|
+
failsOver(agentId, provider) {
|
|
242
|
+
if (!snapshot.autoSwitch) return false;
|
|
243
|
+
const account = accountOf(snapshot, agentId, provider);
|
|
244
|
+
return account !== null && accountsWithRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now) > 0;
|
|
245
|
+
},
|
|
246
|
+
continuedElsewhere: (agentId) => snapshot.continuedElsewhere.has(agentId),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** What `budget_status` says about the account an agent's numbers describe. */
|
|
251
|
+
export interface BudgetAccount {
|
|
252
|
+
/** `main` is the CLI's own login, which the recorder measures; `managed` is one ZeroSub added. */
|
|
253
|
+
readonly kind: "main" | "managed";
|
|
254
|
+
readonly label: string | null;
|
|
255
|
+
/** Other accounts of the same provider ZeroSub could move the agent to now. */
|
|
256
|
+
readonly otherAccountsWithRoom: number;
|
|
257
|
+
readonly autoSwitch: boolean;
|
|
258
|
+
/** For a managed account: ZeroSub's reading of it, which replaces the recorder's numbers. */
|
|
259
|
+
readonly reading: { readonly fetchedAt: string; readonly windows: readonly { readonly id: string; readonly pct: number; readonly resetsAt: string | null }[] } | null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The Claude account behind an agent's budget, as ZeroSub routes it. */
|
|
263
|
+
export function budgetAccount(snapshot: ZeroSubSnapshot, agentId: string, now: number): BudgetAccount | null {
|
|
264
|
+
const account = accountOf(snapshot, agentId, "claude");
|
|
265
|
+
if (!account) return null;
|
|
266
|
+
const reading = account.kind === "managed" ? snapshot.readings[account.id] : undefined;
|
|
267
|
+
return {
|
|
268
|
+
kind: account.kind,
|
|
269
|
+
label: account.label,
|
|
270
|
+
otherAccountsWithRoom: accountsWithRoom(snapshot, account, 100, [], now),
|
|
271
|
+
autoSwitch: snapshot.autoSwitch,
|
|
272
|
+
reading: reading
|
|
273
|
+
? { fetchedAt: reading.fetchedAt, windows: reading.windows.map((window) => ({ id: window.id, pct: window.usedPct, resetsAt: window.resetsAt })) }
|
|
274
|
+
: null,
|
|
275
|
+
};
|
|
276
|
+
}
|
package/shared/smart-session.ts
CHANGED
|
@@ -48,9 +48,24 @@ export const RecorderStatusSchema = z.object({
|
|
|
48
48
|
*/
|
|
49
49
|
export const budgetStatus = defineRpc({
|
|
50
50
|
name: "smart-session.budget",
|
|
51
|
-
|
|
51
|
+
/** The asking agent, so the answer can describe the account it runs on. Optional: panels omit it. */
|
|
52
|
+
input: z.object({ agentId: z.string().min(1).optional() }),
|
|
52
53
|
output: z.object({
|
|
53
54
|
windows: z.array(WindowStatusSchema),
|
|
55
|
+
/**
|
|
56
|
+
* Which Claude account `windows` describe, when ZeroSub runs agents on several. Absent
|
|
57
|
+
* without ZeroSub, or without an agent: then it is always the CLI's own login.
|
|
58
|
+
*/
|
|
59
|
+
account: z
|
|
60
|
+
.object({
|
|
61
|
+
kind: z.enum(["main", "managed"]),
|
|
62
|
+
label: z.string().nullable(),
|
|
63
|
+
otherAccountsWithRoom: z.number(),
|
|
64
|
+
autoSwitch: z.boolean(),
|
|
65
|
+
/** When ZeroSub took the reading `windows` come from; null when they are the recorder's. */
|
|
66
|
+
readAt: z.string().nullable(),
|
|
67
|
+
})
|
|
68
|
+
.optional(),
|
|
54
69
|
/** Finished session windows from the last 14 days, oldest first. */
|
|
55
70
|
past: z.array(PastWindowSchema).optional(),
|
|
56
71
|
recorder: RecorderStatusSchema,
|