sideby 0.13.1 → 0.14.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +2 -2
  4. package/dist/src/cli/commands.js +21 -5
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +14 -1
  7. package/dist/src/cli/main.js +4 -3
  8. package/dist/src/core/config.d.ts +27 -0
  9. package/dist/src/core/config.js +48 -1
  10. package/dist/src/core/handoff-enable.js +10 -5
  11. package/dist/src/core/launch.d.ts +5 -0
  12. package/dist/src/core/launch.js +14 -3
  13. package/dist/src/core/quota-fetch.d.ts +22 -0
  14. package/dist/src/core/quota-fetch.js +98 -0
  15. package/dist/src/core/quota-levels.d.ts +3 -2
  16. package/dist/src/core/quota-levels.js +8 -4
  17. package/dist/src/core/quota-store.d.ts +19 -0
  18. package/dist/src/core/quota-store.js +62 -0
  19. package/dist/src/core/skill-check.d.ts +2 -0
  20. package/dist/src/core/skill-check.js +20 -2
  21. package/dist/src/core/update.js +1 -15
  22. package/dist/src/families/grok/handoff-hook.d.ts +6 -0
  23. package/dist/src/families/grok/handoff-hook.js +45 -2
  24. package/dist/src/families/grok/handoff.d.ts +6 -1
  25. package/dist/src/families/grok/handoff.js +30 -13
  26. package/dist/src/families/grok/index.js +5 -0
  27. package/dist/src/families/grok/quota-log.d.ts +30 -0
  28. package/dist/src/families/grok/quota-log.js +140 -0
  29. package/dist/src/families/grok/quota.d.ts +5 -0
  30. package/dist/src/families/grok/quota.js +112 -0
  31. package/dist/src/panel/handler.d.ts +6 -2
  32. package/dist/src/panel/handler.js +60 -3
  33. package/dist/src/panel/i18n.d.ts +22 -5
  34. package/dist/src/panel/i18n.js +44 -10
  35. package/dist/src/panel/page-logic.d.ts +18 -2
  36. package/dist/src/panel/page-logic.js +53 -6
  37. package/dist/src/panel/page-script.js +275 -90
  38. package/dist/src/panel/page-styles.js +30 -10
  39. package/dist/src/runtime.d.ts +30 -2
  40. package/dist/src/runtime.js +64 -21
  41. package/dist/src/types.d.ts +49 -1
  42. package/package.json +2 -2
  43. package/schemas/config.json +46 -0
  44. package/schemas/quota.json +14 -1
  45. package/skills/sideby/SKILL.md +1 -1
  46. package/skills/sideby/references/diagnose.md +2 -1
  47. package/skills/sideby/references/handoff.md +1 -1
  48. package/skills/sideby/references/quota.md +5 -1
@@ -4,7 +4,7 @@ import { aliasesByAccount } from '../core/accounts.js';
4
4
  import { listAppProfiles, openAppProfile, removeAppProfile, } from '../core/app-profiles.js';
5
5
  import { FAVICON_SVG, ICON_SVG } from '../core/brand-mark.js';
6
6
  import { browserReport, openInBrowser, setBrowserSignIns, } from '../core/browser-profiles.js';
7
- import { aliasArgs, aliasRefs, editConfigUpdate } from '../core/config.js';
7
+ import { aliasArgs, aliasRefs, editConfigUpdate, } from '../core/config.js';
8
8
  import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
9
9
  import { UserError } from '../core/errors.js';
10
10
  import { planHandoff } from '../core/handoff.js';
@@ -86,7 +86,7 @@ export function handoffByFamily(rt, accounts, now) {
86
86
  // Count logins, not directories: a Same Login group (spec §3.21) is one Quota, so it alone is no alternative.
87
87
  const subs = list.filter((a) => a.kind === 'subscription');
88
88
  const logins = subs.filter((a, i) => !subs.slice(0, i).some((b) => a.sameLogin?.includes(b.ref))).length;
89
- if (!f.readQuota || logins < 2)
89
+ if ((!f.readQuota && !f.fetchQuota) || logins < 2)
90
90
  continue;
91
91
  const plan = planHandoff(id, list.map((a) => ({
92
92
  ref: a.ref,
@@ -203,7 +203,8 @@ async function accountState(rt, account, families) {
203
203
  };
204
204
  const [status, quota, usage] = await Promise.all([
205
205
  guard(rt.status(account)),
206
- guard(rt.quota(account)),
206
+ // The kept answer of a Quota check shows now; a stale one is asked again behind it (spec §3.26).
207
+ guard(rt.quota(account, { fetch: 'background' })),
207
208
  guard(rt.usage(account)),
208
209
  ]);
209
210
  const fam = families.find((f) => f.id === account.family);
@@ -303,8 +304,36 @@ async function buildState(rt, opts) {
303
304
  },
304
305
  ...(upd ? { update: upd } : {}),
305
306
  ...(showBrowser ? { browser } : {}),
307
+ display: rt.displayOrder(),
308
+ ...(rt.quotaChecks().length ? { quotaChecks: rt.quotaChecks() } : {}),
306
309
  };
307
310
  }
311
+ /** The `display` of a /api/display-order body: null, or string lists only; anything else is refused. */
312
+ function displayBody(body) {
313
+ const d = body.display;
314
+ if (d === null)
315
+ return undefined;
316
+ const names = (v, what) => {
317
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string' && x))
318
+ throw new HttpError(400, `${what} must be a list of names`);
319
+ return [...v];
320
+ };
321
+ if (!d || typeof d !== 'object' || Array.isArray(d))
322
+ throw new HttpError(400, '"display" must be an object or null');
323
+ const out = {};
324
+ for (const [k, v] of Object.entries(d)) {
325
+ if (k === 'families')
326
+ out.families = names(v, '"families"');
327
+ else if (k === 'accounts') {
328
+ if (!v || typeof v !== 'object' || Array.isArray(v))
329
+ throw new HttpError(400, '"accounts" must be an object');
330
+ out.accounts = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"accounts.${f}"`)]));
331
+ }
332
+ else
333
+ throw new HttpError(400, `unknown key "${k}"`);
334
+ }
335
+ return out;
336
+ }
308
337
  /** The `change` of a /api/handoff/configure body, with every field checked; unknown fields are refused. */
309
338
  function handoffChange(body) {
310
339
  const c = body.change;
@@ -671,6 +700,34 @@ export function createPanelHandler(opts) {
671
700
  };
672
701
  },
673
702
  },
703
+ // Display Order (spec §3.25): replaces config `display`, or removes it for null. The drag itself is the preview.
704
+ '/api/display-order': {
705
+ async run(body) {
706
+ const display = displayBody(body);
707
+ const rt = await getRuntime();
708
+ return { display: await rt.setDisplayOrder(display) };
709
+ },
710
+ },
711
+ // Asks every Family that fetches Quota again now, and waits (spec §3.26); the page reloads its state after.
712
+ '/api/quota/refresh': {
713
+ async run() {
714
+ const rt = await getRuntime();
715
+ await rt.refreshQuota();
716
+ return { ok: true };
717
+ },
718
+ },
719
+ // A Family's Quota check on or off: config `quota.check.<family>` (spec §3.26).
720
+ '/api/quota/check': {
721
+ async run(body) {
722
+ const family = optString(body, 'family');
723
+ if (!family)
724
+ throw new HttpError(400, '"family" is required');
725
+ if (typeof body.on !== 'boolean')
726
+ throw new HttpError(400, '"on" must be true or false');
727
+ const rt = await getRuntime();
728
+ return { family, ...(await rt.setQuotaCheck(family, body.on)) };
729
+ },
730
+ },
674
731
  // Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
675
732
  '/api/quota/teardown': {
676
733
  async run(body) {
@@ -141,6 +141,26 @@ declare const en: {
141
141
  'sort.pressure': string;
142
142
  'sort.name': string;
143
143
  'sort.recent': string;
144
+ 'sort.manual': string;
145
+ 'order.handle': string;
146
+ 'order.moveFamily': string;
147
+ 'order.moveAccount': string;
148
+ 'order.position': string;
149
+ 'order.failed': string;
150
+ 'order.resetButton': string;
151
+ 'order.resetHint': string;
152
+ 'order.reset': string;
153
+ 'quota.fetchFailed': string;
154
+ 'quota.checkOff': string;
155
+ 'quota.checkOffHint': string;
156
+ 'quota.fetchedFrom': string;
157
+ 'quota.lastCheckFailed': string;
158
+ 'quota.refreshFailed': string;
159
+ 'quota.checkOnToast': string;
160
+ 'quota.checkOffToast': string;
161
+ 'quota.checkFailed': string;
162
+ 'set.quotaCheck': string;
163
+ 'set.quotaCheckHint': string;
144
164
  'tb.quota': string;
145
165
  'tb.used': string;
146
166
  'tb.left': string;
@@ -199,13 +219,10 @@ declare const en: {
199
219
  'quota.dataFrom': string;
200
220
  'quota.asOf': string;
201
221
  'quota.sharedFrom': string;
202
- 'quota.readFrom': string;
203
- 'login.count': string;
204
222
  'login.label': string;
223
+ 'login.shared': string;
224
+ 'login.sharedHint': string;
205
225
  'login.hint': string;
206
- 'login.sharedAbove': string;
207
- 'login.collapse': string;
208
- 'login.expand': string;
209
226
  'quota.noWindows': string;
210
227
  'quota.off': string;
211
228
  'quota.turnOnFamily': string;
@@ -143,6 +143,26 @@ const en = {
143
143
  'sort.pressure': 'Sort: quota used',
144
144
  'sort.name': 'Sort: name',
145
145
  'sort.recent': 'Sort: last used',
146
+ 'sort.manual': 'Sort: my order',
147
+ 'order.handle': 'Drag to move, or use the arrow keys',
148
+ 'order.moveFamily': 'Move {name}',
149
+ 'order.moveAccount': 'Move {name}',
150
+ 'order.position': '{name}: place {n} of {total}',
151
+ 'order.failed': 'Could not save the order: {msg}. It is back as it was.',
152
+ 'order.resetButton': 'Default order',
153
+ 'order.resetHint': 'Forget the order you dragged tools and accounts into',
154
+ 'order.reset': 'Back to the default order',
155
+ 'quota.fetchFailed': 'Could not get the quota',
156
+ 'quota.checkOff': 'Quota check off',
157
+ 'quota.checkOffHint': 'Turn it on in Settings to see this quota.',
158
+ 'quota.fetchedFrom': '{host} asked for this quota {ago}',
159
+ 'quota.lastCheckFailed': 'The latest check failed: {why}',
160
+ 'quota.refreshFailed': 'Could not ask for the newest quota: {msg}',
161
+ 'quota.checkOnToast': '{host} quota check is on',
162
+ 'quota.checkOffToast': '{host} quota check is off',
163
+ 'quota.checkFailed': 'Could not change the setting: {msg}',
164
+ 'set.quotaCheck': '{host} quota',
165
+ 'set.quotaCheckHint': "{host} asks for each account's quota, at most every {n} minutes. sideby never reads its login.",
146
166
  'tb.quota': 'Show quota as',
147
167
  'tb.used': 'Used',
148
168
  'tb.left': 'Left',
@@ -201,13 +221,10 @@ const en = {
201
221
  'quota.dataFrom': 'Data from {ago}',
202
222
  'quota.asOf': 'As of {ago}',
203
223
  'quota.sharedFrom': 'Same login as {name}; quota read from {name}, {ago}',
204
- 'quota.readFrom': 'Quota read from {name}, {ago}',
205
- 'login.count': '{n} directories share one quota',
206
224
  'login.label': 'Same login: {names}',
225
+ 'login.shared': "Shares {name}'s quota",
226
+ 'login.sharedHint': "Signed in as the same login as {name}: the vendor counts one quota for both, shown on {name}'s row.",
207
227
  'login.hint': '{names} are signed in as the same login. The vendor counts one quota for all of them, so it shows once here, and a handoff never moves between them.',
208
- 'login.sharedAbove': 'Uses the quota above',
209
- 'login.collapse': 'Hide {names}',
210
- 'login.expand': 'Show {names}',
211
228
  'quota.noWindows': 'No quota windows reported.',
212
229
  'quota.off': 'Quota not on',
213
230
  'quota.turnOnFamily': 'Turn on quota',
@@ -755,6 +772,26 @@ const zh = {
755
772
  'sort.pressure': '排序:额度用量',
756
773
  'sort.name': '排序:名称',
757
774
  'sort.recent': '排序:最近使用',
775
+ 'sort.manual': '排序:我的顺序',
776
+ 'order.handle': '拖动换位置,或用方向键',
777
+ 'order.moveFamily': '移动 {name}',
778
+ 'order.moveAccount': '移动 {name}',
779
+ 'order.position': '{name}:第 {n} 个,共 {total} 个',
780
+ 'order.failed': '顺序没有保存:{msg}。已恢复原来的顺序。',
781
+ 'order.resetButton': '默认顺序',
782
+ 'order.resetHint': '忘掉你拖出来的工具和账号顺序',
783
+ 'order.reset': '已恢复默认顺序',
784
+ 'quota.fetchFailed': '没拿到额度',
785
+ 'quota.checkOff': '额度查询已关',
786
+ 'quota.checkOffHint': '在「设置」里打开,就能看到这份额度。',
787
+ 'quota.fetchedFrom': '{host} {ago}查询的额度',
788
+ 'quota.lastCheckFailed': '最近一次查询失败:{why}',
789
+ 'quota.refreshFailed': '没能查询最新额度:{msg}',
790
+ 'quota.checkOnToast': '已打开 {host} 额度查询',
791
+ 'quota.checkOffToast': '已关闭 {host} 额度查询',
792
+ 'quota.checkFailed': '设置没有改成:{msg}',
793
+ 'set.quotaCheck': '{host} 额度',
794
+ 'set.quotaCheckHint': '{host} 自己查询每个账号的额度,最多每 {n} 分钟一次。sideby 不读它的登录信息。',
758
795
  'tb.quota': '额度显示方式',
759
796
  'tb.used': '已用',
760
797
  'tb.left': '剩余',
@@ -813,13 +850,10 @@ const zh = {
813
850
  'quota.dataFrom': '数据来自 {ago}',
814
851
  'quota.asOf': '截至 {ago}',
815
852
  'quota.sharedFrom': '与 {name} 同一登录;额度读自 {name},{ago}',
816
- 'quota.readFrom': '额度读自 {name},{ago}',
817
- 'login.count': '{n} 个目录共用一份额度',
818
853
  'login.label': '同一登录:{names}',
854
+ 'login.shared': '与 {name} 共用额度',
855
+ 'login.sharedHint': '与 {name} 用同一个身份登录,供应商只算一份额度,显示在 {name} 那一行。',
819
856
  'login.hint': '{names} 用同一个身份登录。供应商只算一份额度,所以这里只显示一次,交接也不会在它们之间进行。',
820
- 'login.sharedAbove': '共用上方的额度',
821
- 'login.collapse': '收起 {names}',
822
- 'login.expand': '展开 {names}',
823
857
  'quota.noWindows': '没有额度窗口数据。',
824
858
  'quota.off': '额度未开启',
825
859
  'quota.turnOnFamily': '开启额度显示',
@@ -20,6 +20,7 @@ export interface LogicAccount {
20
20
  status: string;
21
21
  windows?: {
22
22
  label: string;
23
+ name?: string;
23
24
  windowMinutes: number;
24
25
  usedPercent: number;
25
26
  resetsAt: string;
@@ -88,8 +89,23 @@ export declare function attentionWhy(a: LogicAccount, health: {
88
89
  key: string;
89
90
  vars: Record<string, string | number>;
90
91
  } | null;
91
- /** Sorted copy: `pressure` (fullest Quota first), `name`, or `recent` (last activity first). The Main Account leads ties. */
92
- export declare function sortAccounts<T extends LogicAccount>(list: T[], mode: string, now: number): T[];
92
+ /**
93
+ * `items` in Display Order (spec §3.25): those whose key is in `order` first, in that order; the others after, in the
94
+ * order they came. Names no longer there are simply not met.
95
+ */
96
+ export declare function inDisplayOrder<T>(items: readonly T[], key: (t: T) => string, order?: readonly string[]): T[];
97
+ /**
98
+ * The Display Order after a drag (spec §3.25). `all` is every key of the list in the current Display Order, hidden
99
+ * ones included; `shown` is what the screen showed, in its order, as units (one key, or a Same Login group that moves
100
+ * as one). The unit at `from` moves to `to`; the shown keys then fill the places shown keys held in `all`, so keys a
101
+ * filter hides keep their place among themselves.
102
+ */
103
+ export declare function reorder(all: readonly string[], shown: readonly (readonly string[])[], from: number, to: number): string[];
104
+ /**
105
+ * Sorted copy: `pressure` (fullest Quota first), `name`, `recent` (last activity first), or `manual` (the Family's
106
+ * Display Order `order`, names not in it after). The Main Account leads ties.
107
+ */
108
+ export declare function sortAccounts<T extends LogicAccount>(list: T[], mode: string, now: number, order?: readonly string[]): T[];
93
109
  /**
94
110
  * The sorted list with each Same Login together (spec §3.21): an Account, or the shown members of one login, in
95
111
  * order, at the place of its first member. A login with one shown member stays a plain Account.
@@ -44,14 +44,16 @@ export function quotaPressure(a, now) {
44
44
  const q = a.quota;
45
45
  if (q?.status !== 'ok' || !q.windows)
46
46
  return -1;
47
+ // A named window covers only some models and never counts (spec §3.26).
48
+ const windows = q.windows.filter((w) => !w.name);
47
49
  let max = -1;
48
- for (const w of q.windows) {
50
+ for (const w of windows) {
49
51
  const reset = Date.parse(w.resetsAt);
50
52
  if (Number.isFinite(reset) && reset <= now)
51
53
  continue;
52
54
  max = Math.max(max, Math.max(0, Math.min(100, Number(w.usedPercent) || 0)));
53
55
  }
54
- return q.windows.length && max < 0 ? 0 : max;
56
+ return windows.length && max < 0 ? 0 : max;
55
57
  }
56
58
  /**
57
59
  * Why an Account needs a look, most serious first: `error`, `health-fail`, `signed-out`, `not-installed`,
@@ -71,6 +73,8 @@ export function attentionReasons(a, health, now, warn) {
71
73
  const q = a.quota;
72
74
  if (q && q.status === 'ok' && q.windows && !q.from)
73
75
  for (const w of q.windows) {
76
+ if (w.name)
77
+ continue;
74
78
  const reset = Date.parse(w.resetsAt);
75
79
  if (Number.isFinite(reset) && reset <= now)
76
80
  continue;
@@ -96,6 +100,8 @@ export function attentionWhy(a, health, now, warn) {
96
100
  return { key: `why.${reason}`, vars: {} };
97
101
  let top = null;
98
102
  for (const w of a.quota?.windows ?? []) {
103
+ if (w.name)
104
+ continue;
99
105
  const reset = Date.parse(w.resetsAt);
100
106
  if (Number.isFinite(reset) && reset <= now)
101
107
  continue;
@@ -105,16 +111,55 @@ export function attentionWhy(a, health, now, warn) {
105
111
  }
106
112
  return { key: 'why.quota-high', vars: { label: top?.label ?? '', pct: Math.round(top?.pct ?? 0), warn } };
107
113
  }
108
- /** Sorted copy: `pressure` (fullest Quota first), `name`, or `recent` (last activity first). The Main Account leads ties. */
109
- export function sortAccounts(list, mode, now) {
114
+ /**
115
+ * `items` in Display Order (spec §3.25): those whose key is in `order` first, in that order; the others after, in the
116
+ * order they came. Names no longer there are simply not met.
117
+ */
118
+ export function inDisplayOrder(items, key, order) {
119
+ const at = new Map((order ?? []).map((k, i) => [k, i]));
120
+ return items
121
+ .map((t, i) => ({ t, i, r: at.has(key(t)) ? at.get(key(t)) : (order?.length ?? 0) }))
122
+ .sort((x, y) => x.r - y.r || x.i - y.i)
123
+ .map((x) => x.t);
124
+ }
125
+ /**
126
+ * The Display Order after a drag (spec §3.25). `all` is every key of the list in the current Display Order, hidden
127
+ * ones included; `shown` is what the screen showed, in its order, as units (one key, or a Same Login group that moves
128
+ * as one). The unit at `from` moves to `to`; the shown keys then fill the places shown keys held in `all`, so keys a
129
+ * filter hides keep their place among themselves.
130
+ */
131
+ export function reorder(all, shown, from, to) {
132
+ const units = shown.slice();
133
+ if (from < 0 || from >= units.length || to < 0 || to >= units.length)
134
+ return all.slice();
135
+ const [unit] = units.splice(from, 1);
136
+ units.splice(to, 0, unit);
137
+ const next = units.flat();
138
+ const visible = new Set(next);
139
+ const out = all.slice();
140
+ for (const k of next)
141
+ if (!out.includes(k))
142
+ out.push(k);
143
+ let i = 0;
144
+ for (let at = 0; at < out.length; at++)
145
+ if (visible.has(out[at]))
146
+ out[at] = next[i++];
147
+ return out;
148
+ }
149
+ /**
150
+ * Sorted copy: `pressure` (fullest Quota first), `name`, `recent` (last activity first), or `manual` (the Family's
151
+ * Display Order `order`, names not in it after). The Main Account leads ties.
152
+ */
153
+ export function sortAccounts(list, mode, now, order) {
110
154
  const byName = (x, y) => Number(Boolean(y.isMain)) - Number(Boolean(x.isMain)) ||
111
155
  x.name.localeCompare(y.name, undefined, { numeric: true });
112
156
  const pressure = (a) => {
113
157
  const q = a.quota;
114
158
  if (q?.status !== 'ok' || !q.windows)
115
159
  return -1;
116
- let max = q.windows.length ? 0 : -1;
117
- for (const w of q.windows) {
160
+ const windows = q.windows.filter((w) => !w.name);
161
+ let max = windows.length ? 0 : -1;
162
+ for (const w of windows) {
118
163
  const reset = Date.parse(w.resetsAt);
119
164
  if (!(Number.isFinite(reset) && reset <= now))
120
165
  max = Math.max(max, Number(w.usedPercent) || 0);
@@ -128,6 +173,8 @@ export function sortAccounts(list, mode, now) {
128
173
  const out = list.slice();
129
174
  if (mode === 'name')
130
175
  out.sort(byName);
176
+ else if (mode === 'manual')
177
+ return inDisplayOrder(out.sort(byName), (a) => a.name, order);
131
178
  else if (mode === 'recent')
132
179
  out.sort((x, y) => last(y) - last(x) || byName(x, y));
133
180
  else