paseo-smart-session 3.2.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/docs/session-guard.md +2 -0
- package/package.json +1 -1
- package/server/guard/engine.ts +27 -3
- package/server/guard/index.ts +27 -3
- package/shared/guard/limit-notice.ts +87 -0
- package/shared/guard/zerosub.ts +92 -10
package/docs/session-guard.md
CHANGED
|
@@ -81,6 +81,8 @@ If a reload or connection loss interrupts an unconfirmed stop/send, the pill rea
|
|
|
81
81
|
- **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.
|
|
82
82
|
- A session the guard already froze is released when such an account appears, so the CLI reaches the limit and ZeroSub can switch it.
|
|
83
83
|
- Once no account has room, the guard pauses and continues as before.
|
|
84
|
+
- **Every account out pauses, whichever side sees it first.** An account is out when it's at or over the threshold, at a limit ZeroSub recorded, or has any full window, including the weekly one. When the guard's reading gets there first, it pauses ahead of the CLI as usual. When the CLI gets there first, Claude ends the turn with its "You've hit your … limit" reply rather than an error. The guard reads that reply the way ZeroSub does and records a waiting pause once ZeroSub has recorded the limit. A pause racing that reply is kept, not released and taken again.
|
|
85
|
+
- **Any account getting room continues it, once.** If the agent's own account resets, it continues there. If another account gets room first (confirmed by a ZeroSub reading taken after the pause), the guard sends its continuation anyway. That turn meets the limit on the old account, and ZeroSub moves the agent to the account with room and continues it with its own prompt. With automatic continuation off (`autoResume`), the pause waits for you as before.
|
|
84
86
|
- **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.
|
|
85
87
|
- **Codex tasks aren't continued twice.** When ZeroSub carries a Codex task on in another tab, the old tab's waiting continuation is cancelled.
|
|
86
88
|
|
package/package.json
CHANGED
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";
|
|
@@ -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
|
+
}
|
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
|
}
|