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 +1 -1
- package/dist/src/cli/commands.js +15 -3
- package/dist/src/cli/json-schemas.js +15 -0
- package/dist/src/core/desktop-app.js +13 -2
- package/dist/src/core/handoff-run.d.ts +2 -0
- package/dist/src/core/handoff-run.js +10 -4
- package/dist/src/core/handoff-select.d.ts +9 -0
- package/dist/src/core/handoff-select.js +10 -2
- package/dist/src/core/handoff-status.js +12 -0
- package/dist/src/core/handoff.d.ts +12 -0
- package/dist/src/core/handoff.js +13 -4
- package/dist/src/core/same-login.d.ts +24 -0
- package/dist/src/core/same-login.js +52 -0
- package/dist/src/panel/handler.js +4 -1
- package/dist/src/panel/i18n.d.ts +5 -0
- package/dist/src/panel/i18n.js +10 -0
- package/dist/src/panel/page-logic.d.ts +6 -1
- package/dist/src/panel/page-logic.js +3 -2
- package/dist/src/panel/page-script.js +69 -5
- package/dist/src/panel/page-styles.js +11 -0
- package/dist/src/runtime.d.ts +10 -0
- package/dist/src/runtime.js +84 -23
- package/dist/src/types.d.ts +5 -0
- package/package.json +1 -1
- package/schemas/list.json +7 -0
- package/schemas/next.json +12 -0
- package/schemas/quota.json +4 -0
- package/skills/sideby/SKILL.md +1 -1
- package/skills/sideby/references/handoff.md +1 -1
- package/skills/sideby/references/quota.md +2 -0
package/dist/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"
|
|
1
|
+
{"id":"20261015T030631-4ed31c"}
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -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) => [
|
|
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
|
-
/**
|
|
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(${
|
|
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
|
-
|
|
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;
|
package/dist/src/core/handoff.js
CHANGED
|
@@ -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) =>
|
|
40
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
package/dist/src/panel/i18n.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/panel/i18n.js
CHANGED
|
@@ -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
|
-
|
|
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; }
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
//
|
|
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
|
|
562
|
-
return {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
};
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
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
|
},
|
package/schemas/quota.json
CHANGED
package/skills/sideby/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|