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
package/README.md CHANGED
@@ -94,7 +94,7 @@ cc-switch's docs describe that switch: it writes the live settings file ([README
94
94
  |---|---|---|---|---|
95
95
  | Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |
96
96
  | Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |
97
- | Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | no public source | no public source |
97
+ | Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | weekly, asked from the `grok` CLI | no public source |
98
98
  | pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |
99
99
 
100
100
  sideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261016T161929-77384f"}
1
+ {"id":"20261017T050946-402d73"}
@@ -1,6 +1,6 @@
1
1
  import type { DoctorReport } from '../core/doctor.ts';
2
2
  import { type HandoffIo } from '../core/handoff-run.ts';
3
- import type { Finding } from '../types.ts';
3
+ import type { Account, Finding } from '../types.ts';
4
4
  import { type ParsedArgs } from './args.ts';
5
5
  export interface Io {
6
6
  out(s: string): void;
@@ -15,7 +15,7 @@ export declare function serializeDoctor(r: DoctorReport): {
15
15
  ref: string;
16
16
  family: string;
17
17
  name: string;
18
- kind: import("../types.ts").Account['kind'];
18
+ kind: Account['kind'];
19
19
  dir: string;
20
20
  shared: {
21
21
  ok: number;
@@ -14,7 +14,7 @@ import { resolvePaths, tildify } from '../core/paths.js';
14
14
  import { USAGE_DAYS } from '../core/quota-levels.js';
15
15
  import { groupsToTurnOff, groupsToTurnOn, groupsToUndo, } from '../core/remote-control.js';
16
16
  import { SHELLS } from '../core/shell-init.js';
17
- import { suggestAlias, suggestName } from '../panel/page-logic.js';
17
+ import { inDisplayOrder, suggestAlias, suggestName } from '../panel/page-logic.js';
18
18
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
19
19
  import { UsageError } from './args.js';
20
20
  import { c, clock, compactNumber, quotaText, table, tuningLines, usageText } from './format.js';
@@ -73,15 +73,21 @@ export function serializeDoctor(r) {
73
73
  function tilde(text, home) {
74
74
  return text.split(`${home}/`).join('~/');
75
75
  }
76
+ /** Accounts in the Display Order the user gave them in the Panel (spec §3.25): Families first, then each one's. */
77
+ function displayOrdered(accounts, rt) {
78
+ const d = rt.displayOrder();
79
+ const ids = inDisplayOrder([...new Set(accounts.map((x) => x.family))], (id) => id, d.families);
80
+ return ids.flatMap((id) => inDisplayOrder(accounts.filter((x) => x.family === id), (x) => x.name, d.accounts?.[id]));
81
+ }
76
82
  export async function cmdList(a, io) {
77
83
  const rt = await createRuntime();
78
- const accounts = await rt.accounts();
84
+ const accounts = displayOrdered(await rt.accounts(), rt);
79
85
  const [statuses, appProfiles] = await Promise.all([
80
86
  withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))),
81
87
  listAppProfiles(rt),
82
88
  ]);
83
89
  const hasDesktop = statuses.some((s) => s.desktopApp);
84
- const families = await rt.familyInfo();
90
+ const families = inDisplayOrder(await rt.familyInfo(), (f) => f.id, rt.displayOrder().families);
85
91
  if (a.flags.has('json')) {
86
92
  json(io, { families, accounts: statuses, appProfiles, pluginErrors: [...rt.pluginErrors] });
87
93
  return 0;
@@ -679,7 +685,14 @@ export async function cmdQuota(a, io) {
679
685
  if (sub === 'setup' || sub === 'teardown')
680
686
  return cmdQuotaSetup(a, io, sub);
681
687
  const rt = await createRuntime();
682
- const accounts = sub ? [await rt.resolve(sub)] : await rt.accounts();
688
+ const accounts = !sub
689
+ ? displayOrdered(await rt.accounts(), rt)
690
+ : rt.families.has(sub)
691
+ ? displayOrdered(await rt.accounts(), rt).filter((x) => x.family === sub)
692
+ : [await rt.resolve(sub)];
693
+ // Asks the Families that fetch Quota again now (spec §3.26); a check that is turned off stays off.
694
+ if (a.flags.has('refresh'))
695
+ await rt.refreshQuota(accounts);
683
696
  const rows = await Promise.all(accounts.map(async (x) => ({
684
697
  ref: x.ref,
685
698
  kind: x.kind,
@@ -700,9 +713,12 @@ export async function cmdQuota(a, io) {
700
713
  `${quotaText(r.quota, now)}${r.quota.status === 'ok' && r.quota.from ? c.dim(` read from ${r.quota.from} (same login)`) : ''}`,
701
714
  usageText(r.usage),
702
715
  ]), ['ACCOUNT', 'QUOTA', `USAGE (${USAGE_DAYS}d)`]));
703
- for (const r of rows)
716
+ for (const r of rows) {
704
717
  if (r.quota.status === 'unavailable' && r.quota.detail)
705
718
  io.out(c.dim(`${r.ref}: ${r.quota.detail}`));
719
+ if (r.quota.status === 'ok' && r.quota.fetchError)
720
+ io.out(c.dim(`${r.ref}: the latest check failed: ${r.quota.fetchError}`));
721
+ }
706
722
  // Suggest a setup only where it would help: the Family has one and its plan can be applied.
707
723
  const off = new Set(rows
708
724
  .filter((r) => r.quota.status === 'unavailable' && r.quota.reason === 'not-enabled')
@@ -44,12 +44,14 @@ export function clock(iso, now) {
44
44
  return sameDay ? hm : `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${hm}`;
45
45
  }
46
46
  function windowText(w, now) {
47
+ // A window that covers only some models goes by their name (spec §3.26).
48
+ const label = w.name ?? w.label;
47
49
  if (Date.parse(w.resetsAt) <= now.getTime())
48
- return `${w.label} reset`;
50
+ return `${label} reset`;
49
51
  const pct = Math.round(w.usedPercent);
50
52
  const level = quotaLevel(pct);
51
53
  const color = level === 'fail' ? c.red : level === 'warn' ? c.yellow : c.green;
52
- return `${w.label} ${color(`${pct}%`)} ${c.dim(`→ ${clock(w.resetsAt, now)}`)}`;
54
+ return `${label} ${color(`${pct}%`)} ${c.dim(`→ ${clock(w.resetsAt, now)}`)}`;
53
55
  }
54
56
  export const QUOTA_REASON = {
55
57
  'not-enabled': 'off',
@@ -57,11 +59,13 @@ export const QUOTA_REASON = {
57
59
  unrecognized: 'unrecognized format',
58
60
  'no-source': 'no public source',
59
61
  'api-account': 'API account',
62
+ 'fetch-failed': 'could not get it',
60
63
  };
61
64
  export function quotaText(q, now) {
62
65
  if (q.status === 'unavailable')
63
66
  return c.dim(QUOTA_REASON[q.reason] ?? q.reason);
64
- return `${q.windows.map((w) => windowText(w, now)).join(' ')} ${c.dim(ago(q.observedAt, now))}`;
67
+ const failed = q.fetchError ? c.yellow(' (latest check failed)') : '';
68
+ return `${q.windows.map((w) => windowText(w, now)).join(' ')} ${c.dim(ago(q.observedAt, now))}${failed}`;
65
69
  }
66
70
  export function compactNumber(n) {
67
71
  if (n < 1000)
@@ -100,6 +100,9 @@ const Finding = Type.Object({
100
100
  });
101
101
  const QuotaWindow = Type.Object({
102
102
  label: Str,
103
+ name: Type.Optional(Type.String({
104
+ description: 'The group of models this limit covers, when it covers only some (spec §3.26); it does not count toward Quota Pressure.',
105
+ })),
103
106
  windowMinutes: Type.Number(),
104
107
  usedPercent: Type.Number(),
105
108
  resetsAt: Str,
@@ -114,8 +117,18 @@ const Quota = Type.Union([
114
117
  from: Type.Optional(Type.String({
115
118
  description: 'The account this reading was taken from, when it is another account of the same login (spec §3.21); observedAt is its time.',
116
119
  })),
120
+ fetched: Type.Optional(Type.Boolean({
121
+ description: 'This reading is the kept answer of a quota check that costs a request (spec §3.26).',
122
+ })),
123
+ fetchError: Type.Optional(Type.String({ description: 'Why the latest quota check failed, while this older reading is shown.' })),
124
+ }),
125
+ Type.Object({
126
+ status: Type.Literal('unavailable'),
127
+ reason: Type.String({
128
+ description: 'not-enabled, no-session, unrecognized, no-source, api-account or fetch-failed.',
129
+ }),
130
+ detail: Type.Optional(Str),
117
131
  }),
118
- Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
119
132
  ]);
120
133
  const TuningModel = Type.Object({
121
134
  model: Str,
@@ -23,11 +23,12 @@ Usage:
23
23
  sideby resume <family> [-- args] run the host; a session resumed by id (claude --resume <id>,
24
24
  codex resume <id>) starts in the account that holds it
25
25
  sideby next <family> [--dry-run] [--include-api] [-- args]
26
- recommend the account with the most quota left (claude, codex)
26
+ recommend the account with the most quota left (claude, codex, grok)
27
27
  and start it; --dry-run only recommends
28
28
  sideby doctor [target] [--fix] [--force]
29
29
  check shared items and permissions; --fix repairs safe issues
30
- sideby quota [account] quota and 7-day token usage
30
+ sideby quota [account|family] [--refresh]
31
+ quota and 7-day token usage; --refresh asks Grok again now
31
32
  sideby quota setup claude [--yes] turn on Claude quota (shows the change first)
32
33
  sideby quota teardown claude undo it, restoring the original file
33
34
  sideby tune [account|family] estimate the cheapest auto-compact threshold per model
@@ -221,7 +222,7 @@ const COMMANDS = {
221
222
  new: { flags: ['api', 'json', 'next'], valued: ['alias'] },
222
223
  alias: { flags: ['json'] },
223
224
  doctor: { flags: ['fix', 'force', 'json'] },
224
- quota: { flags: ['json', 'yes'] },
225
+ quota: { flags: ['json', 'yes', 'refresh'] },
225
226
  tune: { flags: ['json', 'yes'] },
226
227
  model: { flags: ['json', 'yes'], valued: ['effort'] },
227
228
  'remote-control': { flags: ['json', 'yes'] },
@@ -69,6 +69,13 @@ export declare const ConfigSchema: Type.TObject<{
69
69
  browser: Type.TOptional<Type.TObject<{
70
70
  signIns: Type.TOptional<Type.TBoolean>;
71
71
  }>>;
72
+ display: Type.TOptional<Type.TObject<{
73
+ families: Type.TOptional<Type.TArray<Type.TString>>;
74
+ accounts: Type.TOptional<Type.TRecord<"^.*$", Type.TArray<Type.TString>>>;
75
+ }>>;
76
+ quota: Type.TOptional<Type.TObject<{
77
+ check: Type.TOptional<Type.TRecord<"^.*$", Type.TBoolean>>;
78
+ }>>;
72
79
  shellInitFile: Type.TOptional<Type.TObject<{
73
80
  zsh: Type.TOptional<Type.TString>;
74
81
  bash: Type.TOptional<Type.TString>;
@@ -116,6 +123,26 @@ export declare function editConfigBrowser(file: string, edit: (current: BrowserC
116
123
  before: BrowserConfig | undefined;
117
124
  after: BrowserConfig | undefined;
118
125
  }>;
126
+ export type DisplayConfig = NonNullable<Config['display']>;
127
+ /**
128
+ * The Panel's drag and drop (spec §3.25): replaces the config `display` key, or removes it when `display` is undefined
129
+ * or empty (see `editConfigKey` for how the file is written).
130
+ */
131
+ export declare function editConfigDisplay(file: string, display: DisplayConfig | undefined): Promise<{
132
+ changed: boolean;
133
+ before: DisplayConfig | undefined;
134
+ after: DisplayConfig | undefined;
135
+ }>;
136
+ export type QuotaConfig = NonNullable<Config['quota']>;
137
+ /**
138
+ * The Panel's Quota check switch (spec §3.26): `false` writes `quota.check.<family>: false`, `true` removes that key
139
+ * (on is the default), and an empty `quota` goes away with it (see `editConfigKey` for how the file is written).
140
+ */
141
+ export declare function editConfigQuotaCheck(file: string, family: string, on: boolean): Promise<{
142
+ changed: boolean;
143
+ before: QuotaConfig | undefined;
144
+ after: QuotaConfig | undefined;
145
+ }>;
119
146
  export type AccountsConfig = NonNullable<Config['accounts']>;
120
147
  /**
121
148
  * `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
@@ -106,6 +106,23 @@ export const ConfigSchema = Type.Object({
106
106
  description: "Look in Google Chrome's local data for which browser user signed in to each Account's website (key names only, never cookies or passwords; nothing leaves this computer). Off by default.",
107
107
  })),
108
108
  }, { additionalProperties: false, description: 'Browser Sign-ins (spec §3.24, ADR-0023).' })),
109
+ display: Type.Optional(Type.Object({
110
+ families: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
111
+ uniqueItems: true,
112
+ description: 'Family ids in the order the Panel and `sideby list` show them; families not listed follow in their usual order.',
113
+ })),
114
+ accounts: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }), {
115
+ description: 'Per family, account names (not refs or short commands) in the order they are shown; accounts not listed follow, main first, then by name.',
116
+ })),
117
+ }, {
118
+ additionalProperties: false,
119
+ description: 'Display Order (spec §3.25): the order you dragged families and accounts into in the Panel. Display only: it never changes which account a Handoff picks.',
120
+ })),
121
+ quota: Type.Optional(Type.Object({
122
+ check: Type.Optional(Type.Record(Type.String(), Type.Boolean(), {
123
+ description: 'Per family, whether sideby may ask for Quota in a way that costs a request (Grok: its own CLI asks xAI, at most every 15 minutes per account). On by default; `false` turns it off. Off in CI.',
124
+ })),
125
+ }, { additionalProperties: false, description: 'Quota that sideby asks for (spec §3.26, ADR-0025).' })),
109
126
  shellInitFile: Type.Optional(Type.Object({
110
127
  zsh: Type.Optional(Type.String({ minLength: 1 })),
111
128
  bash: Type.Optional(Type.String({ minLength: 1 })),
@@ -172,7 +189,7 @@ async function configTarget(file) {
172
189
  }
173
190
  /**
174
191
  * Re-reads the config file, lets `edit` change one top-level `key` (sideby writes only `aliases`, `handoff`,
175
- * `accounts`, `update` and `browser`, spec §3.10), and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
192
+ * `accounts`, `update`, `browser`, `display` and `quota`, spec §3.10), and an `edit` that returns undefined removes it, and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
176
193
  * with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
177
194
  * file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
178
195
  * while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
@@ -211,6 +228,8 @@ async function editConfigKey(file, key, retry, edit, opts = {}) {
211
228
  const next = raw === null
212
229
  ? { $schema: 'https://unpkg.com/sideby/schemas/config.json', [key]: after }
213
230
  : { ...data, [key]: after };
231
+ if (after === undefined)
232
+ delete next[key];
214
233
  if (!Value.Check(ConfigSchema, next))
215
234
  throw new ConfigError(`the new ${key} would not match schemas/config.json`);
216
235
  const h = handoffSettings(next);
@@ -251,6 +270,34 @@ export function editConfigUpdate(file, edit, opts = {}) {
251
270
  export function editConfigBrowser(file, edit, opts = {}) {
252
271
  return editConfigKey(file, 'browser', 'change the setting', (current) => edit(current ?? {}), opts);
253
272
  }
273
+ /**
274
+ * The Panel's drag and drop (spec §3.25): replaces the config `display` key, or removes it when `display` is undefined
275
+ * or empty (see `editConfigKey` for how the file is written).
276
+ */
277
+ export function editConfigDisplay(file, display) {
278
+ const accounts = Object.fromEntries(Object.entries(display?.accounts ?? {}).filter(([, names]) => names.length));
279
+ const next = {
280
+ ...(display?.families?.length ? { families: [...display.families] } : {}),
281
+ ...(Object.keys(accounts).length ? { accounts } : {}),
282
+ };
283
+ return editConfigKey(file, 'display', 'move it again', () => (Object.keys(next).length ? next : undefined));
284
+ }
285
+ /**
286
+ * The Panel's Quota check switch (spec §3.26): `false` writes `quota.check.<family>: false`, `true` removes that key
287
+ * (on is the default), and an empty `quota` goes away with it (see `editConfigKey` for how the file is written).
288
+ */
289
+ export function editConfigQuotaCheck(file, family, on) {
290
+ return editConfigKey(file, 'quota', 'change the setting', (current) => {
291
+ const check = { ...(current?.check ?? {}) };
292
+ if (on)
293
+ delete check[family];
294
+ else
295
+ check[family] = false;
296
+ const { check: _old, ...rest } = current ?? {};
297
+ const next = { ...rest, ...(Object.keys(check).length ? { check } : {}) };
298
+ return Object.keys(next).length ? next : undefined;
299
+ });
300
+ }
254
301
  /**
255
302
  * `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
256
303
  * written). With `dryRun`, shows the change without writing.
@@ -25,11 +25,12 @@ async function throughLink(dir, rel) {
25
25
  }
26
26
  /**
27
27
  * The other Accounts of the Family that lack the hook file `file` (a path in the Main Account) at the same place in
28
- * their own directory. Only that one file is ever written, and one that is already there is left as it is: an
29
- * Account's other files are never touched.
28
+ * their own directory, or hold an earlier version of it that an older sideby wrote (`earlierTexts`). Only that one
29
+ * file is ever written; one with other content is left as it is, and an Account's other files are never touched.
30
30
  */
31
31
  async function hookCopies(rt, family, file) {
32
32
  const accounts = (await rt.accounts()).filter((a) => a.family === family);
33
+ const earlier = rt.families.get(family)?.handoff?.hookSetup?.earlierTexts ?? [];
33
34
  const main = accounts.find((a) => a.isMain);
34
35
  if (!main)
35
36
  return [];
@@ -43,9 +44,13 @@ async function hookCopies(rt, family, file) {
43
44
  continue;
44
45
  const target = join(a.dir, rel);
45
46
  // Whether the file is there, not whether it can be read: one that cannot be read is still not ours to replace.
46
- const have = await lstat(target).then(() => true, () => false);
47
- // A file that is already there is not ours to replace, whatever it says.
48
- if (!have)
47
+ const st = await lstat(target).catch(() => null);
48
+ // A file that is already there is not ours to replace, unless it is, byte for byte, one an older sideby wrote.
49
+ if (!st)
50
+ out.push({ ref: a.ref, target });
51
+ else if (st.isFile() &&
52
+ earlier.length &&
53
+ earlier.includes(await readFile(target, 'utf8').catch(() => '')))
49
54
  out.push({ ref: a.ref, target });
50
55
  }
51
56
  return out;
@@ -34,6 +34,11 @@ export declare function sidebyBinProblem(env: Env, why: string): Promise<string
34
34
  * copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
35
35
  * then let `launch.before` hooks adjust or abort. Never touches the caller's environment.
36
36
  */
37
+ /**
38
+ * The Account's environment before its Secret File and Plugin hooks: `baseEnv` with the Hijack Variables cleared and
39
+ * the Family's `selectVar` set for every Account but the Main Account.
40
+ */
41
+ export declare function accountEnv(family: FamilyDef, account: Account, baseEnv: Env): Env;
37
42
  export declare function prepareLaunch(opts: {
38
43
  account: Account;
39
44
  family: FamilyDef;
@@ -59,14 +59,25 @@ export async function sidebyBinProblem(env, why) {
59
59
  * copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
60
60
  * then let `launch.before` hooks adjust or abort. Never touches the caller's environment.
61
61
  */
62
- export async function prepareLaunch(opts) {
63
- const { account, family } = opts;
64
- const env = { ...opts.baseEnv };
62
+ /**
63
+ * The Account's environment before its Secret File and Plugin hooks: `baseEnv` with the Hijack Variables cleared and
64
+ * the Family's `selectVar` set for every Account but the Main Account.
65
+ */
66
+ export function accountEnv(family, account, baseEnv) {
67
+ const env = { ...baseEnv };
65
68
  for (const v of family.hijackVars)
66
69
  delete env[v];
67
70
  delete env[family.selectVar];
71
+ if (!account.isMain)
72
+ env[family.selectVar] = account.dir;
73
+ return env;
74
+ }
75
+ export async function prepareLaunch(opts) {
76
+ const { account, family } = opts;
77
+ const env = accountEnv(family, account, opts.baseEnv);
68
78
  if (account.secretFile)
69
79
  Object.assign(env, await readSecretFile(account.secretFile, opts.baseEnv));
80
+ // The Secret File may not move the Account: its directory always wins.
70
81
  if (!account.isMain)
71
82
  env[family.selectVar] = account.dir;
72
83
  // Which Account this is, for a status line, prompt or hook to show (spec §3.5); set for every Launch, so a value
@@ -0,0 +1,22 @@
1
+ import type { Account, Env, QuotaFetch, QuotaResult, ReadContext } from '../types.ts';
2
+ import { type QuotaStoreEntry } from './quota-store.ts';
3
+ /**
4
+ * `wait`: ask when the kept answer is old, and wait for it (the CLI). `background`: ask when old, but show the kept
5
+ * answer now (the Panel; with nothing kept it waits a few seconds). `force`: ask now, and wait (a refresh). `never`:
6
+ * the kept answer only.
7
+ */
8
+ export type QuotaFetchMode = 'wait' | 'background' | 'force' | 'never';
9
+ export declare const FETCH_TIMEOUT_MS = 20000;
10
+ /** Why fetching is off for `family`, or null when it is on (spec §3.26). */
11
+ export declare function quotaCheckOff(family: string, config: {
12
+ quota?: {
13
+ check?: Record<string, boolean>;
14
+ };
15
+ }, env: Env): string | null;
16
+ /** The kept answer for `account`, after asking again when `mode` and its age say so. */
17
+ export declare function fetchedQuota(fq: QuotaFetch, account: Account, ctx: ReadContext, hostEnv: Env, mode: QuotaFetchMode): Promise<QuotaStoreEntry | null>;
18
+ /**
19
+ * What sideby shows from a Family that fetches Quota: the newer of the kept answer and the local reading; else why
20
+ * there is none (`off` is why fetching is off, or null).
21
+ */
22
+ export declare function shownQuota(local: QuotaResult | null, kept: QuotaStoreEntry | null, off: string | null): QuotaResult;
@@ -0,0 +1,98 @@
1
+ import { keptQuota, quotaStoreFile, readQuotaStore, writeQuotaStore, } from './quota-store.js';
2
+ import { inCi } from './update.js';
3
+ export const FETCH_TIMEOUT_MS = 20_000;
4
+ /** How long a `background` read waits when nothing is kept yet, so a first Panel load still shows Quota. */
5
+ const FIRST_WAIT_MS = 4_000;
6
+ /** One fetch per Account file at a time, shared by every reader in this process. */
7
+ const inFlight = new Map();
8
+ /** Why fetching is off for `family`, or null when it is on (spec §3.26). */
9
+ export function quotaCheckOff(family, config, env) {
10
+ if (config.quota?.check?.[family] === false)
11
+ return `Quota checks are turned off for this tool (config quota.check.${family}); turn them on in the Panel's settings`;
12
+ if (inCi(env))
13
+ return 'Quota checks are off in CI';
14
+ return null;
15
+ }
16
+ const sleep = (ms) => new Promise((resolve) => {
17
+ setTimeout(() => resolve(null), ms).unref();
18
+ });
19
+ async function runFetch(fq, account, ctx, hostEnv, file) {
20
+ const kept = await readQuotaStore(file);
21
+ const ac = new AbortController();
22
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
23
+ const aborted = new Promise((_, reject) => {
24
+ ac.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
25
+ });
26
+ aborted.catch(() => { });
27
+ let answer;
28
+ let error;
29
+ try {
30
+ answer = await Promise.race([fq.fetch(account, { ...ctx, signal: ac.signal, hostEnv }), aborted]);
31
+ }
32
+ catch (err) {
33
+ error = ac.signal.aborted
34
+ ? `no answer within ${FETCH_TIMEOUT_MS / 1000} seconds; sideby asks again in ${fq.everyMinutes} minutes`
35
+ : (err?.message ?? String(err));
36
+ }
37
+ finally {
38
+ clearTimeout(timer);
39
+ if (!ac.signal.aborted)
40
+ ac.abort();
41
+ }
42
+ if (answer?.status === 'unavailable')
43
+ error = answer.detail ?? answer.reason;
44
+ const quota = answer?.status === 'ok' ? keptQuota(answer) : kept?.quota;
45
+ const entry = {
46
+ version: 1,
47
+ attemptedAt: new Date().toISOString(),
48
+ ...(quota ? { quota } : {}),
49
+ ...(error ? { error } : {}),
50
+ };
51
+ await writeQuotaStore(file, entry).catch(() => { });
52
+ return entry;
53
+ }
54
+ /** The kept answer for `account`, after asking again when `mode` and its age say so. */
55
+ export async function fetchedQuota(fq, account, ctx, hostEnv, mode) {
56
+ const file = quotaStoreFile(ctx.stateDir, account.family, account.name);
57
+ const kept = await readQuotaStore(file);
58
+ if (mode === 'never')
59
+ return kept;
60
+ const at = kept ? Date.parse(kept.attemptedAt) : Number.NaN;
61
+ const age = ctx.now.getTime() - at;
62
+ // A time in the future (a changed clock) counts as old, so it cannot stop every later fetch.
63
+ const stale = !kept || !Number.isFinite(at) || age < -60_000 || age >= fq.everyMinutes * 60_000;
64
+ if (mode !== 'force' && !stale)
65
+ return kept;
66
+ let run = inFlight.get(file);
67
+ if (!run) {
68
+ run = runFetch(fq, account, ctx, hostEnv, file).finally(() => inFlight.delete(file));
69
+ inFlight.set(file, run);
70
+ }
71
+ if (mode !== 'background')
72
+ return run;
73
+ run.catch(() => { });
74
+ if (kept)
75
+ return kept;
76
+ return Promise.race([run, sleep(FIRST_WAIT_MS)]).then((e) => e ?? kept);
77
+ }
78
+ /**
79
+ * What sideby shows from a Family that fetches Quota: the newer of the kept answer and the local reading; else why
80
+ * there is none (`off` is why fetching is off, or null).
81
+ */
82
+ export function shownQuota(local, kept, off) {
83
+ const fetched = kept?.quota
84
+ ? { ...kept.quota, fetched: true, ...(kept.error ? { fetchError: kept.error } : {}) }
85
+ : null;
86
+ const own = local?.status === 'ok' ? local : null;
87
+ if (fetched && own)
88
+ return Date.parse(own.observedAt) > Date.parse(fetched.observedAt) ? own : fetched;
89
+ if (fetched)
90
+ return fetched;
91
+ if (own)
92
+ return own;
93
+ if (off)
94
+ return { status: 'unavailable', reason: 'not-enabled', detail: off };
95
+ if (kept?.error)
96
+ return { status: 'unavailable', reason: 'fetch-failed', detail: kept.error };
97
+ return local ?? { status: 'unavailable', reason: 'no-session' };
98
+ }
@@ -13,7 +13,8 @@ export declare function quotaLevel(usedPercent: number): QuotaLevel;
13
13
  export declare function windowReset(resetsAt: string, now: Date): boolean;
14
14
  /**
15
15
  * Quota Pressure: the used percent of the fullest window that has not reset yet (0 when all have reset),
16
- * or null when the Account has no Quota. The Panel keeps its own copy (`quotaPressure` in page-logic.ts,
17
- * -1 for null) because page-logic helpers must be self-contained; a test keeps the two equal.
16
+ * or null when the Account has no Quota. A window with a `name` covers only some models and never counts (spec
17
+ * §3.26). The Panel keeps its own copy (`quotaPressure` in page-logic.ts, -1 for null) because page-logic helpers
18
+ * must be self-contained; a test keeps the two equal.
18
19
  */
19
20
  export declare function quotaPressure(quota: QuotaResult | undefined, now: Date): number | null;
@@ -20,14 +20,18 @@ export function windowReset(resetsAt, now) {
20
20
  }
21
21
  /**
22
22
  * Quota Pressure: the used percent of the fullest window that has not reset yet (0 when all have reset),
23
- * or null when the Account has no Quota. The Panel keeps its own copy (`quotaPressure` in page-logic.ts,
24
- * -1 for null) because page-logic helpers must be self-contained; a test keeps the two equal.
23
+ * or null when the Account has no Quota. A window with a `name` covers only some models and never counts (spec
24
+ * §3.26). The Panel keeps its own copy (`quotaPressure` in page-logic.ts, -1 for null) because page-logic helpers
25
+ * must be self-contained; a test keeps the two equal.
25
26
  */
26
27
  export function quotaPressure(quota, now) {
27
- if (quota?.status !== 'ok' || quota.windows.length === 0)
28
+ if (quota?.status !== 'ok')
29
+ return null;
30
+ const windows = quota.windows.filter((w) => !w.name);
31
+ if (windows.length === 0)
28
32
  return null;
29
33
  let max = 0;
30
- for (const w of quota.windows)
34
+ for (const w of windows)
31
35
  if (!windowReset(w.resetsAt, now))
32
36
  max = Math.max(max, Math.min(100, Number(w.usedPercent) || 0));
33
37
  return max;
@@ -0,0 +1,19 @@
1
+ import type { QuotaResult } from '../types.ts';
2
+ export type FetchedQuota = Extract<QuotaResult, {
3
+ status: 'ok';
4
+ }>;
5
+ export interface QuotaStoreEntry {
6
+ version: 1;
7
+ /** When the latest fetch finished, good or not; the next one waits `everyMinutes` from here. */
8
+ attemptedAt: string;
9
+ /** The latest good answer, kept while later fetches fail. */
10
+ quota?: FetchedQuota;
11
+ /** Why the latest fetch failed, when it did. */
12
+ error?: string;
13
+ }
14
+ export declare function quotaStoreFile(stateDir: string, family: string, name: string): string;
15
+ /** Only the fields a Quota reading may carry, so a plugin's extra fields never reach the file. */
16
+ export declare function keptQuota(q: FetchedQuota): FetchedQuota;
17
+ /** The kept answer, or null when there is none or the file is not one sideby wrote. */
18
+ export declare function readQuotaStore(file: string): Promise<QuotaStoreEntry | null>;
19
+ export declare function writeQuotaStore(file: string, entry: QuotaStoreEntry): Promise<void>;
@@ -0,0 +1,62 @@
1
+ // The kept answers of `FamilyDef.fetchQuota` (spec §3.26): one file per Account in sideby's state directory, with the
2
+ // Quota numbers, reset times and plan name only. The handoff hook reads it, so this module imports only Node
3
+ // built-ins and leaf modules (never the Runtime, a Family index or core/accounts.ts).
4
+ import { mkdir, readFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { writeFileAtomic } from './fs-safe.js';
7
+ export function quotaStoreFile(stateDir, family, name) {
8
+ return join(stateDir, 'quota', family, `${name}.fetched.json`);
9
+ }
10
+ const isWindow = (w) => {
11
+ const v = w;
12
+ return (typeof v === 'object' &&
13
+ v !== null &&
14
+ typeof v.label === 'string' &&
15
+ (v.name === undefined || typeof v.name === 'string') &&
16
+ typeof v.windowMinutes === 'number' &&
17
+ typeof v.usedPercent === 'number' &&
18
+ typeof v.resetsAt === 'string');
19
+ };
20
+ /** Only the fields a Quota reading may carry, so a plugin's extra fields never reach the file. */
21
+ export function keptQuota(q) {
22
+ return {
23
+ status: 'ok',
24
+ windows: q.windows.filter(isWindow).map((w) => ({
25
+ label: w.label,
26
+ ...(w.name ? { name: w.name } : {}),
27
+ windowMinutes: w.windowMinutes,
28
+ usedPercent: w.usedPercent,
29
+ resetsAt: w.resetsAt,
30
+ })),
31
+ observedAt: q.observedAt,
32
+ source: q.source,
33
+ ...(typeof q.plan === 'string' && q.plan ? { plan: q.plan } : {}),
34
+ };
35
+ }
36
+ /** The kept answer, or null when there is none or the file is not one sideby wrote. */
37
+ export async function readQuotaStore(file) {
38
+ let data;
39
+ try {
40
+ data = JSON.parse(await readFile(file, 'utf8'));
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ const e = data;
46
+ if (typeof e !== 'object' || e === null || e.version !== 1 || typeof e.attemptedAt !== 'string')
47
+ return null;
48
+ const q = e.quota;
49
+ const quota = q && q.status === 'ok' && Array.isArray(q.windows) && typeof q.observedAt === 'string'
50
+ ? keptQuota({ ...q, source: typeof q.source === 'string' ? q.source : '' })
51
+ : undefined;
52
+ return {
53
+ version: 1,
54
+ attemptedAt: e.attemptedAt,
55
+ ...(quota ? { quota } : {}),
56
+ ...(typeof e.error === 'string' ? { error: e.error } : {}),
57
+ };
58
+ }
59
+ export async function writeQuotaStore(file, entry) {
60
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
61
+ await writeFileAtomic(file, `${JSON.stringify(entry)}\n`, 0o600);
62
+ }
@@ -1,5 +1,7 @@
1
1
  import type { FamilyDef, Finding } from '../types.ts';
2
2
  export declare const SKILL_INSTALL = "npx skills add Adonis0123/sideby -g";
3
+ /** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
4
+ export declare function gitRootOf(dir: string): Promise<string | null>;
3
5
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
4
6
  export declare function skillVersionOf(text: string): string | null;
5
7
  /**