sideby 0.13.1 → 0.15.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 (85) hide show
  1. package/README.md +2 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +4 -2
  4. package/dist/src/cli/commands.js +194 -11
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +104 -3
  7. package/dist/src/cli/main.js +27 -7
  8. package/dist/src/core/config.d.ts +28 -0
  9. package/dist/src/core/config.js +54 -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 +16 -0
  20. package/dist/src/core/skill-check.js +47 -3
  21. package/dist/src/core/update.d.ts +67 -1
  22. package/dist/src/core/update.js +147 -37
  23. package/dist/src/families/grok/handoff-hook.d.ts +6 -0
  24. package/dist/src/families/grok/handoff-hook.js +45 -2
  25. package/dist/src/families/grok/handoff.d.ts +6 -1
  26. package/dist/src/families/grok/handoff.js +30 -13
  27. package/dist/src/families/grok/index.js +5 -0
  28. package/dist/src/families/grok/quota-log.d.ts +30 -0
  29. package/dist/src/families/grok/quota-log.js +140 -0
  30. package/dist/src/families/grok/quota.d.ts +5 -0
  31. package/dist/src/families/grok/quota.js +112 -0
  32. package/dist/src/panel/handler.d.ts +13 -3
  33. package/dist/src/panel/handler.js +81 -8
  34. package/dist/src/panel/i18n.d.ts +138 -8
  35. package/dist/src/panel/i18n.js +301 -41
  36. package/dist/src/panel/page-logic.d.ts +59 -2
  37. package/dist/src/panel/page-logic.js +99 -6
  38. package/dist/src/panel/page-script.js +769 -175
  39. package/dist/src/panel/page-styles.js +83 -15
  40. package/dist/src/panel/page.js +3 -0
  41. package/dist/src/runtime.d.ts +32 -2
  42. package/dist/src/runtime.js +64 -21
  43. package/dist/src/types.d.ts +49 -1
  44. package/package.json +3 -2
  45. package/schemas/alias.json +1 -1
  46. package/schemas/app-install.json +1 -1
  47. package/schemas/app-uninstall.json +1 -1
  48. package/schemas/browser-open.json +1 -1
  49. package/schemas/browser-switch.json +1 -1
  50. package/schemas/browser.json +1 -1
  51. package/schemas/config.json +60 -0
  52. package/schemas/desktop-remove.json +1 -1
  53. package/schemas/desktop.json +1 -1
  54. package/schemas/doctor.json +1 -1
  55. package/schemas/error.json +1 -1
  56. package/schemas/families.json +1 -1
  57. package/schemas/handoff-disable.json +1 -1
  58. package/schemas/handoff-enable.json +1 -1
  59. package/schemas/handoff-ready.json +1 -1
  60. package/schemas/handoff-setup.json +1 -1
  61. package/schemas/handoff-status.json +282 -1
  62. package/schemas/handoff-teardown.json +1 -1
  63. package/schemas/list.json +6 -1
  64. package/schemas/model.json +1 -1
  65. package/schemas/new.json +1 -1
  66. package/schemas/next.json +1 -1
  67. package/schemas/order.json +148 -0
  68. package/schemas/pin.json +126 -0
  69. package/schemas/plugins.json +1 -1
  70. package/schemas/quota-check.json +88 -0
  71. package/schemas/quota-setup.json +1 -1
  72. package/schemas/quota-teardown.json +1 -1
  73. package/schemas/quota.json +15 -2
  74. package/schemas/remote-control.json +1 -1
  75. package/schemas/tune-change.json +1 -1
  76. package/schemas/tune.json +1 -1
  77. package/schemas/unpin.json +126 -0
  78. package/schemas/update-auto.json +84 -0
  79. package/schemas/update.json +79 -1
  80. package/skills/sideby/SKILL.md +7 -3
  81. package/skills/sideby/references/diagnose.md +2 -1
  82. package/skills/sideby/references/handoff.md +9 -1
  83. package/skills/sideby/references/panel.md +28 -0
  84. package/skills/sideby/references/quota.md +11 -1
  85. package/skills/sideby/references/update.md +6 -2
@@ -106,6 +106,26 @@ 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
+ pinned: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }), {
118
+ description: 'Per family, the names of the accounts pinned to the top of their group, in the order they are shown; they lead the group in every sort. Resetting the order keeps them.',
119
+ })),
120
+ }, {
121
+ additionalProperties: false,
122
+ description: 'Display Order (spec §3.25): the order you gave families and accounts in the Panel, and the accounts you pinned. Display only: it never changes which account a Handoff picks.',
123
+ })),
124
+ quota: Type.Optional(Type.Object({
125
+ check: Type.Optional(Type.Record(Type.String(), Type.Boolean(), {
126
+ 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.',
127
+ })),
128
+ }, { additionalProperties: false, description: 'Quota that sideby asks for (spec §3.26, ADR-0025).' })),
109
129
  shellInitFile: Type.Optional(Type.Object({
110
130
  zsh: Type.Optional(Type.String({ minLength: 1 })),
111
131
  bash: Type.Optional(Type.String({ minLength: 1 })),
@@ -172,7 +192,7 @@ async function configTarget(file) {
172
192
  }
173
193
  /**
174
194
  * 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
195
+ * `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
196
  * with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
177
197
  * 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
198
  * while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
@@ -211,6 +231,8 @@ async function editConfigKey(file, key, retry, edit, opts = {}) {
211
231
  const next = raw === null
212
232
  ? { $schema: 'https://unpkg.com/sideby/schemas/config.json', [key]: after }
213
233
  : { ...data, [key]: after };
234
+ if (after === undefined)
235
+ delete next[key];
214
236
  if (!Value.Check(ConfigSchema, next))
215
237
  throw new ConfigError(`the new ${key} would not match schemas/config.json`);
216
238
  const h = handoffSettings(next);
@@ -251,6 +273,37 @@ export function editConfigUpdate(file, edit, opts = {}) {
251
273
  export function editConfigBrowser(file, edit, opts = {}) {
252
274
  return editConfigKey(file, 'browser', 'change the setting', (current) => edit(current ?? {}), opts);
253
275
  }
276
+ /**
277
+ * The Panel's drag and drop (spec §3.25): replaces the config `display` key, or removes it when `display` is undefined
278
+ * or empty (see `editConfigKey` for how the file is written).
279
+ */
280
+ export function editConfigDisplay(file, display) {
281
+ const lists = (r) => Object.fromEntries(Object.entries(r ?? {}).filter(([, names]) => names.length));
282
+ const accounts = lists(display?.accounts);
283
+ const pinned = lists(display?.pinned);
284
+ const next = {
285
+ ...(display?.families?.length ? { families: [...display.families] } : {}),
286
+ ...(Object.keys(accounts).length ? { accounts } : {}),
287
+ ...(Object.keys(pinned).length ? { pinned } : {}),
288
+ };
289
+ return editConfigKey(file, 'display', 'move it again', () => (Object.keys(next).length ? next : undefined));
290
+ }
291
+ /**
292
+ * The Panel's Quota check switch (spec §3.26): `false` writes `quota.check.<family>: false`, `true` removes that key
293
+ * (on is the default), and an empty `quota` goes away with it (see `editConfigKey` for how the file is written).
294
+ */
295
+ export function editConfigQuotaCheck(file, family, on) {
296
+ return editConfigKey(file, 'quota', 'change the setting', (current) => {
297
+ const check = { ...(current?.check ?? {}) };
298
+ if (on)
299
+ delete check[family];
300
+ else
301
+ check[family] = false;
302
+ const { check: _old, ...rest } = current ?? {};
303
+ const next = { ...rest, ...(Object.keys(check).length ? { check } : {}) };
304
+ return Object.keys(next).length ? next : undefined;
305
+ });
306
+ }
254
307
  /**
255
308
  * `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
256
309
  * 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,21 @@
1
1
  import type { FamilyDef, Finding } from '../types.ts';
2
+ export declare const SKILL_SOURCE = "Adonis0123/sideby";
2
3
  export declare const SKILL_INSTALL = "npx skills add Adonis0123/sideby -g";
4
+ /** The command a person runs to update the skill where sideby leaves it alone. */
5
+ export declare const SKILL_UPDATE_SHOWN = "npx skills update sideby -g";
6
+ /** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
7
+ export declare function gitRootOf(dir: string): Promise<string | null>;
8
+ /**
9
+ * Whether the skills tool's install record (`.skill-lock.json` beside the `skills` folder holding `dir`) names this
10
+ * repository as the source of `sideby`: then `npx skills update` owns the skill, even inside a git repository (a
11
+ * dotfiles clone of `~/.agents`, say). Reads only that one entry's `source`.
12
+ */
13
+ export declare function installedBySkillsTool(dir: string): Promise<boolean>;
14
+ /**
15
+ * The git repository that owns the sideby skill in `dir`, or null when sideby may update it: not in a repository, or
16
+ * the skills tool installed it there.
17
+ */
18
+ export declare function skillOwnerRepo(dir: string): Promise<string | null>;
3
19
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
4
20
  export declare function skillVersionOf(text: string): string | null;
5
21
  /**
@@ -1,11 +1,51 @@
1
1
  // Doctor's look at an installed sideby Agent Skill (spec §3.11). Read-only: a skill written for another
2
2
  // sideby version teaches commands this CLI may not have, so a version mismatch is a warning with the
3
3
  // reinstall command. Not installed is not a problem.
4
- import { readFile, realpath } from 'node:fs/promises';
4
+ import { readFile, realpath, stat } from 'node:fs/promises';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { mainDirOf } from './accounts.js';
7
7
  import { tildify } from './paths.js';
8
- export const SKILL_INSTALL = 'npx skills add Adonis0123/sideby -g';
8
+ export const SKILL_SOURCE = 'Adonis0123/sideby';
9
+ export const SKILL_INSTALL = `npx skills add ${SKILL_SOURCE} -g`;
10
+ /** The command a person runs to update the skill where sideby leaves it alone. */
11
+ export const SKILL_UPDATE_SHOWN = 'npx skills update sideby -g';
12
+ /** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
13
+ export async function gitRootOf(dir) {
14
+ for (let d = dir;; d = dirname(d)) {
15
+ try {
16
+ await stat(join(d, '.git'));
17
+ return d;
18
+ }
19
+ catch {
20
+ // keep looking up
21
+ }
22
+ if (dirname(d) === d)
23
+ return null;
24
+ }
25
+ }
26
+ /**
27
+ * Whether the skills tool's install record (`.skill-lock.json` beside the `skills` folder holding `dir`) names this
28
+ * repository as the source of `sideby`: then `npx skills update` owns the skill, even inside a git repository (a
29
+ * dotfiles clone of `~/.agents`, say). Reads only that one entry's `source`.
30
+ */
31
+ export async function installedBySkillsTool(dir) {
32
+ try {
33
+ const lock = JSON.parse(await readFile(join(dirname(dirname(dir)), '.skill-lock.json'), 'utf8'));
34
+ const source = lock.skills?.sideby?.source;
35
+ return typeof source === 'string' && source.toLowerCase() === SKILL_SOURCE.toLowerCase();
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * The git repository that owns the sideby skill in `dir`, or null when sideby may update it: not in a repository, or
43
+ * the skills tool installed it there.
44
+ */
45
+ export async function skillOwnerRepo(dir) {
46
+ const repo = await gitRootOf(dir);
47
+ return repo && !(await installedBySkillsTool(dir)) ? repo : null;
48
+ }
9
49
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
10
50
  export function skillVersionOf(text) {
11
51
  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
@@ -70,13 +110,17 @@ export async function checkInstalledSkills(home, families, version) {
70
110
  if (found === version)
71
111
  continue;
72
112
  const where = tildify(path, home);
113
+ // `sideby update` brings the skill along, except one a git repository owns (a dotfiles clone, say).
114
+ const repo = await skillOwnerRepo(dirname(real));
73
115
  out.push({
74
116
  level: 'warn',
75
117
  account: `${family.id}:main`,
76
118
  item: where,
77
119
  code: 'skill.outdated',
78
120
  message: `the sideby skill at ${where} is ${found ? `for sideby ${found}` : 'from before skills had a version'}; this sideby is ${version}`,
79
- hint: `update it with \`${SKILL_INSTALL}\`, so agents learn this version's commands`,
121
+ hint: repo
122
+ ? `it lives in the git repository ${tildify(repo, home)}: update it where that repository comes from (\`${SKILL_UPDATE_SHOWN}\` there), so agents learn this version's commands`
123
+ : `run \`sideby update\`, which updates the skill too (or \`${SKILL_INSTALL}\` by hand), so agents learn this version's commands`,
80
124
  source: 'core',
81
125
  fixable: false,
82
126
  });
@@ -32,7 +32,25 @@ export interface UpdateCache {
32
32
  message: string;
33
33
  shown?: boolean;
34
34
  };
35
+ /** The last line the CLI printed about a sideby skill behind this version (`version` is the CLI's). */
36
+ skillNotified?: {
37
+ version: string;
38
+ at: string;
39
+ };
40
+ /** The last time Auto Update brought the skill to `to` alone; `shown` once the CLI has said so. */
41
+ skillAuto?: {
42
+ to: string;
43
+ at: string;
44
+ ok: boolean;
45
+ message: string;
46
+ shown?: boolean;
47
+ };
35
48
  }
49
+ /**
50
+ * Why sideby cannot do something itself, for a Panel to say in the viewer's language; `reason` keeps the English
51
+ * text the CLI prints. `reasonAt` carries the path a code needs.
52
+ */
53
+ export type ReasonCode = 'local-build' | 'npx' | 'volta' | 'not-writable' | 'project' | 'source' | 'skill-in-git';
36
54
  export type InstallKind = 'npm' | 'pnpm' | 'bun' | 'volta' | 'npx' | 'project' | 'source' | 'local';
37
55
  export interface InstallMethod {
38
56
  kind: InstallKind;
@@ -42,6 +60,9 @@ export interface InstallMethod {
42
60
  canUpdate: boolean;
43
61
  /** Why it cannot, in a phrase that follows "sideby cannot update itself: ". */
44
62
  reason?: string;
63
+ reasonCode?: ReasonCode;
64
+ /** The folders that are not writable (`not-writable`). */
65
+ reasonAt?: string;
45
66
  /** npm only: the prefix whose `lib/node_modules` holds sideby. */
46
67
  prefix?: string;
47
68
  }
@@ -87,6 +108,12 @@ type UpdateSettings = {
87
108
  auto?: boolean;
88
109
  };
89
110
  };
111
+ export type ChecksOffCode = 'local-build' | 'env' | 'config' | 'ci';
112
+ /** Why Update Checks are off, with a code for the Panel, or null when they are on (spec §3.23). */
113
+ export declare function checksOffWhy(env: Env, config: UpdateSettings, local?: boolean): {
114
+ code: ChecksOffCode;
115
+ text: string;
116
+ } | null;
90
117
  /** Why Update Checks are off, or null when they are on (spec §3.23). */
91
118
  export declare function checksOff(env: Env, config: UpdateSettings, local?: boolean): string | null;
92
119
  export declare function readUpdateCache(paths: Paths): Promise<UpdateCache>;
@@ -114,6 +141,29 @@ export declare function refreshUpdateCheck(paths: Paths, env: Env): Promise<Upda
114
141
  export declare function noticeLine(cache: UpdateCache, current: string, now: Date): string | null;
115
142
  /** What the CLI says once about the last Auto Update, or null. */
116
143
  export declare function autoUpdateLine(cache: UpdateCache): string | null;
144
+ /** What the CLI says once about the skill Auto Update brought up to date alone, or null. */
145
+ export declare function skillAutoLine(cache: UpdateCache): string | null;
146
+ /**
147
+ * The sideby skill an agent reads, as the CLI sees it after a command: the version of the first one behind
148
+ * `current` (null: one without a version), and whether sideby may update it. Absent when none is behind.
149
+ */
150
+ export interface SkillBehind {
151
+ installed: string | null;
152
+ canRun: boolean;
153
+ }
154
+ /** The skill part of a `SkillUpdatePlan` toward the running version, as `afterCommandPlan` takes it. */
155
+ export declare const skillBehind: (plan: SkillUpdatePlan | null, current: string) => SkillBehind | null;
156
+ /**
157
+ * The line about a skill behind this version the CLI may print now (spec §3.23), or null: once a day per CLI version,
158
+ * and never while Auto Update is on (the background check brings the skill along) and sideby may update it.
159
+ */
160
+ export declare function skillNoticeLine(cache: UpdateCache, current: string, config: UpdateSettings, skill: SkillBehind | null, now: Date): string | null;
161
+ /**
162
+ * Auto Update is on, the CLI is the newest version the cache knows, and the skill is behind it where sideby may
163
+ * update it, not tried for this version yet (or a failed try a day ago). A newer CLI waiting is left to the Update
164
+ * of the CLI: the skills tool fetches the default branch, which would put the skill ahead of this CLI.
165
+ */
166
+ export declare function skillAutoPending(cache: UpdateCache, current: string, config: UpdateSettings, skill: SkillBehind | null, now: Date): boolean;
117
167
  /** The `_notice` of a `--json` output (spec §3.23): what an agent should mention once its task is done. */
118
168
  export interface JsonNotice {
119
169
  update?: {
@@ -140,7 +190,7 @@ export declare function jsonNotice(cache: UpdateCache, current: string, config:
140
190
  * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
141
191
  * writes `record` with `updateUpdateCache` and starts the check; nothing here touches the machine.
142
192
  */
143
- export declare function afterCommandPlan(cache: UpdateCache, current: string, config: UpdateSettings, env: Env, now: Date): {
193
+ export declare function afterCommandPlan(cache: UpdateCache, current: string, config: UpdateSettings, env: Env, now: Date, skill?: SkillBehind | null): {
144
194
  lines: string[];
145
195
  record: ((cur: UpdateCache) => Partial<UpdateCache>) | null;
146
196
  startCheck: boolean;
@@ -183,6 +233,8 @@ export declare const shownMethod: (m: InstallMethod) => {
183
233
  kind: InstallKind;
184
234
  canUpdate: boolean;
185
235
  reason?: string | undefined;
236
+ reasonCode?: ReasonCode | undefined;
237
+ reasonAt?: string | undefined;
186
238
  };
187
239
  /** The plan as `sideby update --json` and the Panel show it (schemas/update.json). */
188
240
  export declare function shownPlan(plan: UpdatePlan): {
@@ -193,6 +245,8 @@ export declare function shownPlan(plan: UpdatePlan): {
193
245
  kind: InstallKind;
194
246
  canUpdate: boolean;
195
247
  reason?: string | undefined;
248
+ reasonCode?: ReasonCode | undefined;
249
+ reasonAt?: string | undefined;
196
250
  };
197
251
  command: string;
198
252
  canRun: boolean;
@@ -209,6 +263,9 @@ export interface SkillUpdatePlan {
209
263
  /** False when sideby must not run it; `reason` says why and the user runs `command` where it belongs. */
210
264
  canRun: boolean;
211
265
  reason?: string;
266
+ reasonCode?: ReasonCode;
267
+ /** The git repository that owns the skill (`skill-in-git`). */
268
+ reasonAt?: string;
212
269
  }
213
270
  /** The skill part of an Update toward `target`; null when no sideby skill is installed. Reads two files. */
214
271
  export declare function skillUpdatePlan(home: string, target: string): Promise<SkillUpdatePlan | null>;
@@ -219,6 +276,15 @@ export declare function applySkillUpdate(plan: SkillUpdatePlan | undefined, env:
219
276
  ok: boolean;
220
277
  message: string;
221
278
  } | null>;
279
+ /**
280
+ * Updates only the sideby skill, for a CLI that is already current (`sideby update`, the Panel, Auto Update). Holds
281
+ * the same lock as an Update, so a skill update never runs beside one; null when there was nothing to do.
282
+ */
283
+ export declare function applySkillOnly(paths: Paths, plan: SkillUpdatePlan | undefined, env: Env, onOutput?: (text: string) => void): Promise<{
284
+ ok: boolean;
285
+ message: string;
286
+ busy?: boolean;
287
+ } | null>;
222
288
  export interface UpdateResult {
223
289
  ok: boolean;
224
290
  /** Exit code of the install command; absent when it did not run. */