sideby 0.13.0 → 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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +3 -2
  4. package/dist/src/cli/commands.js +69 -8
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +62 -2
  7. package/dist/src/cli/main.js +42 -4
  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 +15 -0
  20. package/dist/src/core/skill-check.js +49 -3
  21. package/dist/src/core/update.d.ts +45 -0
  22. package/dist/src/core/update.js +72 -1
  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 +9 -3
  33. package/dist/src/panel/handler.js +65 -3
  34. package/dist/src/panel/i18n.d.ts +25 -5
  35. package/dist/src/panel/i18n.js +52 -12
  36. package/dist/src/panel/page-logic.d.ts +18 -2
  37. package/dist/src/panel/page-logic.js +53 -6
  38. package/dist/src/panel/page-script.js +286 -92
  39. package/dist/src/panel/page-styles.js +30 -10
  40. package/dist/src/runtime.d.ts +30 -2
  41. package/dist/src/runtime.js +64 -21
  42. package/dist/src/types.d.ts +49 -1
  43. package/package.json +2 -2
  44. package/schemas/alias.json +62 -0
  45. package/schemas/app-install.json +62 -0
  46. package/schemas/app-uninstall.json +62 -0
  47. package/schemas/browser-open.json +62 -0
  48. package/schemas/browser-switch.json +62 -0
  49. package/schemas/browser.json +62 -0
  50. package/schemas/config.json +46 -0
  51. package/schemas/desktop-remove.json +62 -0
  52. package/schemas/desktop.json +62 -0
  53. package/schemas/doctor.json +62 -0
  54. package/schemas/error.json +62 -0
  55. package/schemas/families.json +62 -0
  56. package/schemas/handoff-disable.json +62 -0
  57. package/schemas/handoff-enable.json +62 -0
  58. package/schemas/handoff-ready.json +62 -0
  59. package/schemas/handoff-setup.json +62 -0
  60. package/schemas/handoff-status.json +62 -0
  61. package/schemas/handoff-teardown.json +62 -0
  62. package/schemas/list.json +62 -0
  63. package/schemas/model.json +62 -0
  64. package/schemas/new.json +62 -0
  65. package/schemas/next.json +62 -0
  66. package/schemas/plugins.json +62 -0
  67. package/schemas/quota-setup.json +62 -0
  68. package/schemas/quota-teardown.json +62 -0
  69. package/schemas/quota.json +76 -1
  70. package/schemas/remote-control.json +62 -0
  71. package/schemas/tune-change.json +62 -0
  72. package/schemas/tune.json +62 -0
  73. package/schemas/update.json +107 -1
  74. package/skills/sideby/SKILL.md +4 -2
  75. package/skills/sideby/references/diagnose.md +3 -2
  76. package/skills/sideby/references/handoff.md +1 -1
  77. package/skills/sideby/references/quota.md +5 -1
  78. package/skills/sideby/references/update.md +16 -0
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":"20261016T155413-627722"}
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;
@@ -8,13 +8,14 @@ export interface Io {
8
8
  /** The terminal Auto Handoff counts down on (spec §3.17); `process.stdin` when left out. */
9
9
  stdin?: HandoffIo['stdin'];
10
10
  }
11
+ export declare function setJsonNotice(n: object | null): void;
11
12
  export declare function serializeDoctor(r: DoctorReport): {
12
13
  status: "issues" | "ok";
13
14
  accounts: {
14
15
  ref: string;
15
16
  family: string;
16
17
  name: string;
17
- kind: import("../types.ts").Account['kind'];
18
+ kind: Account['kind'];
18
19
  dir: string;
19
20
  shared: {
20
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';
@@ -51,7 +51,12 @@ async function runAccountNow(rt, ref, args, io) {
51
51
  }
52
52
  return code;
53
53
  }
54
- const json = (io, data) => io.out(JSON.stringify({ schemaVersion: 1, ...data }, null, 2));
54
+ // The `_notice` main.ts works out before a `--json` command (spec §3.23); every JSON output of this run carries it.
55
+ let notice = null;
56
+ export function setJsonNotice(n) {
57
+ notice = n;
58
+ }
59
+ const json = (io, data) => io.out(JSON.stringify({ schemaVersion: 1, ...data, ...(notice ? { _notice: notice } : {}) }, null, 2));
55
60
  function stripFix(f) {
56
61
  const { fix: _fix, ...rest } = f;
57
62
  return rest;
@@ -68,15 +73,21 @@ export function serializeDoctor(r) {
68
73
  function tilde(text, home) {
69
74
  return text.split(`${home}/`).join('~/');
70
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
+ }
71
82
  export async function cmdList(a, io) {
72
83
  const rt = await createRuntime();
73
- const accounts = await rt.accounts();
84
+ const accounts = displayOrdered(await rt.accounts(), rt);
74
85
  const [statuses, appProfiles] = await Promise.all([
75
86
  withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))),
76
87
  listAppProfiles(rt),
77
88
  ]);
78
89
  const hasDesktop = statuses.some((s) => s.desktopApp);
79
- const families = await rt.familyInfo();
90
+ const families = inDisplayOrder(await rt.familyInfo(), (f) => f.id, rt.displayOrder().families);
80
91
  if (a.flags.has('json')) {
81
92
  json(io, { families, accounts: statuses, appProfiles, pluginErrors: [...rt.pluginErrors] });
82
93
  return 0;
@@ -674,7 +685,14 @@ export async function cmdQuota(a, io) {
674
685
  if (sub === 'setup' || sub === 'teardown')
675
686
  return cmdQuotaSetup(a, io, sub);
676
687
  const rt = await createRuntime();
677
- 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);
678
696
  const rows = await Promise.all(accounts.map(async (x) => ({
679
697
  ref: x.ref,
680
698
  kind: x.kind,
@@ -695,9 +713,12 @@ export async function cmdQuota(a, io) {
695
713
  `${quotaText(r.quota, now)}${r.quota.status === 'ok' && r.quota.from ? c.dim(` read from ${r.quota.from} (same login)`) : ''}`,
696
714
  usageText(r.usage),
697
715
  ]), ['ACCOUNT', 'QUOTA', `USAGE (${USAGE_DAYS}d)`]));
698
- for (const r of rows)
716
+ for (const r of rows) {
699
717
  if (r.quota.status === 'unavailable' && r.quota.detail)
700
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
+ }
701
722
  // Suggest a setup only where it would help: the Family has one and its plan can be applied.
702
723
  const off = new Set(rows
703
724
  .filter((r) => r.quota.status === 'unavailable' && r.quota.reason === 'not-enabled')
@@ -1051,11 +1072,41 @@ export async function cmdUpdate(a, io) {
1051
1072
  const asJson = a.flags.has('json');
1052
1073
  const plan = await u.planUpdate(paths, process.env);
1053
1074
  const shown = u.shownPlan(plan);
1075
+ const sk = plan.skill;
1076
+ const skillLine = (s) => s.canRun
1077
+ ? `the sideby skill (for ${s.installed.map((v) => v ?? 'no version').join(', ')}) is updated too:\n ${s.command}`
1078
+ : `the sideby skill is not updated by sideby: ${s.reason}.\n ${s.command}`;
1054
1079
  if (!plan.available) {
1080
+ // The CLI is current; an outdated skill is the one thing left to update.
1081
+ if (sk?.outdated && sk.canRun) {
1082
+ if (!a.flags.has('yes')) {
1083
+ if (asJson)
1084
+ json(io, { ...shown, updated: false });
1085
+ else {
1086
+ io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
1087
+ io.out(`\nUpdating ${skillLine(sk)}`);
1088
+ io.out(c.cyan('\nRun it with `sideby update --yes`.'));
1089
+ }
1090
+ return 10;
1091
+ }
1092
+ if (!asJson)
1093
+ io.out(c.dim(`$ ${sk.command}`));
1094
+ const r = await u.applySkillUpdate(sk, process.env, asJson ? undefined : (t) => process.stderr.write(t));
1095
+ if (asJson)
1096
+ json(io, { ...shown, updated: false, skillUpdated: r?.ok ?? false, message: r?.message ?? '' });
1097
+ else if (r?.ok)
1098
+ io.out(`${c.green('✓')} ${r.message}; agents load it in their next session.`);
1099
+ else
1100
+ io.err(c.red(`✗ ${r?.message}`));
1101
+ return r?.ok ? 0 : 1;
1102
+ }
1055
1103
  if (asJson)
1056
1104
  json(io, { ...shown, updated: false });
1057
- else
1105
+ else {
1058
1106
  io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
1107
+ if (sk?.outdated)
1108
+ io.out(c.yellow(skillLine(sk)));
1109
+ }
1059
1110
  return 0;
1060
1111
  }
1061
1112
  if (!plan.canRun) {
@@ -1073,6 +1124,8 @@ export async function cmdUpdate(a, io) {
1073
1124
  else {
1074
1125
  io.out(`sideby ${c.bold(plan.latest)} is available (you have ${plan.current}). What changed: ${plan.releaseNotes}`);
1075
1126
  io.out(`\nUpdating runs:\n ${plan.command}`);
1127
+ if (sk?.outdated)
1128
+ io.out(`Then ${skillLine(sk)}`);
1076
1129
  io.out(c.cyan('\nRun it with `sideby update --yes`.'));
1077
1130
  }
1078
1131
  return 10;
@@ -1091,6 +1144,7 @@ export async function cmdUpdate(a, io) {
1091
1144
  restartedPanel: r.restartedPanel,
1092
1145
  refreshedApp: r.refreshedApp,
1093
1146
  skill: SKILL_INSTALL,
1147
+ ...(r.skillUpdated === undefined ? {} : { skillUpdated: r.skillUpdated }),
1094
1148
  ...(r.ok ? {} : { output: r.output }),
1095
1149
  });
1096
1150
  return r.ok ? 0 : 1;
@@ -1105,7 +1159,14 @@ export async function cmdUpdate(a, io) {
1105
1159
  if (r.refreshedApp)
1106
1160
  io.out(' The desktop app now starts the new version and shows its icon.');
1107
1161
  io.out(' A `sideby ui` running in a terminal and sessions already running keep the old version until you restart them.');
1108
- io.out(` If you use the sideby skill, update it too: ${SKILL_INSTALL}`);
1162
+ if (r.skillUpdated)
1163
+ io.out(' The sideby skill was updated too; agents load it in their next session.');
1164
+ else if (r.skillUpdated === false)
1165
+ io.err(c.yellow(` ${r.skillMessage}`));
1166
+ else if (sk?.outdated)
1167
+ io.out(c.yellow(` ${skillLine(sk)}`));
1168
+ else if (!sk)
1169
+ io.out(` If you use the sideby skill, install it with: ${SKILL_INSTALL}`);
1109
1170
  return 0;
1110
1171
  }
1111
1172
  /** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
@@ -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,
@@ -212,6 +225,30 @@ const AppProfile = Type.Object({
212
225
  }, {
213
226
  description: 'A window of a desktop app that signs in apart from every account (Grok Bot, ADR-0019). Open or create one with `sideby desktop grok-bot:<name>`.',
214
227
  });
228
+ /**
229
+ * On any `--json` output when there is something to mention once the task is done (spec §3.23): a newer sideby in the
230
+ * cached Update Check, or an installed sideby skill for another version. Off with SIDEBY_NO_UPDATE_NOTIFIER.
231
+ */
232
+ const Notice = Type.Object({
233
+ update: Type.Optional(Type.Object({
234
+ current: Str,
235
+ latest: Str,
236
+ command: Type.String({
237
+ description: 'Run it after the user agrees: it shows the plan and exits 10; then --yes.',
238
+ }),
239
+ message: Str,
240
+ })),
241
+ skill: Type.Optional(Type.Object({
242
+ installed: Type.Union([Str, Type.Null()], {
243
+ description: "The skill's version; null when it has none.",
244
+ }),
245
+ current: Str,
246
+ command: Type.String({
247
+ description: 'Updates the installed sideby skill (run it after the user agrees).',
248
+ }),
249
+ message: Str,
250
+ })),
251
+ }, { description: 'Not the answer to the task: mention it briefly afterwards and offer to update.' });
215
252
  export const OUTPUT_SCHEMAS = {
216
253
  list: Type.Object({
217
254
  schemaVersion: Version,
@@ -564,7 +601,25 @@ export const OUTPUT_SCHEMAS = {
564
601
  refreshedApp: Type.Optional(Type.Boolean({
565
602
  description: 'The desktop app (`sideby app install`) was installed and was written again by the new version.',
566
603
  })),
567
- skill: Type.Optional(Type.String({ description: 'The command that updates the sideby skill; sideby never runs it.' })),
604
+ skill: Type.Optional(Type.String({ description: 'The command that installs the sideby skill by hand.' })),
605
+ skillUpdate: Type.Optional(Type.Object({
606
+ installed: Type.Array(Type.Union([Str, Type.Null()]), {
607
+ description: 'Versions of the installed sideby skills; null for one without a version.',
608
+ }),
609
+ outdated: Type.Boolean({
610
+ description: 'Some installed skill is for another version than sideby will run.',
611
+ }),
612
+ command: Str,
613
+ canRun: Type.Boolean({
614
+ description: 'False when sideby leaves the skill alone (see reason): give the user command.',
615
+ }),
616
+ reason: Type.Optional(Str),
617
+ }, {
618
+ description: 'The installed sideby skill, which the Update brings along; absent when none is installed.',
619
+ })),
620
+ skillUpdated: Type.Optional(Type.Boolean({
621
+ description: 'The skill was updated too (true) or that failed (false); absent when not tried.',
622
+ })),
568
623
  output: Type.Optional(Type.String({ description: 'What the failed install command printed.' })),
569
624
  }, { title: 'sideby update --json' }),
570
625
  error: Type.Object({
@@ -576,3 +631,8 @@ export const OUTPUT_SCHEMAS = {
576
631
  }, { title: 'sideby error --json' }),
577
632
  config: ConfigSchema,
578
633
  };
634
+ for (const [name, schema] of Object.entries(OUTPUT_SCHEMAS)) {
635
+ const props = schema.properties;
636
+ if (name !== 'config' && props)
637
+ props._notice = Type.Optional(Notice);
638
+ }
@@ -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'] },
@@ -249,6 +250,38 @@ function noticeAllowed(cmd, parsed) {
249
250
  return false;
250
251
  return true;
251
252
  }
253
+ /**
254
+ * Before a `--json` command (spec §3.23): the `_notice` its output carries, from the cached Update Check and the
255
+ * installed sideby skills, so an agent that reads JSON learns of a newer version too. Starts the next check detached
256
+ * when the last one is a day old, as afterCommand does on a terminal. Never fails the command.
257
+ */
258
+ async function jsonNoticeFor() {
259
+ try {
260
+ const u = await import('../core/update.js');
261
+ const { resolvePaths } = await import('../core/paths.js');
262
+ const { loadConfig } = await import('../core/config.js');
263
+ const { packageVersion } = await import('../core/version.js');
264
+ const { installedSkillVersions, SKILL_INSTALL } = await import('../core/skill-check.js');
265
+ const paths = resolvePaths(process.env);
266
+ const config = await loadConfig(paths.configFile).catch(() => ({}));
267
+ const cache = await u.readUpdateCache(paths);
268
+ const skills = await installedSkillVersions(paths.home);
269
+ const n = u.jsonNotice(cache, packageVersion(), config, process.env, skills, SKILL_INSTALL);
270
+ if (!u.checksOff(process.env, config) && u.checkDue(cache, new Date())) {
271
+ const { spawn } = await import('node:child_process');
272
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'update-check'], {
273
+ detached: true,
274
+ stdio: 'ignore',
275
+ });
276
+ child.on('error', () => { });
277
+ child.unref();
278
+ }
279
+ return n;
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ }
252
285
  /**
253
286
  * After a command (spec §3.23): print the Update Notice and the result of an Auto Update, each at most once, and
254
287
  * start the next Update Check detached when the last one is a day old. Only on a terminal; never fails the command.
@@ -319,6 +352,7 @@ export async function main(argv) {
319
352
  // Only sideby's own options count: arguments after `--` belong to the host.
320
353
  const own = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
321
354
  const wantsJson = Boolean(spec?.flags.includes('json')) && own.includes('--json');
355
+ let notice = null;
322
356
  try {
323
357
  if (!spec)
324
358
  throw new UsageError(`unknown command "${cmd}"; run \`sideby --help\``);
@@ -354,6 +388,10 @@ export async function main(argv) {
354
388
  return 0;
355
389
  }
356
390
  const cmds = await import('./commands.js');
391
+ if (wantsJson && noticeAllowed(cmd, parsed)) {
392
+ notice = await jsonNoticeFor();
393
+ cmds.setJsonNotice(notice);
394
+ }
357
395
  const code = await dispatch(cmd, parsed, io, cmds);
358
396
  if (!wantsJson && noticeAllowed(cmd, parsed))
359
397
  await afterCommand();
@@ -365,7 +403,7 @@ export async function main(argv) {
365
403
  if (wantsJson) {
366
404
  const { UserError } = await import('../core/errors.js');
367
405
  const code = usage ? 'usage' : err instanceof UserError && err.code ? err.code : 'error';
368
- console.log(JSON.stringify({ schemaVersion: 1, error: message, code }, null, 2));
406
+ console.log(JSON.stringify({ schemaVersion: 1, error: message, code, ...(notice ? { _notice: notice } : {}) }, null, 2));
369
407
  }
370
408
  else
371
409
  console.error(`sideby: ${message}`);
@@ -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;