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.
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-smart-session",
3
3
  "type": "module",
4
- "version": "3.2.0",
4
+ "version": "3.3.0",
5
5
  "license": "MIT",
6
6
  "description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
7
7
  "repository": {
@@ -166,6 +166,15 @@ export class Engine {
166
166
  this.quotaFailures.set(agentId, { at: new Date(this.now()).toISOString(), turnId });
167
167
  }
168
168
  }
169
+ /**
170
+ * The agent's finished turn ended on the CLI's limit notice (`shared/guard/limit-notice.ts`).
171
+ * Claude completes such a turn rather than failing it, so this is how a pause is recorded
172
+ * when the CLI reaches the limit before the guard's reading does. Only reported while
173
+ * ZeroSub is enabled, whose own detection this mirrors.
174
+ */
175
+ limitReached(agentId: string, turnId: string | null) {
176
+ this.quotaFailures.set(agentId, { at: new Date(this.now()).toISOString(), turnId });
177
+ }
169
178
  private async leftDaemon(suspension: NonNullable<Pause["suspension"]>) {
170
179
  return !!this.processes && ((this.processes.alive && !await this.processes.alive(suspension)) ||
171
180
  (this.processes.owned && !await this.processes.owned(suspension))) === true;
@@ -327,7 +336,7 @@ export class Engine {
327
336
  pause.phase = "cancelled"; pause.note = note; pause.settledAt = new Date(this.now()).toISOString();
328
337
  }
329
338
  /** A normally completed turn racing cancel must never become a continuation. */
330
- async turnEnded(agentId: string, turnId: string | null, outcome: string) {
339
+ async turnEnded(agentId: string, turnId: string | null, outcome: string, endedOnLimit = false) {
331
340
  return this.exclusive(async () => {
332
341
  if (!["completed", "canceled"].includes(outcome)) return;
333
342
  const state = await this.store.read();
@@ -342,6 +351,12 @@ export class Engine {
342
351
  }
343
352
  if (outcome !== "completed") continue;
344
353
  if (turnId !== null && pause.turnId === turnId) {
354
+ if (endedOnLimit) {
355
+ // It completed on the limit notice, not with the work done: the same pause
356
+ // stands, now as the quota stop it is, rather than a release and a second pause.
357
+ if (!pause.wasQuotaFailure) { pause.wasQuotaFailure = true; changed = true; }
358
+ continue;
359
+ }
345
360
  await this.cancel(pause, "The interrupted turn completed before the pause."); changed = true;
346
361
  }
347
362
  }
@@ -397,6 +412,10 @@ export class Engine {
397
412
  this.accounts = accounts;
398
413
  const usageOf = (agentId: string, provider: string) => accounts?.usageFor(agentId, provider) ?? usage;
399
414
  const failsOver = (agentId: string, provider: string) => accounts?.failsOver(agentId, provider) ?? false;
415
+ // A pause can continue once its own account resets, or — with ZeroSub — once any
416
+ // account it can use has room, shown by a reading newer than the pause.
417
+ const ready = (pause: Pause) => resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now()) ||
418
+ (!!accounts && accounts.roomSince(pause.agentId, pause.provider, timestamp(pause.pausedAt)!) !== null);
400
419
  const byId = new Map(agents.map(a => [a.id, a]));
401
420
  this.providers = new Map(agents.map(a => [a.id, a.provider]));
402
421
  const currentDaemon = await this.processes?.daemonIdentity?.();
@@ -465,7 +484,7 @@ export class Engine {
465
484
  if ((agent.activeTurn?.turnId ?? null) !== pause.turnId && !await nativeRunning(agent.id)) {
466
485
  await this.cancel(pause, "The suspended turn was stopped or changed outside Session Guard."); changed = true; continue;
467
486
  }
468
- if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
487
+ if (pause.demo || !policy.autoResume || this.now() < this.nextResumeAt || !ready(pause)) continue;
469
488
  const latest = (await client.fetchAgent(pause.agentId))?.agent;
470
489
  if (!latest || latest.archivedAt || latest.status === "closed" || latest.provider !== pause.provider ||
471
490
  (latest.lastUserMessageAt ?? null) !== pause.lastUserMessageAt ||
@@ -488,8 +507,10 @@ export class Engine {
488
507
  await this.cancel(pause, "Work was continued outside Session Guard."); changed = true; continue;
489
508
  }
490
509
  const canContinue = (candidate: Agent) => candidate.status === "idle" || (pause.closedOnRestart && candidate.status === "closed") || (pause.wasQuotaFailure && candidate.status === "error");
510
+ // With ZeroSub, room on another account also continues it: the continuation meets the
511
+ // limit on this account, and ZeroSub fails it over there and carries it on.
491
512
  if (!canContinue(agent) || agent.pendingPermissions?.length || !policy.autoResume ||
492
- this.now() < this.nextResumeAt || !resetReady(pause, usageOf(pause.agentId, pause.provider), policy, this.now())) continue;
513
+ this.now() < this.nextResumeAt || !ready(pause)) continue;
493
514
  // Re-read just before sending: a person may have continued while usage was fetched.
494
515
  const latest = (await client.fetchAgent(pause.agentId))?.agent;
495
516
  if (!latest || !canContinue(latest) || latest.archivedAt || latest.pendingPermissions?.length ||
@@ -529,6 +550,9 @@ export class Engine {
529
550
  const window = sessionWindow(provider, policy);
530
551
  if (!window || (usedPct(window) ?? -1) < policy.thresholdPct) continue;
531
552
  if (failsOver(agent.id, agent.provider)) continue;
553
+ // Past the limit notice it is ZeroSub that switches, by its own rule: leave it be
554
+ // while that rule has an account to move to.
555
+ if (quotaFailed && accounts?.switchesAtLimit(agent.id, agent.provider)) continue;
532
556
  const fetchedAt = timestamp(provider?.fetchedAt ?? own.fetchedAt);
533
557
  const windowEnd = timestamp(window.resetsAt);
534
558
  if (windowEnd !== null && windowEnd <= this.now() && (fetchedAt === null || fetchedAt <= windowEnd)) continue;
@@ -16,7 +16,7 @@
16
16
  * pass, and gives the lock back once no pause is left waiting on it.
17
17
  */
18
18
 
19
- import type { PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
19
+ import type { PluginHandlerContext, PluginHookContext, PluginServerContext } from "@getpaseo/plugin/server";
20
20
 
21
21
  import {
22
22
  agentStatusRpc,
@@ -44,6 +44,18 @@ import { timelinePublisher } from "./timeline.ts";
44
44
  import { withRecordedUsage } from "./usage.ts";
45
45
  import { readZeroSub } from "./zerosub.ts";
46
46
  import { accountView } from "../../shared/guard/zerosub.ts";
47
+ import { limitNotice, turnRows, type TurnRow } from "../../shared/guard/limit-notice.ts";
48
+
49
+ /** How many timeline rows to read back for a finished turn's limit notice (as ZeroSub). */
50
+ const NOTICE_ROWS = 40;
51
+
52
+ /** Whether a finished turn ended on the CLI's limit notice, read from the agent's timeline tail. */
53
+ async function readLimitNotice(context: PluginHookContext, agentId: string, provider: string, turnId: string): Promise<boolean> {
54
+ if (provider !== "claude" && provider !== "codex") return false;
55
+ const page = await context.paseo.agents.ref(agentId).timeline.refetch({ direction: "tail", limit: NOTICE_ROWS });
56
+ const entries = page.entries as unknown as readonly { item: TurnRow; turnId?: string }[];
57
+ return limitNotice(provider, turnRows(entries, turnId)) !== null;
58
+ }
47
59
 
48
60
  /** The install id this plugin's own RPCs are reached through. */
49
61
  const PLUGIN_ID = "smart-session";
@@ -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
+ }
@@ -174,22 +174,63 @@ const limited = (account: ZeroSubAccount, now: number) => {
174
174
 
175
175
  /**
176
176
  * One account's usage as a provider row the guard reads, or an unavailable row when
177
- * there is no trustworthy reading. A limit the CLI reported is the freshest word on a
178
- * window, so it shows as full until it lifts.
177
+ * there is no trustworthy reading. Whether the account is out is `withOut`'s job.
179
178
  */
180
- export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount, now: number): ProviderUsage {
179
+ export function accountUsage(snapshot: ZeroSubSnapshot, account: ZeroSubAccount): ProviderUsage {
181
180
  const reading = snapshot.readings[account.id];
182
181
  if (!reading || reading.windows.length === 0) return { providerId: account.family, status: "unavailable", fetchedAt: null, windows: [] };
183
- const full = limited(account, now) && account.limitKind === "window";
184
182
  const windows: UsageWindow[] = reading.windows.map((window) => ({
185
183
  id: paseoWindowId(account.family, window),
186
- usedPct: full ? Math.max(100, window.usedPct) : window.usedPct,
184
+ usedPct: window.usedPct,
187
185
  remainingPct: null,
188
186
  resetsAt: window.resetsAt,
189
187
  }));
190
188
  return { providerId: account.family, status: "available", fetchedAt: reading.fetchedAt, windows };
191
189
  }
192
190
 
191
+ const pctOf = (window: UsageWindow): number | null =>
192
+ typeof window.usedPct === "number" ? window.usedPct : typeof window.remainingPct === "number" ? 100 - window.remainingPct : null;
193
+
194
+ /**
195
+ * When an account is out, the time it gets room again (ms); null while it has room.
196
+ *
197
+ * Out means ZeroSub recorded a limit on it (the CLI's notice, or a full reading), or a
198
+ * window it reads that has not rolled over yet is full. That includes the weekly
199
+ * window: the session window the guard watches can show room while the account is
200
+ * out for days. A recorded budget limit counts too: the account takes no turn.
201
+ */
202
+ function outUntil(row: ProviderUsage, account: ZeroSubAccount, now: number): number | null {
203
+ const ends: number[] = [];
204
+ const until = time(account.limitedUntil);
205
+ if (until !== null && until > now) ends.push(until);
206
+ for (const window of row.status === "available" ? row.windows : []) {
207
+ const resets = time(window.resetsAt);
208
+ if (resets !== null && resets <= now) continue; // Rolled over since that reading.
209
+ if ((pctOf(window) ?? 0) >= 100) ends.push(resets ?? now);
210
+ }
211
+ return ends.length ? Math.max(...ends) : null;
212
+ }
213
+
214
+ /**
215
+ * An out account's row, with the guard's session windows shown full until it gets room
216
+ * again. The guard pauses and anchors on its session window alone, so this is how an
217
+ * account out on another window, or at a limit no reading shows yet, reads as out; the
218
+ * anchor is when the account as a whole has room again.
219
+ */
220
+ function withOut(row: ProviderUsage, account: ZeroSubAccount, now: number, sessionWindowIds: readonly string[]): ProviderUsage {
221
+ const until = outUntil(row, account, now);
222
+ if (until === null || row.status !== "available") return row;
223
+ let changed = false;
224
+ const windows = row.windows.map((window) => {
225
+ const resets = time(window.resetsAt);
226
+ // Already full until the account has room again: the reading says so itself.
227
+ if (!sessionWindowIds.includes(window.id) || ((pctOf(window) ?? 0) >= 100 && resets !== null && resets >= until)) return window;
228
+ changed = true;
229
+ return { ...window, usedPct: Math.max(100, pctOf(window) ?? 100), remainingPct: null, resetsAt: new Date(Math.max(until, resets ?? 0)).toISOString() };
230
+ });
231
+ return changed ? { ...row, windows } : row;
232
+ }
233
+
193
234
  /**
194
235
  * Whether an account could take an agent right now: usable by ZeroSub's own rules
195
236
  * (signed in, not set aside, no recorded limit) and, stricter than ZeroSub, with a
@@ -214,12 +255,32 @@ export function accountsWithRoom(snapshot: ZeroSubSnapshot, current: ZeroSubAcco
214
255
  account.family === current.family && account.id !== current.id && hasRoom(snapshot, account, thresholdPct, sessionWindowIds, now)).length;
215
256
  }
216
257
 
258
+ /**
259
+ * Whether ZeroSub would move the agent to another account at the limit, by ZeroSub's
260
+ * own rule (`isUsable` in its routing): automatic switching on and another account of
261
+ * the provider signed in, not set aside and with no recorded limit. Looser than
262
+ * `hasRoom`, deliberately: once the CLI has reported the limit it is ZeroSub that
263
+ * decides, and this is what it decides with.
264
+ */
265
+ export function switchesAtLimit(snapshot: ZeroSubSnapshot, current: ZeroSubAccount, now: number): boolean {
266
+ return snapshot.autoSwitch && snapshot.accounts.some((account) =>
267
+ account.family === current.family && account.id !== current.id && account.signedIn && !account.disabled && !limited(account, now));
268
+ }
269
+
217
270
  /** The view Session Guard's engine asks, once per pass. */
218
271
  export interface AccountView {
219
- /** Usage as the agent's own account sees it. */
272
+ /** Usage as the agent's own account sees it, with an out account's session windows shown full. */
220
273
  usageFor(agentId: string, provider: string): Usage;
221
274
  /** ZeroSub will move this agent to another account with room when its account hits the limit. */
222
275
  failsOver(agentId: string, provider: string): boolean;
276
+ /** After the CLI reported the limit: ZeroSub is moving this agent to another account itself. */
277
+ switchesAtLimit(agentId: string, provider: string): boolean;
278
+ /**
279
+ * Whether an account this agent can use has room, shown by a reading taken after
280
+ * `since` (a pause): its own account, or — when ZeroSub would fail it over there —
281
+ * another one. Null while every account is still out.
282
+ */
283
+ roomSince(agentId: string, provider: string, since: number): "own" | "elsewhere" | null;
223
284
  /** ZeroSub already carries this agent's Codex task on in another tab. */
224
285
  continuedElsewhere(agentId: string): boolean;
225
286
  }
@@ -230,19 +291,40 @@ export function accountView(
230
291
  policy: { thresholdPct: number; windowIds: readonly string[] },
231
292
  now: number,
232
293
  ): AccountView {
294
+ const roomAfter = (account: ZeroSubAccount, since: number) => {
295
+ const fetched = time(snapshot.readings[account.id]?.fetchedAt);
296
+ return fetched !== null && fetched > since && hasRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now);
297
+ };
233
298
  return {
234
299
  usageFor(agentId, provider) {
235
300
  const account = accountOf(snapshot, agentId, provider);
236
- // The CLI login is what Paseo and the recorder already measure, and measure more often.
237
- if (!account || account.kind === "main") return usage;
238
- const own = accountUsage(snapshot, account, now);
239
- return { ...usage, providers: [...usage.providers.filter((p) => p.providerId !== provider), { ...own, providerId: provider }] };
301
+ if (!account) return usage;
302
+ // The CLI login is what Paseo and the recorder already measure, and measure more
303
+ // often; ZeroSub only adds a limit it recorded on it.
304
+ const own = account.kind === "main"
305
+ ? usage.providers.find((p) => p.providerId === provider)
306
+ : { ...accountUsage(snapshot, account), providerId: provider };
307
+ if (!own) return usage;
308
+ const shown = withOut(own, account, now, policy.windowIds);
309
+ if (shown === own && account.kind === "main") return usage;
310
+ return { ...usage, providers: [...usage.providers.filter((p) => p.providerId !== provider), shown] };
240
311
  },
241
312
  failsOver(agentId, provider) {
242
313
  if (!snapshot.autoSwitch) return false;
243
314
  const account = accountOf(snapshot, agentId, provider);
244
315
  return account !== null && accountsWithRoom(snapshot, account, policy.thresholdPct, policy.windowIds, now) > 0;
245
316
  },
317
+ switchesAtLimit(agentId, provider) {
318
+ const account = accountOf(snapshot, agentId, provider);
319
+ return account !== null && switchesAtLimit(snapshot, account, now);
320
+ },
321
+ roomSince(agentId, provider, since) {
322
+ const account = accountOf(snapshot, agentId, provider);
323
+ if (!account) return null;
324
+ if (roomAfter(account, since)) return "own";
325
+ if (!snapshot.autoSwitch) return null;
326
+ return snapshot.accounts.some((other) => other.family === account.family && other.id !== account.id && roomAfter(other, since)) ? "elsewhere" : null;
327
+ },
246
328
  continuedElsewhere: (agentId) => snapshot.continuedElsewhere.has(agentId),
247
329
  };
248
330
  }