sideby 0.7.0 → 0.8.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":"20261014T102906-2ab079"}
@@ -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,
@@ -134,6 +134,8 @@ declare const en: {
134
134
  'acct.next': string;
135
135
  'acct.nextHint': string;
136
136
  'acct.nextLabel': string;
137
+ 'acct.sameLogin': string;
138
+ 'acct.sameLoginHint': string;
137
139
  'acct.notInstalled': string;
138
140
  'acct.readError': string;
139
141
  'acct.installHost': string;
@@ -148,6 +150,7 @@ declare const en: {
148
150
  'quota.resetNoData': string;
149
151
  'quota.dataFrom': string;
150
152
  'quota.asOf': string;
153
+ 'quota.sharedFrom': string;
151
154
  'quota.noWindows': string;
152
155
  'quota.off': string;
153
156
  'quota.turnOnFamily': string;
@@ -136,6 +136,8 @@ const en = {
136
136
  'acct.next': 'Next',
137
137
  'acct.nextHint': 'The account to move on to when this family runs low: most quota left. Click to copy `sideby next {family}`.',
138
138
  'acct.nextLabel': '{ref} is next: copy `sideby next {family}`',
139
+ 'acct.sameLogin': 'Same login as {names}',
140
+ '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
141
  'acct.notInstalled': 'Tool missing',
140
142
  'acct.readError': 'Could not read this account.',
141
143
  'acct.installHost': '{host} is not installed on this machine. {link}',
@@ -150,6 +152,7 @@ const en = {
150
152
  'quota.resetNoData': 'Window reset · no new data since {date}',
151
153
  'quota.dataFrom': 'Data from {ago}',
152
154
  'quota.asOf': 'As of {ago}',
155
+ 'quota.sharedFrom': 'Same login as {name}; quota read from {name}, {ago}',
153
156
  'quota.noWindows': 'No quota windows reported.',
154
157
  'quota.off': 'Quota not on',
155
158
  'quota.turnOnFamily': 'Turn on quota',
@@ -593,6 +596,8 @@ const zh = {
593
596
  'acct.next': '下一个',
594
597
  'acct.nextHint': '这个家族额度用完时换到这里:剩余额度最多。点击复制 `sideby next {family}`。',
595
598
  'acct.nextLabel': '{ref} 是下一个:复制 `sideby next {family}`',
599
+ 'acct.sameLogin': '与 {names} 同一登录',
600
+ 'acct.sameLoginHint': '与 {names} 用同一个身份登录。供应商只算一份额度,所以它们显示同一份额度,交接也不会在它们之间进行。',
596
601
  'acct.notInstalled': '未安装工具',
597
602
  'acct.readError': '无法读取这个账号。',
598
603
  'acct.installHost': '本机没有安装 {host}。{link}',
@@ -607,6 +612,7 @@ const zh = {
607
612
  'quota.resetNoData': '已重置,{date}后无新数据',
608
613
  'quota.dataFrom': '数据来自 {ago}',
609
614
  'quota.asOf': '截至 {ago}',
615
+ 'quota.sharedFrom': '与 {name} 同一登录;额度读自 {name},{ago}',
610
616
  'quota.noWindows': '没有额度窗口数据。',
611
617
  'quota.off': '额度未开启',
612
618
  '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)
@@ -837,7 +837,9 @@ const MAIN = String.raw `
837
837
  if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
838
838
  const stale = isStale(q.observedAt, now)
839
839
  const wins = q.windows || []
840
- return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) }) },
840
+ // A reading borrowed from another Account of the same login says where it came from (spec §3.21).
841
+ const from = q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
842
+ return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: from || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })) },
841
843
  wins.length ? cols.map((c) => {
842
844
  const w = wins.find((x) => x.label === c)
843
845
  return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
@@ -884,6 +886,9 @@ const MAIN = String.raw `
884
886
  return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
885
887
  }),
886
888
  a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
889
+ a.sameLogin && a.sameLogin.length
890
+ ? h('span', { class: 'tag', title: t('acct.sameLoginHint', { names: a.sameLogin.map(refName).join(', ') }) }, t('acct.sameLogin', { names: a.sameLogin.map(refName).join(', ') }))
891
+ : null,
887
892
  isNext(a)
888
893
  ? 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
894
  : null,
@@ -892,6 +897,8 @@ const MAIN = String.raw `
892
897
  : null,
893
898
  a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t('acct.notInstalled')) : null)
894
899
  }
900
+ // The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
901
+ function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
895
902
  // Who the account is signed in as, under its name; masked while "Hide emails" is on.
896
903
  function emailLine(a) {
897
904
  const id = a.identity
@@ -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.8.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.8.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.