sideby 0.7.0 → 0.9.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/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261014T084812-64e992"}
1
+ {"id":"20261015T030631-4ed31c"}
@@ -71,8 +71,10 @@ export async function cmdList(a, io) {
71
71
  io.out(`Run ${installed.map((f) => f.bin).join(' or ')} once, then \`sideby new <family> <name>\`.`);
72
72
  }
73
73
  else {
74
+ // `= main`: the other Accounts of its Same Login, which share its Quota (spec §3.21).
75
+ const sameMark = (refs) => refs?.length ? ` ${c.dim(`= ${refs.map((r) => r.slice(r.indexOf(':') + 1)).join(', ')}`)}` : '';
74
76
  const rows = statuses.map((s) => [
75
- c.bold(s.ref),
77
+ `${c.bold(s.ref)}${sameMark(s.sameLogin)}`,
76
78
  s.isMain ? 'main' : s.kind === 'api' ? c.cyan('api') : 'sub',
77
79
  s.login === 'logged-in'
78
80
  ? c.green('✓')
@@ -87,6 +89,8 @@ export async function cmdList(a, io) {
87
89
  ...(hasDesktop ? [s.desktopApp?.running ? c.green('open') : c.dim('—')] : []),
88
90
  ]);
89
91
  io.out(table(rows, ['ACCOUNT', 'TYPE', 'LOGIN', 'EMAIL', 'MODEL', 'DIR', ...(hasDesktop ? ['DESKTOP'] : [])]));
92
+ if (statuses.some((s) => s.sameLogin?.length))
93
+ io.out(c.dim('\n= <account>: signed in as the same login as that account, so they share one quota.'));
90
94
  io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
91
95
  }
92
96
  for (const s of statuses)
@@ -216,6 +220,8 @@ export async function cmdNext(a, io) {
216
220
  }
217
221
  const now = rt.readContext().now;
218
222
  const note = (e) => {
223
+ if (e.sameLoginAs)
224
+ return `same login as ${e.sameLoginAs}, which shares its quota; skipped`;
219
225
  if (e.state === 'ready')
220
226
  return `fullest window ${Math.round(e.pressure ?? 0)}%`;
221
227
  if (e.state === 'unknown')
@@ -239,11 +245,13 @@ export async function cmdNext(a, io) {
239
245
  const why = plan.earliestReset
240
246
  ? `every ${family} account is at its limit; ${plan.earliestReset.ref} is back at ${clock(plan.earliestReset.at, now)}`
241
247
  : `no ${family} account can be used now`;
248
+ const same = plan.accounts.filter((e) => e.sameLoginAs).map((e) => e.ref);
249
+ const sameNote = same.length ? `; ${same.join(', ')} share the quota of ${plan.from}` : '';
242
250
  const next = [
243
251
  ...(out.length ? [`sign in with \`${out.join('` or `')}\``] : []),
244
252
  ...(apiLeft ? ['add --include-api to use an API account'] : []),
245
253
  ];
246
- io.err(c.yellow(`\n${why}${next.length ? `; ${next.join(', or ')}` : ''}.`));
254
+ io.err(c.yellow(`\n${why}${sameNote}${next.length ? `; ${next.join(', or ')}` : ''}.`));
247
255
  return 1;
248
256
  }
249
257
  const picked = plan.accounts.find((e) => e.ref === plan.pick);
@@ -530,7 +538,11 @@ export async function cmdQuota(a, io) {
530
538
  return 0;
531
539
  }
532
540
  const now = new Date();
533
- io.out(table(rows.map((r) => [c.bold(r.ref), quotaText(r.quota, now), usageText(r.usage)]), ['ACCOUNT', 'QUOTA', `USAGE (${USAGE_DAYS}d)`]));
541
+ io.out(table(rows.map((r) => [
542
+ c.bold(r.ref),
543
+ `${quotaText(r.quota, now)}${r.quota.status === 'ok' && r.quota.from ? c.dim(` read from ${r.quota.from} (same login)`) : ''}`,
544
+ usageText(r.usage),
545
+ ]), ['ACCOUNT', 'QUOTA', `USAGE (${USAGE_DAYS}d)`]));
534
546
  for (const r of rows)
535
547
  if (r.quota.status === 'unavailable' && r.quota.detail)
536
548
  io.out(c.dim(`${r.ref}: ${r.quota.detail}`));
@@ -36,6 +36,9 @@ const AccountStatus = Type.Intersect([
36
36
  identity: Type.Optional(Type.Object({ email: Type.Optional(Str), org: Type.Optional(Str) }, {
37
37
  description: "Who the account is signed in as, from the host's login file: identity fields only, never a token (ADR-0003).",
38
38
  })),
39
+ sameLogin: Type.Optional(Type.Array(Str, {
40
+ description: 'The other accounts signed in as the same login (same family, email and organization), which share one quota (spec §3.21). Absent when there are none.',
41
+ })),
39
42
  problems: Type.Optional(Type.Array(Str)),
40
43
  desktopApp: Type.Optional(Type.Object({ app: Str, installed: Type.Boolean(), running: Type.Boolean() }, {
41
44
  description: "The family's desktop app for this account (macOS, subscription accounts of claude and codex): whether it is installed and whether this account's own window runs now. Open it with `sideby desktop <account>`.",
@@ -106,6 +109,9 @@ const Quota = Type.Union([
106
109
  observedAt: Str,
107
110
  source: Str,
108
111
  plan: Type.Optional(Str),
112
+ from: Type.Optional(Type.String({
113
+ description: 'The account this reading was taken from, when it is another account of the same login (spec §3.21); observedAt is its time.',
114
+ })),
109
115
  }),
110
116
  Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
111
117
  ]);
@@ -263,6 +269,9 @@ export const OUTPUT_SCHEMAS = {
263
269
  next: Type.Object({
264
270
  schemaVersion: Version,
265
271
  family: Str,
272
+ from: Type.Optional(Type.String({
273
+ description: 'The account `sideby next` runs in (SIDEBY_ACCOUNT, set by sideby for the sessions it starts), when it belongs to this family.',
274
+ })),
266
275
  pick: Type.Union([Str, Type.Null()], {
267
276
  description: 'The recommended account; null when none can be used now (exit code 1).',
268
277
  }),
@@ -283,6 +292,12 @@ export const OUTPUT_SCHEMAS = {
283
292
  }),
284
293
  resetsAt: Type.Optional(Type.String({ description: 'For full: when its last full window resets.' })),
285
294
  observedAt: Type.Optional(Type.String({ description: "When the account's quota was recorded (its last session)." })),
295
+ quotaFrom: Type.Optional(Type.String({
296
+ description: 'The account the quota was read from, when it is another account of the same login.',
297
+ })),
298
+ sameLoginAs: Type.Optional(Type.String({
299
+ description: 'The current account (`from`), when this one is signed in as the same login: it shares that quota and is never picked.',
300
+ })),
286
301
  }), { description: 'Every account of the family, best first.' }),
287
302
  hasQuota: Type.Boolean({
288
303
  description: 'False when no account has quota data: the pick is then a guess, and `sideby next` does not start it (exit 1).',
@@ -139,11 +139,22 @@ export function instanceOf(app, appData, procs) {
139
139
  return after === '' || after.startsWith(' ') || after === '/' || after.startsWith('/ ');
140
140
  });
141
141
  }
142
- /** Brings a running app to the front through NSRunningApplication (no Automation permission needed). */
142
+ /**
143
+ * Brings a running app to the front as a Dock click does: a reopen event (`aevt/rapp`) to that one process, so an
144
+ * app whose windows were all closed opens one again, then NSRunningApplication activation. Activation alone makes
145
+ * such an app frontmost with nothing on screen (seen 2026-10-14 with the Codex app). Neither needs the Automation
146
+ * permission.
147
+ */
143
148
  async function activate(deps, pid) {
149
+ const p = Math.trunc(pid);
144
150
  const script = [
145
151
  "ObjC.import('AppKit');",
146
- `const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${Math.trunc(pid)});`,
152
+ `const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${p});`,
153
+ 'if (!app.isNil()) {',
154
+ ` const target = $.NSAppleEventDescriptor.descriptorWithProcessIdentifier(${p});`,
155
+ ' const reopen = $.NSAppleEventDescriptor.appleEventWithEventClassEventIDTargetDescriptorReturnIDTransactionID(0x61657674, 0x72617070, target, -1, 0);',
156
+ ' reopen.sendEventWithOptionsTimeoutError(1, 5, null);',
157
+ '}',
147
158
  "app.isNil() ? 'missing' : (app.activateWithOptions($.NSApplicationActivateAllWindows | $.NSApplicationActivateIgnoringOtherApps) ? 'ok' : 'refused')",
148
159
  ].join(' ');
149
160
  const r = await deps.exec(OSASCRIPT, ['-l', 'JavaScript', '-e', script]);
@@ -10,6 +10,8 @@ export interface HandoffIo {
10
10
  }
11
11
  /** How long the Host gets to exit after SIGTERM (time for its SessionEnd hooks) before sideby gives up. */
12
12
  export declare const EXIT_GRACE_MS = 10000;
13
+ /** Accounts the decision left out and why (spec §3.21), as `; skipped claude:main (…)`, or ''. */
14
+ export declare function skippedText(d: HandoffDecision): string;
13
15
  export declare function explain(d: HandoffDecision): string;
14
16
  /** `args` with `extra` before the first `--`, so a user's own `--` cannot turn sideby's options into prompt text. */
15
17
  export declare function beforeDashes(args: readonly string[], extra: readonly string[]): string[];
@@ -18,14 +18,20 @@ const PLAN_REFRESH_MS = 60_000;
18
18
  const READY_POLL_MS = 1000;
19
19
  const hookCommand = (family) => (event) => `sideby handoff-hook ${family} ${event}`;
20
20
  const clock = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
21
+ /** Accounts the decision left out and why (spec §3.21), as `; skipped claude:main (…)`, or ''. */
22
+ export function skippedText(d) {
23
+ if (d.kind === 'wait' || !d.skipped?.length)
24
+ return '';
25
+ return `; skipped ${d.skipped.map((x) => `${x.ref} (${x.reason})`).join(', ')}`;
26
+ }
21
27
  export function explain(d) {
22
28
  if (d.kind === 'wait')
23
29
  return `the full window resets at ${clock(d.until)}, so waiting beats moving on`;
24
30
  if (d.kind === 'none')
25
- return d.earliestReset
31
+ return `${d.earliestReset
26
32
  ? `${d.reason}; ${d.earliestReset.ref} is back first, at ${clock(d.earliestReset.at)}`
27
- : d.reason;
28
- return `next: ${d.ref}`;
33
+ : d.reason}${skippedText(d)}`;
34
+ return `next: ${d.ref}${skippedText(d)}`;
29
35
  }
30
36
  /** Resolves with the ready file once the hook writes one for `runId`; `stop()` ends the watch. */
31
37
  function watchReady(dir, runId) {
@@ -287,7 +293,7 @@ opts = {}) {
287
293
  await writeJsonFile(join(dir, FILES.chain), hops);
288
294
  await rm(join(dir, FILES.ready), { force: true });
289
295
  await rm(join(dir, FILES.request), { force: true });
290
- io.err(`sideby: ${account.ref} saved its session and is handing over because ${WHY[ready.trigger]}; next is ${decision.ref}. Brief: ${done.path}`);
296
+ io.err(`sideby: ${account.ref} saved its session and is handing over because ${WHY[ready.trigger]}; next is ${decision.ref}${skippedText(decision)}. Brief: ${done.path}`);
291
297
  if (settings.countdownSeconds > 0 &&
292
298
  (await countdown(io, settings.countdownSeconds, decision.ref, account.ref))) {
293
299
  io.err(`sideby: staying in ${account.ref}`);
@@ -9,14 +9,22 @@ export interface SelectCandidate {
9
9
  login: LoginState | 'not-needed';
10
10
  quota: QuotaResult;
11
11
  identity?: AccountIdentity;
12
+ /** The other Accounts of its Same Login (spec §3.21); they share its Quota. */
13
+ sameLogin?: readonly string[];
12
14
  /** Whether the Account's Host is on PATH. */
13
15
  installed: boolean;
14
16
  /** Whether its Family can start as the next Account (has `handoff`, and the run would be eligible). */
15
17
  receive: boolean;
16
18
  }
19
+ /** An Account the Handoff Order lists that was left out, and why (spec §3.21). */
20
+ export interface SkippedAccount {
21
+ ref: string;
22
+ reason: string;
23
+ }
17
24
  export type HandoffDecision = {
18
25
  kind: 'pick';
19
26
  ref: string;
27
+ skipped?: SkippedAccount[];
20
28
  } | {
21
29
  kind: 'wait';
22
30
  until: string;
@@ -27,6 +35,7 @@ export type HandoffDecision = {
27
35
  ref: string;
28
36
  at: string;
29
37
  };
38
+ skipped?: SkippedAccount[];
30
39
  };
31
40
  /** Organizations when both have one, else email domains when both have one, else the same. */
32
41
  export declare function sameOrganization(a?: AccountIdentity, b?: AccountIdentity): boolean;
@@ -70,7 +70,14 @@ export function selectNext(input) {
70
70
  const ranked = planHandoff(origin.family, own, now, { includeApi: true }).accounts.map((e) => byRef.get(e.ref));
71
71
  list = [...ranked, ...input.candidates.filter((c) => c.family !== origin.family && c.kind === 'api')];
72
72
  }
73
- const eligible = list.filter((c) => !used.has(c.ref) &&
73
+ // The same login shares the origin's Quota, so taking over there gains nothing (spec §3.21).
74
+ const sameLogin = new Set(origin.sameLogin ?? []);
75
+ const skipped = list
76
+ .filter((c) => sameLogin.has(c.ref))
77
+ .map((c) => ({ ref: c.ref, reason: `same login as ${origin.ref}, so it shares the same quota` }));
78
+ const withSkipped = skipped.length ? { skipped } : {};
79
+ const eligible = list.filter((c) => !sameLogin.has(c.ref) &&
80
+ !used.has(c.ref) &&
74
81
  c.installed &&
75
82
  c.receive &&
76
83
  // An API account pays per request, so it gets past no vendor's limit: sameFamily does not apply to it.
@@ -80,7 +87,7 @@ export function selectNext(input) {
80
87
  const first = states.find(({ e }) => e.state === 'ready' || e.state === 'unknown') ??
81
88
  states.find(({ e }) => e.state === 'api');
82
89
  if (first)
83
- return { kind: 'pick', ref: first.c.ref };
90
+ return { kind: 'pick', ref: first.c.ref, ...withSkipped };
84
91
  const full = states
85
92
  .filter(({ e }) => e.state === 'full' && e.resetsAt)
86
93
  .sort((a, b) => Date.parse(a.e.resetsAt) - Date.parse(b.e.resetsAt))[0];
@@ -90,5 +97,6 @@ export function selectNext(input) {
90
97
  ? 'every account that may take over is full or signed out'
91
98
  : 'no account may take over',
92
99
  ...(full ? { earliestReset: { ref: full.c.ref, at: full.e.resetsAt } } : {}),
100
+ ...withSkipped,
93
101
  };
94
102
  }
@@ -68,6 +68,18 @@ export async function handoffStatus(rt) {
68
68
  }
69
69
  if (unknown.length)
70
70
  notes.push(`order names ${unknown.join(', ')}, which sideby cannot find; they are skipped`);
71
+ // Same Login (spec §3.21): one note per group, so a skipped account in the order is no surprise.
72
+ const seen = new Set();
73
+ for (const a of accounts.filter((x) => x.family === f.id)) {
74
+ if (seen.has(a.ref))
75
+ continue;
76
+ const peers = await rt.sameLogin(a);
77
+ if (!peers.length)
78
+ continue;
79
+ for (const r of [a.ref, ...peers])
80
+ seen.add(r);
81
+ notes.push(`${[a.ref, ...peers].join(', ')} are signed in as the same login and share one quota; a handoff from one never goes to another`);
82
+ }
71
83
  families.push({
72
84
  family: f.id,
73
85
  starts,
@@ -7,6 +7,8 @@ export interface HandoffCandidate {
7
7
  kind: Account['kind'];
8
8
  login: LoginState | 'not-needed';
9
9
  quota: QuotaResult;
10
+ /** The other Accounts of its Same Login (spec §3.21). */
11
+ sameLogin?: readonly string[];
10
12
  }
11
13
  export interface HandoffEntry {
12
14
  ref: string;
@@ -18,9 +20,18 @@ export interface HandoffEntry {
18
20
  resetsAt?: string;
19
21
  /** When the Quota was recorded; it is only as fresh as the Account's last session. */
20
22
  observedAt?: string;
23
+ /** The Account the Quota was read from, when it is another Account of the same login (spec §3.21). */
24
+ quotaFrom?: string;
25
+ /**
26
+ * The current Account, when this one is signed in as the same login (spec §3.21): it shares that Quota, so it is
27
+ * never picked.
28
+ */
29
+ sameLoginAs?: string;
21
30
  }
22
31
  export interface HandoffPlan {
23
32
  family: string;
33
+ /** The Account this process runs in (`SIDEBY_ACCOUNT`), when it belongs to the Family. */
34
+ from?: string;
24
35
  /** The recommended Account, or null when none can be used now. */
25
36
  pick: string | null;
26
37
  /** Every Account, best first; the ones that cannot be picked come last. */
@@ -38,4 +49,5 @@ export interface HandoffPlan {
38
49
  }
39
50
  export declare function planHandoff(family: string, candidates: readonly HandoffCandidate[], now: Date, opts?: {
40
51
  includeApi?: boolean;
52
+ from?: string;
41
53
  }): HandoffPlan;
@@ -9,6 +9,7 @@ function entry(c, now) {
9
9
  kind: c.kind,
10
10
  pressure,
11
11
  ...(c.quota.status === 'ok' ? { observedAt: c.quota.observedAt } : {}),
12
+ ...(c.quota.status === 'ok' && c.quota.from ? { quotaFrom: c.quota.from } : {}),
12
13
  };
13
14
  if (c.kind === 'api')
14
15
  return { ...base, state: 'api' };
@@ -29,6 +30,7 @@ function entry(c, now) {
29
30
  return { ...base, state: 'full', ...(last === undefined ? {} : { resetsAt: new Date(last).toISOString() }) };
30
31
  }
31
32
  export function planHandoff(family, candidates, now, opts = {}) {
33
+ const current = opts.from ? candidates.find((c) => c.ref === opts.from) : undefined;
32
34
  const byRef = new Map(candidates.map((c) => [c.ref, c]));
33
35
  const tie = (a, b) => {
34
36
  const x = byRef.get(a.ref);
@@ -36,15 +38,22 @@ export function planHandoff(family, candidates, now, opts = {}) {
36
38
  return Number(y.isMain) - Number(x.isMain) || x.name.localeCompare(y.name, undefined, { numeric: true });
37
39
  };
38
40
  const accounts = candidates
39
- .map((c) => entry(c, now))
40
- .sort((a, b) => ORDER[a.state] - ORDER[b.state] ||
41
+ .map((c) => {
42
+ const e = entry(c, now);
43
+ return current?.sameLogin?.includes(c.ref) ? { ...e, sameLoginAs: current.ref } : e;
44
+ })
45
+ .sort((a, b) =>
46
+ // A Same Login partner of the current Account can never be picked, so it goes with the others that cannot.
47
+ Number(Boolean(a.sameLoginAs)) - Number(Boolean(b.sameLoginAs)) ||
48
+ ORDER[a.state] - ORDER[b.state] ||
41
49
  (a.state === 'ready' ? a.pressure - b.pressure : 0) ||
42
50
  (a.state === 'full' ? resetTime(a) - resetTime(b) || 0 : 0) ||
43
51
  tie(a, b));
44
- const usable = (e) => e.state === 'ready' || e.state === 'unknown' || (e.state === 'api' && Boolean(opts.includeApi));
52
+ const usable = (e) => !e.sameLoginAs &&
53
+ (e.state === 'ready' || e.state === 'unknown' || (e.state === 'api' && Boolean(opts.includeApi)));
45
54
  const pick = accounts.find(usable)?.ref ?? null;
46
55
  const hasQuota = accounts.some((e) => e.state === 'ready' || e.state === 'full');
47
- const plan = { family, pick, accounts, hasQuota };
56
+ const plan = { family, ...(current ? { from: current.ref } : {}), pick, accounts, hasQuota };
48
57
  if (pick === null) {
49
58
  const first = accounts.find((e) => e.state === 'full' && e.resetsAt);
50
59
  if (first?.resetsAt)
@@ -0,0 +1,24 @@
1
+ import type { Account, AccountIdentity, QuotaResult } from '../types.ts';
2
+ /**
3
+ * Families whose Identity tells one Quota apart. Codex is left out: its Identity is an email only, and one email can
4
+ * belong to several ChatGPT workspaces, each with its own Quota (ADR-0016).
5
+ */
6
+ export declare const SAME_LOGIN_FAMILIES: ReadonlySet<string>;
7
+ export interface LoginMember {
8
+ ref: string;
9
+ family: string;
10
+ kind: Account['kind'];
11
+ identity?: AccountIdentity;
12
+ }
13
+ /**
14
+ * Whether two Accounts are the same login: same Family (one that groups), both subscriptions, the same email, and the
15
+ * same organization (both have one and it is equal, or neither has one). Without an email nothing groups.
16
+ */
17
+ export declare function isSameLogin(a: LoginMember, b: LoginMember): boolean;
18
+ /** Ref → the other refs of its Same Login, in the order given; Accounts without one are left out. */
19
+ export declare function sameLoginGroups(members: readonly LoginMember[]): Map<string, string[]>;
20
+ /**
21
+ * The Quota an Account of a Same Login shows: the freshest `ok` reading of the group (its own wins a tie), marked
22
+ * with `from` when it is another member's. Without any `ok` reading the Account keeps its own result.
23
+ */
24
+ export declare function sharedQuota(ref: string, readings: ReadonlyMap<string, QuotaResult>, peers: readonly string[]): QuotaResult;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Families whose Identity tells one Quota apart. Codex is left out: its Identity is an email only, and one email can
3
+ * belong to several ChatGPT workspaces, each with its own Quota (ADR-0016).
4
+ */
5
+ export const SAME_LOGIN_FAMILIES = new Set(['claude']);
6
+ const norm = (s) => s?.trim().toLowerCase();
7
+ /**
8
+ * Whether two Accounts are the same login: same Family (one that groups), both subscriptions, the same email, and the
9
+ * same organization (both have one and it is equal, or neither has one). Without an email nothing groups.
10
+ */
11
+ export function isSameLogin(a, b) {
12
+ if (a.ref === b.ref || a.family !== b.family || !SAME_LOGIN_FAMILIES.has(a.family))
13
+ return false;
14
+ if (a.kind !== 'subscription' || b.kind !== 'subscription')
15
+ return false;
16
+ const ea = norm(a.identity?.email);
17
+ if (!ea || ea !== norm(b.identity?.email))
18
+ return false;
19
+ return norm(a.identity?.org) === norm(b.identity?.org);
20
+ }
21
+ /** Ref → the other refs of its Same Login, in the order given; Accounts without one are left out. */
22
+ export function sameLoginGroups(members) {
23
+ const out = new Map();
24
+ for (const a of members) {
25
+ const peers = members.filter((b) => isSameLogin(a, b)).map((b) => b.ref);
26
+ if (peers.length)
27
+ out.set(a.ref, peers);
28
+ }
29
+ return out;
30
+ }
31
+ /**
32
+ * The Quota an Account of a Same Login shows: the freshest `ok` reading of the group (its own wins a tie), marked
33
+ * with `from` when it is another member's. Without any `ok` reading the Account keeps its own result.
34
+ */
35
+ export function sharedQuota(ref, readings, peers) {
36
+ const own = readings.get(ref) ?? { status: 'unavailable', reason: 'unrecognized' };
37
+ let best;
38
+ for (const r of [ref, ...peers]) {
39
+ const q = readings.get(r);
40
+ if (q?.status !== 'ok')
41
+ continue;
42
+ const t = Date.parse(q.observedAt);
43
+ if (!Number.isFinite(t))
44
+ continue;
45
+ if (!best || t > best.t)
46
+ best = { ref: r, q, t };
47
+ }
48
+ if (!best || best.ref === ref)
49
+ return own;
50
+ const { from: _from, ...reading } = best.q;
51
+ return { ...reading, from: best.ref };
52
+ }
@@ -44,7 +44,10 @@ export function handoffByFamily(rt, accounts, now) {
44
44
  const out = {};
45
45
  for (const [id, f] of rt.families) {
46
46
  const list = accounts.filter((a) => a.family === id);
47
- if (!f.readQuota || list.filter((a) => a.kind === 'subscription').length < 2)
47
+ // Count logins, not directories: a Same Login group (spec §3.21) is one Quota, so it alone is no alternative.
48
+ const subs = list.filter((a) => a.kind === 'subscription');
49
+ const logins = subs.filter((a, i) => !subs.slice(0, i).some((b) => a.sameLogin?.includes(b.ref))).length;
50
+ if (!f.readQuota || logins < 2)
48
51
  continue;
49
52
  const plan = planHandoff(id, list.map((a) => ({
50
53
  ref: a.ref,
@@ -120,6 +120,8 @@ declare const en: {
120
120
  'filter.clear': string;
121
121
  'group.expand': string;
122
122
  'group.collapse': string;
123
+ 'row.collapse': string;
124
+ 'row.expand': string;
123
125
  'group.toggle': string;
124
126
  'group.attention.one': string;
125
127
  'group.attention.other': string;
@@ -134,6 +136,8 @@ declare const en: {
134
136
  'acct.next': string;
135
137
  'acct.nextHint': string;
136
138
  'acct.nextLabel': string;
139
+ 'acct.sameLogin': string;
140
+ 'acct.sameLoginHint': string;
137
141
  'acct.notInstalled': string;
138
142
  'acct.readError': string;
139
143
  'acct.installHost': string;
@@ -148,6 +152,7 @@ declare const en: {
148
152
  'quota.resetNoData': string;
149
153
  'quota.dataFrom': string;
150
154
  'quota.asOf': string;
155
+ 'quota.sharedFrom': string;
151
156
  'quota.noWindows': string;
152
157
  'quota.off': string;
153
158
  'quota.turnOnFamily': string;
@@ -122,6 +122,8 @@ const en = {
122
122
  'filter.clear': 'Clear filters',
123
123
  'group.expand': 'Show accounts',
124
124
  'group.collapse': 'Hide accounts',
125
+ 'row.collapse': 'Collapse {name}',
126
+ 'row.expand': 'Expand {name}',
125
127
  'group.toggle': '{title} ({count}): {action}',
126
128
  'group.attention.one': '{n} needs attention',
127
129
  'group.attention.other': '{n} need attention',
@@ -136,6 +138,8 @@ const en = {
136
138
  'acct.next': 'Next',
137
139
  'acct.nextHint': 'The account to move on to when this family runs low: most quota left. Click to copy `sideby next {family}`.',
138
140
  'acct.nextLabel': '{ref} is next: copy `sideby next {family}`',
141
+ 'acct.sameLogin': 'Same login as {names}',
142
+ 'acct.sameLoginHint': 'Signed in as the same login as {names}. The vendor counts one quota for all of them, so they show the same quota and a handoff never moves between them.',
139
143
  'acct.notInstalled': 'Tool missing',
140
144
  'acct.readError': 'Could not read this account.',
141
145
  'acct.installHost': '{host} is not installed on this machine. {link}',
@@ -150,6 +154,7 @@ const en = {
150
154
  'quota.resetNoData': 'Window reset · no new data since {date}',
151
155
  'quota.dataFrom': 'Data from {ago}',
152
156
  'quota.asOf': 'As of {ago}',
157
+ 'quota.sharedFrom': 'Same login as {name}; quota read from {name}, {ago}',
153
158
  'quota.noWindows': 'No quota windows reported.',
154
159
  'quota.off': 'Quota not on',
155
160
  'quota.turnOnFamily': 'Turn on quota',
@@ -579,6 +584,8 @@ const zh = {
579
584
  'filter.clear': '清除筛选',
580
585
  'group.expand': '展开账号',
581
586
  'group.collapse': '收起账号',
587
+ 'row.collapse': '收起 {name}',
588
+ 'row.expand': '展开 {name}',
582
589
  'group.toggle': '{title}({count}):{action}',
583
590
  'group.attention.one': '{n} 个需要处理',
584
591
  'group.attention.other': '{n} 个需要处理',
@@ -593,6 +600,8 @@ const zh = {
593
600
  'acct.next': '下一个',
594
601
  'acct.nextHint': '这个家族额度用完时换到这里:剩余额度最多。点击复制 `sideby next {family}`。',
595
602
  'acct.nextLabel': '{ref} 是下一个:复制 `sideby next {family}`',
603
+ 'acct.sameLogin': '与 {names} 同一登录',
604
+ 'acct.sameLoginHint': '与 {names} 用同一个身份登录。供应商只算一份额度,所以它们显示同一份额度,交接也不会在它们之间进行。',
596
605
  'acct.notInstalled': '未安装工具',
597
606
  'acct.readError': '无法读取这个账号。',
598
607
  'acct.installHost': '本机没有安装 {host}。{link}',
@@ -607,6 +616,7 @@ const zh = {
607
616
  'quota.resetNoData': '已重置,{date}后无新数据',
608
617
  'quota.dataFrom': '数据来自 {ago}',
609
618
  'quota.asOf': '截至 {ago}',
619
+ 'quota.sharedFrom': '与 {name} 同一登录;额度读自 {name},{ago}',
610
620
  'quota.noWindows': '没有额度窗口数据。',
611
621
  'quota.off': '额度未开启',
612
622
  'quota.turnOnFamily': '开启额度显示',
@@ -26,7 +26,11 @@ export interface LogicAccount {
26
26
  }[];
27
27
  observedAt?: string;
28
28
  plan?: string;
29
+ /** The Account the reading came from, when it is another Account of the same login (spec §3.21). */
30
+ from?: string;
29
31
  };
32
+ /** The other Accounts of its Same Login (spec §3.21). */
33
+ sameLogin?: string[];
30
34
  usage?: {
31
35
  status: string;
32
36
  inputTokens?: number;
@@ -65,7 +69,8 @@ export declare function passedWindow(w: {
65
69
  export declare function quotaPressure(a: LogicAccount, now: number): number;
66
70
  /**
67
71
  * Why an Account needs a look, most serious first: `error`, `health-fail`, `signed-out`, `not-installed`,
68
- * `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well.
72
+ * `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well. A Quota read from
73
+ * another Account of the same login (`quota.from`, spec §3.21) is that Account's warning, so it counts once per group.
69
74
  */
70
75
  export declare function attentionReasons(a: LogicAccount, health: {
71
76
  fail: number;
@@ -55,7 +55,8 @@ export function quotaPressure(a, now) {
55
55
  }
56
56
  /**
57
57
  * Why an Account needs a look, most serious first: `error`, `health-fail`, `signed-out`, `not-installed`,
58
- * `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well.
58
+ * `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well. A Quota read from
59
+ * another Account of the same login (`quota.from`, spec §3.21) is that Account's warning, so it counts once per group.
59
60
  */
60
61
  export function attentionReasons(a, health, now, warn) {
61
62
  const out = [];
@@ -68,7 +69,7 @@ export function attentionReasons(a, health, now, warn) {
68
69
  if (a.hostInstalled === false)
69
70
  out.push('not-installed');
70
71
  const q = a.quota;
71
- if (q && q.status === 'ok' && q.windows)
72
+ if (q && q.status === 'ok' && q.windows && !q.from)
72
73
  for (const w of q.windows) {
73
74
  const reset = Date.parse(w.resetsAt);
74
75
  if (Number.isFinite(reset) && reset <= now)
@@ -65,13 +65,15 @@ const MAIN = String.raw `
65
65
  function loadPrefs() {
66
66
  let raw = {}
67
67
  try { raw = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {} } catch {}
68
- const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, hideEmail: false }
68
+ const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, rows: {}, hideEmail: false }
69
69
  for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
70
70
  if (raw.hideEmail === true) p.hideEmail = true
71
71
  if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
72
72
  if (typeof raw.family === 'string') p.family = raw.family
73
73
  if (raw.collapsed && typeof raw.collapsed === 'object')
74
74
  for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
75
+ if (raw.rows && typeof raw.rows === 'object')
76
+ for (const k of Object.keys(raw.rows)) if (raw.rows[k] === true) p.rows[k] = true
75
77
  return p
76
78
  }
77
79
  function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
@@ -837,7 +839,9 @@ const MAIN = String.raw `
837
839
  if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
838
840
  const stale = isStale(q.observedAt, now)
839
841
  const wins = q.windows || []
840
- return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) }) },
842
+ // A reading borrowed from another Account of the same login says where it came from (spec §3.21).
843
+ const from = q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
844
+ return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: from || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })) },
841
845
  wins.length ? cols.map((c) => {
842
846
  const w = wins.find((x) => x.label === c)
843
847
  return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
@@ -884,6 +888,9 @@ const MAIN = String.raw `
884
888
  return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
885
889
  }),
886
890
  a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
891
+ a.sameLogin && a.sameLogin.length
892
+ ? h('span', { class: 'tag', title: t('acct.sameLoginHint', { names: a.sameLogin.map(refName).join(', ') }) }, t('acct.sameLogin', { names: a.sameLogin.map(refName).join(', ') }))
893
+ : null,
887
894
  isNext(a)
888
895
  ? h('button', { class: 'tag tag-ok tag-btn', type: 'button', title: t('acct.nextHint', { family: a.family }), 'aria-label': t('acct.nextLabel', { ref: a.ref, family: a.family }), onclick: () => copyText('sideby next ' + a.family) }, t('acct.next'))
889
896
  : null,
@@ -892,6 +899,8 @@ const MAIN = String.raw `
892
899
  : null,
893
900
  a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t('acct.notInstalled')) : null)
894
901
  }
902
+ // The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
903
+ function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
895
904
  // Who the account is signed in as, under its name; masked while "Hide emails" is on.
896
905
  function emailLine(a) {
897
906
  const id = a.identity
@@ -947,12 +956,63 @@ const MAIN = String.raw `
947
956
  }
948
957
  // What a row or card shows depends on more than the account (health, view preferences, language); the
949
958
  // fingerprint covers what its click handlers use.
950
- function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref]) }
959
+ function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref, folded(a)]) }
960
+ // Each account can be folded to one line (its name) and opened again, like a family group; the choice is kept in
961
+ // this browser's preferences.
962
+ function folded(a) { return prefs.rows[a.ref] === true }
963
+ function rowToggle(a) {
964
+ const open = !folded(a)
965
+ const label = t(open ? 'row.collapse' : 'row.expand', { name: a.name })
966
+ return h('button', { class: 'icon-btn row-toggle', type: 'button', 'aria-expanded': String(open), 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); toggleRow(a) } }, icon('chevron', 'chev'))
967
+ }
968
+ // The whole account cell (and a folded row anywhere) folds and opens it, not only the arrow; the buttons and links
969
+ // inside keep their own action, and a click that ends a text selection does nothing.
970
+ function foldClick(a) {
971
+ return (e) => {
972
+ if (e.target.closest('button, a, input, select, textarea, .tip')) return
973
+ if (window.getSelection && String(window.getSelection())) return
974
+ toggleRow(a)
975
+ }
976
+ }
977
+ // Folding animates: the details fade out, then the row's height eases to one line; opening eases the height out
978
+ // and fades the details in. The arrow turns with it. Reduced motion just switches.
979
+ const EASE = 'cubic-bezier(.2, 0, 0, 1)'
980
+ function rowDetails(el) { return Array.from(el.querySelectorAll(':scope > :not(.ident):not(.more):not(.card-head), .ident-email, .ident-sub')) }
981
+ function toggleRow(a) {
982
+ const closing = !folded(a)
983
+ const find = () => document.querySelector('[data-ref="' + CSS.escape(a.ref) + '"]')
984
+ const apply = () => {
985
+ if (closing) prefs.rows[a.ref] = true; else delete prefs.rows[a.ref]
986
+ savePrefs()
987
+ renderAccounts()
988
+ }
989
+ const old = find()
990
+ // A hidden page delays animation events; switch at once there too.
991
+ if (REDUCED || document.hidden || !old || !old.animate) { apply(); return }
992
+ const from = old.getBoundingClientRect().height
993
+ const fade = closing ? rowDetails(old).map((d) => d.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 110, easing: 'ease-out', fill: 'forwards' }).finished) : []
994
+ Promise.all(fade).catch(() => {}).then(() => {
995
+ apply()
996
+ const el = find()
997
+ if (!el) return
998
+ const to = el.getBoundingClientRect().height
999
+ el.style.overflow = 'hidden'
1000
+ const done = () => { el.style.overflow = '' }
1001
+ el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: 220, easing: EASE }).finished.then(done, done)
1002
+ const chev = el.querySelector('.row-toggle .chev')
1003
+ if (chev) chev.animate([{ transform: closing ? 'rotate(90deg)' : 'rotate(0deg)' }, { transform: closing ? 'rotate(0deg)' : 'rotate(90deg)' }], { duration: 220, easing: EASE })
1004
+ if (!closing) for (const d of rowDetails(el)) d.animate([{ opacity: 0, transform: 'translateY(-4px)' }, { opacity: 1, transform: 'none' }], { duration: 200, delay: 60, easing: 'ease-out', fill: 'backwards' })
1005
+ })
1006
+ }
951
1007
  // The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
952
1008
  function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
953
1009
  function row(a, now, cols) {
1010
+ if (folded(a))
1011
+ return h('div', { class: 'row row-folded fold-hit' + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
1012
+ h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a))),
1013
+ moreButton(a))
954
1014
  return h('div', { class: 'row' + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a) },
955
- h('div', { class: 'ident' }, badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a))),
1015
+ h('div', { class: 'ident fold-hit', onclick: foldClick(a) }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a))),
956
1016
  quotaBlock(a, now, cols),
957
1017
  usageBlock(a),
958
1018
  lastUsed(a),
@@ -961,8 +1021,12 @@ const MAIN = String.raw `
961
1021
  notes(a))
962
1022
  }
963
1023
  function card(a, now, cols) {
1024
+ if (folded(a))
1025
+ return h('article', { class: 'card card-folded fold-hit' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref, onclick: foldClick(a) },
1026
+ h('header', { class: 'card-head' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a)), moreButton(a)))
964
1027
  return h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
965
- h('header', { class: 'card-head' },
1028
+ h('header', { class: 'card-head fold-hit', onclick: foldClick(a) },
1029
+ rowToggle(a),
966
1030
  badge(a.family, 'sm'),
967
1031
  h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a)),
968
1032
  moreButton(a)),
@@ -159,6 +159,16 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
159
159
  .row:first-child { border-top: 0; }
160
160
  .row:last-child { border-radius: 0 0 var(--radius) var(--radius); }
161
161
  .row-error .ident-name .name { color: var(--fail); }
162
+ /* One account folded to its name line, opened again with the arrow at its start. */
163
+ .row-toggle { width: 28px; height: 28px; margin: -4px -6px -4px -8px; }
164
+ .fold-hit { cursor: pointer; }
165
+ .row > .ident.fold-hit { align-self: stretch; margin: -11px 0 -11px -16px; padding: 11px 0 11px 16px; }
166
+ .fold-hit:hover .row-toggle { background: var(--hover); color: var(--text); }
167
+ .row-toggle[aria-expanded=true] .chev { transform: rotate(90deg); }
168
+ .row-toggle[aria-expanded=true]:not(:hover) { background: transparent; color: var(--muted); }
169
+ .fold-hit:hover .row-toggle[aria-expanded=true] { background: var(--hover); color: var(--text); }
170
+ .row-folded { grid-template-areas: "id id id id id more"; padding-top: 7px; padding-bottom: 7px; }
171
+ .card-folded .card-head { align-items: center; }
162
172
  .col-head { position: sticky; top: 0; z-index: 2; padding: 7px 10px 7px 16px; border-top: 1px solid var(--border); background: var(--surface); color: var(--faint); font-size: 11.5px; font-weight: var(--strong-weight); line-height: 1.3; white-space: nowrap; }
163
173
  .col-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
164
174
  .col-head .quota > span { overflow: hidden; text-overflow: ellipsis; }
@@ -515,6 +525,7 @@ select:is(:hover, :focus) { --chevron: var(--text); }
515
525
  @media (max-width: 1100px) {
516
526
  .col-head { display: none; }
517
527
  .row { grid-template-columns: minmax(0, 1fr) auto var(--m-w); grid-template-areas: "id health more" "quota quota quota" "usage usage last" "notes notes notes"; row-gap: 10px; }
528
+ .row-folded { grid-template-areas: "id id more"; }
518
529
  .row > .quota { grid-template-columns: repeat(var(--qn), minmax(0, 1fr)); max-width: 560px; }
519
530
  .rows .m-label, .rows .m-unit, .rows .u-unit { display: inline; }
520
531
  .rows .usage { justify-content: flex-start; }
@@ -103,6 +103,10 @@ export interface AccountStatus extends Account {
103
103
  model?: string;
104
104
  /** Who the Account is signed in as (email, organization), when its Family can tell. */
105
105
  identity?: AccountIdentity;
106
+ /**
107
+ * The other Accounts signed in as the same login, which share its Quota (spec §3.21); absent when there are none.
108
+ */
109
+ sameLogin?: string[];
106
110
  /** Plugin errors met while reading this Account (for example an unreadable settings file). */
107
111
  problems?: string[];
108
112
  /**
@@ -197,7 +201,13 @@ export interface Runtime {
197
201
  * (spec §3.15). Throws UnknownFamilyError, or a UserError when the Family cannot route sessions.
198
202
  */
199
203
  resumeLaunch(familyId: string, userArgs: readonly string[]): Promise<ResumeLaunch>;
204
+ /**
205
+ * The Account's Quota. For an Account of a Same Login (spec §3.21) it is the group's freshest reading, with `from`
206
+ * naming the Account it was read from when that is another one.
207
+ */
200
208
  quota(account: Account): Promise<QuotaResult>;
209
+ /** The other Accounts signed in as the same login as this one (spec §3.21), in discovery order; empty when none. */
210
+ sameLogin(account: Account): Promise<string[]>;
201
211
  usage(account: Account): Promise<UsageResult>;
202
212
  /** Estimated Compaction Threshold per model for one Account (spec §3.18); read-only. */
203
213
  tuning(account: Account): Promise<TuningResult>;
@@ -14,6 +14,7 @@ import { prepareLaunch, which } from './core/launch.js';
14
14
  import { memoStore } from './core/memo-store.js';
15
15
  import { resolvePaths } from './core/paths.js';
16
16
  import { findResumeRoute } from './core/resume.js';
17
+ import { SAME_LOGIN_FAMILIES, sameLoginGroups, sharedQuota } from './core/same-login.js';
17
18
  import { shareMeaning } from './core/share-meaning.js';
18
19
  import { SHELLS, shellInitScript, writeShellInitFile, } from './core/shell-init.js';
19
20
  import { groupSuggestion, MAX_COMPACT_WINDOW, MIN_COMPACT_WINDOW, } from './core/tuning.js';
@@ -84,6 +85,56 @@ export async function createRuntime(opts = {}) {
84
85
  out.push(...(await discoverAccounts(f, paths.home, ignore)));
85
86
  return out;
86
87
  };
88
+ /** Same Login groups (spec §3.21) among `list`: ref → the other refs. Only Families that group take part. */
89
+ const groupsOf = async (list) => {
90
+ const members = await Promise.all(list
91
+ .filter((a) => a.kind === 'subscription' && SAME_LOGIN_FAMILIES.has(a.family) && families.has(a.family))
92
+ .map(async (a) => {
93
+ const identity = await readIdentity(familyOf(a.family), a);
94
+ return { ref: a.ref, family: a.family, kind: a.kind, ...(identity ? { identity } : {}) };
95
+ }));
96
+ return sameLoginGroups(members);
97
+ };
98
+ /** The Accounts of `account`'s Same Login, other than itself; reads only its own Family. */
99
+ const peersOf = async (account) => {
100
+ const f = families.get(account.family);
101
+ if (!f || account.kind === 'api' || !SAME_LOGIN_FAMILIES.has(f.id))
102
+ return [];
103
+ const list = await discoverAccounts(f, paths.home, ignore);
104
+ const peers = (await groupsOf(list)).get(account.ref) ?? [];
105
+ return list.filter((a) => peers.includes(a.ref));
106
+ };
107
+ /** Every Account's Quota as shown (shared within a Same Login), reading each Account's own cache once. */
108
+ const quotasOf = async (list) => {
109
+ const [own, groups] = await Promise.all([
110
+ Promise.all(list.map(async (a) => [a.ref, await ownQuota(a)])).then((e) => new Map(e)),
111
+ groupsOf(list),
112
+ ]);
113
+ return new Map(list.map((a) => [a.ref, sharedQuota(a.ref, own, groups.get(a.ref) ?? [])]));
114
+ };
115
+ const ownQuota = async (account) => {
116
+ if (account.kind === 'api')
117
+ return { status: 'unavailable', reason: 'api-account' };
118
+ const f = familyOf(account.family);
119
+ if (!f.readQuota)
120
+ return { status: 'unavailable', reason: 'no-source' };
121
+ await memos.ready();
122
+ try {
123
+ const q = await f.readQuota(account, readContext());
124
+ // Only sideby marks a reading as borrowed from another Account (spec §3.21); a reader's own `from` is dropped.
125
+ if (q.status === 'ok' && q.from !== undefined) {
126
+ const { from: _from, ...own } = q;
127
+ return own;
128
+ }
129
+ return q;
130
+ }
131
+ catch (err) {
132
+ return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
133
+ }
134
+ finally {
135
+ memos.saveSoon();
136
+ }
137
+ };
87
138
  const loginOf = async (a) => {
88
139
  if (a.kind === 'api')
89
140
  return 'not-needed';
@@ -208,13 +259,14 @@ export async function createRuntime(opts = {}) {
208
259
  return undefined;
209
260
  }
210
261
  };
211
- const [installed, login, model, identity] = await Promise.all([
262
+ const [installed, login, model, identity, peers] = await Promise.all([
212
263
  which(f.bin, env),
213
264
  account.kind === 'api'
214
265
  ? undefined
215
266
  : safe('login state', f.loginState && (() => f.loginState(account))),
216
267
  safe('model', f.model && (() => f.model(account))),
217
268
  readIdentity(f, account),
269
+ peersOf(account).catch(() => []),
218
270
  ]);
219
271
  return {
220
272
  ...account,
@@ -223,6 +275,7 @@ export async function createRuntime(opts = {}) {
223
275
  login: account.kind === 'api' ? 'not-needed' : (login ?? 'unknown'),
224
276
  ...(model ? { model } : {}),
225
277
  ...(identity ? { identity } : {}),
278
+ ...(peers.length ? { sameLogin: peers.map((p) => p.ref) } : {}),
226
279
  ...(problems.length ? { problems } : {}),
227
280
  };
228
281
  },
@@ -436,21 +489,13 @@ export async function createRuntime(opts = {}) {
436
489
  };
437
490
  },
438
491
  async quota(account) {
439
- if (account.kind === 'api')
440
- return { status: 'unavailable', reason: 'api-account' };
441
- const f = familyOf(account.family);
442
- if (!f.readQuota)
443
- return { status: 'unavailable', reason: 'no-source' };
444
- await memos.ready();
445
- try {
446
- return await f.readQuota(account, readContext());
447
- }
448
- catch (err) {
449
- return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
450
- }
451
- finally {
452
- memos.saveSoon();
453
- }
492
+ const peers = await peersOf(account);
493
+ if (!peers.length)
494
+ return ownQuota(account);
495
+ return (await quotasOf([account, ...peers])).get(account.ref);
496
+ },
497
+ async sameLogin(account) {
498
+ return (await peersOf(account)).map((a) => a.ref);
454
499
  },
455
500
  async usage(account) {
456
501
  const f = familyOf(account.family);
@@ -556,21 +601,36 @@ export async function createRuntime(opts = {}) {
556
601
  throw new UserError(`no ${f.title} accounts yet; create one with \`sideby new ${f.id} <name>\``, {
557
602
  code: 'no-accounts',
558
603
  });
559
- // Only the login state and Quota: no identity, model or Host lookups are needed to rank.
604
+ // Login state, Quota and Same Login (spec §3.21): no model or Host lookups are needed to rank.
605
+ const [quotas, groups] = await Promise.all([quotasOf(list), groupsOf(list)]);
560
606
  const candidates = await Promise.all(list.map(async (a) => {
561
- const [login, quota] = await Promise.all([loginOf(a), rt.quota(a)]);
562
- return { ref: a.ref, name: a.name, isMain: a.isMain, kind: a.kind, login, quota };
607
+ const peers = groups.get(a.ref);
608
+ return {
609
+ ref: a.ref,
610
+ name: a.name,
611
+ isMain: a.isMain,
612
+ kind: a.kind,
613
+ login: await loginOf(a),
614
+ quota: quotas.get(a.ref),
615
+ ...(peers ? { sameLogin: peers } : {}),
616
+ };
563
617
  }));
564
- return planHandoff(f.id, candidates, readContext().now, o);
618
+ // The current Account: the one sideby started this process from, when it belongs to this Family.
619
+ const current = env.SIDEBY_ACCOUNT;
620
+ const from = current && list.some((a) => a.ref === current) ? current : undefined;
621
+ return planHandoff(f.id, candidates, readContext().now, { ...o, ...(from ? { from } : {}) });
565
622
  },
566
623
  handoffSettings: () => handoffSettings(config),
567
624
  async handoffDecision(origin, used, opts = {}) {
568
- const candidates = await Promise.all((await accounts()).map(async (a) => {
625
+ const all = await accounts();
626
+ const [quotas, groups] = await Promise.all([quotasOf(all), groupsOf(all)]);
627
+ const candidates = await Promise.all(all.map(async (a) => {
569
628
  const f = familyOf(a.family);
570
- const [installed, login, quota, identity] = await Promise.all([
629
+ const quota = quotas.get(a.ref);
630
+ const peers = groups.get(a.ref);
631
+ const [installed, login, identity] = await Promise.all([
571
632
  which(f.bin, env),
572
633
  loginOf(a),
573
- rt.quota(a),
574
634
  readIdentity(f, a),
575
635
  ]);
576
636
  let receive = false;
@@ -590,6 +650,7 @@ export async function createRuntime(opts = {}) {
590
650
  login,
591
651
  quota,
592
652
  ...(identity ? { identity } : {}),
653
+ ...(peers ? { sameLogin: peers } : {}),
593
654
  installed: installed !== null,
594
655
  receive,
595
656
  };
@@ -214,6 +214,11 @@ export type QuotaResult = {
214
214
  observedAt: string;
215
215
  source: string;
216
216
  plan?: string;
217
+ /**
218
+ * Set by sideby, never by a Family's reader: the Account this reading was taken from, when it is another
219
+ * Account signed in as the same login (spec §3.21). `observedAt` is that Account's time.
220
+ */
221
+ from?: string;
217
222
  } | {
218
223
  status: 'unavailable';
219
224
  reason: QuotaUnavailableReason;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
5
5
  "license": "MIT",
6
6
  "author": "Adonis0123 (https://github.com/Adonis0123)",
package/schemas/list.json CHANGED
@@ -147,6 +147,13 @@
147
147
  },
148
148
  "description": "Who the account is signed in as, from the host's login file: identity fields only, never a token (ADR-0003)."
149
149
  },
150
+ "sameLogin": {
151
+ "type": "array",
152
+ "items": {
153
+ "type": "string"
154
+ },
155
+ "description": "The other accounts signed in as the same login (same family, email and organization), which share one quota (spec §3.21). Absent when there are none."
156
+ },
150
157
  "problems": {
151
158
  "type": "array",
152
159
  "items": {
package/schemas/next.json CHANGED
@@ -17,6 +17,10 @@
17
17
  "family": {
18
18
  "type": "string"
19
19
  },
20
+ "from": {
21
+ "type": "string",
22
+ "description": "The account `sideby next` runs in (SIDEBY_ACCOUNT, set by sideby for the sessions it starts), when it belongs to this family."
23
+ },
20
24
  "pick": {
21
25
  "anyOf": [
22
26
  {
@@ -97,6 +101,14 @@
97
101
  "observedAt": {
98
102
  "type": "string",
99
103
  "description": "When the account's quota was recorded (its last session)."
104
+ },
105
+ "quotaFrom": {
106
+ "type": "string",
107
+ "description": "The account the quota was read from, when it is another account of the same login."
108
+ },
109
+ "sameLoginAs": {
110
+ "type": "string",
111
+ "description": "The current account (`from`), when this one is signed in as the same login: it shares that quota and is never picked."
100
112
  }
101
113
  }
102
114
  },
@@ -86,6 +86,10 @@
86
86
  },
87
87
  "plan": {
88
88
  "type": "string"
89
+ },
90
+ "from": {
91
+ "type": "string",
92
+ "description": "The account this reading was taken from, when it is another account of the same login (spec §3.21); observedAt is its time."
89
93
  }
90
94
  }
91
95
  },
@@ -2,7 +2,7 @@
2
2
  name: sideby
3
3
  description: Use when the user runs, or wants to run, several AI coding accounts (Claude Code, Codex, Grok Build, pi) side by side. Covers installing sideby and setting up a new account ("install sideby", "set up another Claude account", "add cc008", "配置一个 cc00x", "再加一个 Codex 账号"), explaining what accounts share and what stays separate ("what do my accounts share?", "这些账号共享了什么"), showing which account a session is ("show [cc001] in the status line", "加上账号标识"), seeing accounts, quota and usage, moving on when one hits its 5-hour or weekly limit, adding a short command (alias), and repairing drifted account directories (skills, hooks, rules or settings not shared, wrong symlinks, credential file permissions). Drives the `sideby` CLI with `--json`; the agent does every step except browser sign-in, filling in secrets and starting interactive sessions, which it hands to the user as one command.
4
4
  metadata:
5
- version: "0.7.0"
5
+ version: "0.9.0"
6
6
  requires:
7
7
  bins: [sideby, node]
8
8
  cliHelp: sideby --help
@@ -15,7 +15,7 @@ Use this when the user says an account hit its 5-hour or weekly limit ("usage li
15
15
  | `full` | A live window is at 85% or more; `resetsAt` is when it is usable again | No |
16
16
  | `logged-out` | Needs `sideby login <ref>` | No |
17
17
 
18
- Each entry's `observedAt` says when its quota was recorded. If `hasQuota` is `false`, no account has quota data: the pick is only a guess, so say so and suggest turning quota on (see the routing table in SKILL.md) or letting the user choose.
18
+ Each entry's `observedAt` says when its quota was recorded (`quotaFrom` when it was read from another account of the same login). An entry with `sameLoginAs` is signed in as the same login as the account you run in: it shares that quota and is never picked. If `hasQuota` is `false`, no account has quota data: the pick is only a guess, so say so and suggest turning quota on (see the routing table in SKILL.md) or letting the user choose.
19
19
  3. Give the user the command to run in their own terminal: `sideby next <family>` (starts the pick) or `sideby run <pick>`. Host arguments go after `--`. Do not run it yourself.
20
20
  4. Offer a handoff note: the new session starts empty, because each account keeps its own sessions. Write a short note the user can paste into it: the goal, what is done, what is in progress, the files involved, and the next step.
21
21
 
@@ -11,4 +11,6 @@ Accounts with their own (not linked) `settings.json` show quota `not-enabled` un
11
11
 
12
12
  After setup, each account shows quota once it has had one session with a reply. Codex quota needs no setup: sideby reads it from Codex's own session files.
13
13
 
14
+ Two Claude accounts signed in as the same login (same email and organization; `sameLogin` in `sideby list --json`) share one quota: both show the freshest reading, and `quota.from` names the account it was read from. Explain that to the user rather than calling the numbers wrong; usage stays per account, and sideby never merges or deletes either directory.
15
+
14
16
  Usage (7-day tokens) is read by sideby itself from Claude Code and Codex local records; no ccusage is needed. Grok and pi have no quota or usage source.