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.
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-smart-session",
3
3
  "type": "module",
4
- "version": "2.2.0",
4
+ "version": "3.1.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": {
@@ -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, usage, policy, this.now())) continue;
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, usage, policy, this.now())) continue;
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 provider = usage.providers.find(p => p.providerId === agent.provider);
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
- const fetchedAt = timestamp(provider?.fetchedAt ?? usage.fetchedAt);
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);
@@ -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
- const engine = new Engine(store, readPolicy, connect, Date.now, options.processes ?? new LinuxProcessControl(), publish, readUsage);
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((provider) => {
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
+ }
@@ -48,9 +48,24 @@ export const RecorderStatusSchema = z.object({
48
48
  */
49
49
  export const budgetStatus = defineRpc({
50
50
  name: "smart-session.budget",
51
- input: z.object({}),
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,