sideby 0.12.1 → 0.13.1

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 (86) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +5 -2
  4. package/dist/src/cli/commands.js +165 -20
  5. package/dist/src/cli/json-schemas.js +100 -2
  6. package/dist/src/cli/main.js +72 -4
  7. package/dist/src/core/brand-mark.d.ts +5 -7
  8. package/dist/src/core/brand-mark.js +18 -5
  9. package/dist/src/core/browser-profiles.d.ts +71 -0
  10. package/dist/src/core/browser-profiles.js +361 -0
  11. package/dist/src/core/config.d.ts +15 -0
  12. package/dist/src/core/config.js +13 -1
  13. package/dist/src/core/create.js +2 -1
  14. package/dist/src/core/desktop-app.d.ts +3 -1
  15. package/dist/src/core/desktop-app.js +29 -8
  16. package/dist/src/core/launch.d.ts +7 -0
  17. package/dist/src/core/launch.js +22 -2
  18. package/dist/src/core/leveldb-keys.d.ts +14 -0
  19. package/dist/src/core/leveldb-keys.js +291 -0
  20. package/dist/src/core/remote-control.d.ts +17 -6
  21. package/dist/src/core/remote-control.js +40 -17
  22. package/dist/src/core/skill-check.d.ts +13 -0
  23. package/dist/src/core/skill-check.js +29 -1
  24. package/dist/src/core/update.d.ts +55 -3
  25. package/dist/src/core/update.js +124 -4
  26. package/dist/src/core/version.d.ts +11 -0
  27. package/dist/src/core/version.js +26 -0
  28. package/dist/src/desktop/app.d.ts +2 -0
  29. package/dist/src/desktop/app.js +39 -3
  30. package/dist/src/families/browser.d.ts +20 -0
  31. package/dist/src/families/browser.js +23 -0
  32. package/dist/src/families/claude/browser.d.ts +3 -0
  33. package/dist/src/families/claude/browser.js +14 -0
  34. package/dist/src/families/claude/desktop.js +1 -0
  35. package/dist/src/families/codex/browser.d.ts +7 -0
  36. package/dist/src/families/codex/browser.js +26 -0
  37. package/dist/src/families/desktop.d.ts +5 -1
  38. package/dist/src/families/pi/index.js +2 -0
  39. package/dist/src/panel/handler.d.ts +9 -1
  40. package/dist/src/panel/handler.js +54 -6
  41. package/dist/src/panel/i18n.d.ts +74 -4
  42. package/dist/src/panel/i18n.js +160 -20
  43. package/dist/src/panel/page-logic.d.ts +23 -0
  44. package/dist/src/panel/page-logic.js +44 -0
  45. package/dist/src/panel/page-script.js +587 -183
  46. package/dist/src/panel/page-styles.js +61 -18
  47. package/dist/src/panel/page.d.ts +5 -0
  48. package/dist/src/panel/page.js +10 -9
  49. package/dist/src/runtime.d.ts +6 -1
  50. package/dist/src/runtime.js +22 -11
  51. package/dist/src/types.d.ts +2 -0
  52. package/package.json +5 -3
  53. package/schemas/alias.json +62 -0
  54. package/schemas/app-install.json +62 -0
  55. package/schemas/app-uninstall.json +62 -0
  56. package/schemas/browser-open.json +109 -0
  57. package/schemas/browser-switch.json +104 -0
  58. package/schemas/browser.json +161 -0
  59. package/schemas/config.json +11 -0
  60. package/schemas/desktop-remove.json +62 -0
  61. package/schemas/desktop.json +62 -0
  62. package/schemas/doctor.json +62 -0
  63. package/schemas/error.json +62 -0
  64. package/schemas/families.json +68 -0
  65. package/schemas/handoff-disable.json +62 -0
  66. package/schemas/handoff-enable.json +62 -0
  67. package/schemas/handoff-ready.json +62 -0
  68. package/schemas/handoff-setup.json +62 -0
  69. package/schemas/handoff-status.json +62 -0
  70. package/schemas/handoff-teardown.json +62 -0
  71. package/schemas/list.json +68 -0
  72. package/schemas/model.json +62 -0
  73. package/schemas/new.json +62 -0
  74. package/schemas/next.json +62 -0
  75. package/schemas/plugins.json +62 -0
  76. package/schemas/quota-setup.json +62 -0
  77. package/schemas/quota-teardown.json +62 -0
  78. package/schemas/quota.json +62 -0
  79. package/schemas/remote-control.json +66 -0
  80. package/schemas/tune-change.json +62 -0
  81. package/schemas/tune.json +62 -0
  82. package/schemas/update.json +111 -1
  83. package/skills/sideby/SKILL.md +8 -4
  84. package/skills/sideby/references/diagnose.md +1 -1
  85. package/skills/sideby/references/setup.md +1 -1
  86. package/skills/sideby/references/update.md +16 -0
package/README.md CHANGED
@@ -77,7 +77,7 @@ cc-switch's docs describe that switch: it writes the live settings file ([README
77
77
  | See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |
78
78
  | Find the cheapest auto-compact size from your own sessions, and use it in one click | `sideby tune` (or the panel's Context tuning tab) | [Context tuning](docs/guide/usage.md#context-tuning) |
79
79
  | See each account's model and effort, and set them per account | `sideby model <account>` (or click the model in the panel) | [Model and effort](docs/guide/usage.md#model-and-effort) |
80
- | Stop Claude sessions from connecting to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` (or the panel's banner) | [Remote Control](docs/guide/usage.md#remote-control) |
80
+ | Choose whether Claude sessions connect to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` or `on` (or the panel's Settings) | [Remote Control](docs/guide/usage.md#remote-control) |
81
81
  | Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |
82
82
  | Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |
83
83
  | Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261016T081651-7c664d"}
1
+ {"id":"20261016T161929-77384f"}
@@ -8,6 +8,7 @@ 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: {
@@ -44,8 +45,8 @@ export declare function cmdResume(a: ParsedArgs, io: Io): Promise<number>;
44
45
  export declare function cmdNext(a: ParsedArgs, io: Io): Promise<number>;
45
46
  export declare function cmdDoctor(a: ParsedArgs, io: Io): Promise<number>;
46
47
  /**
47
- * `sideby remote-control [off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
48
- * claude.ai by themselves, and turning that off for every Account (a preview with exit 10 until --yes).
48
+ * `sideby remote-control [on|off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
49
+ * claude.ai by themselves, and turning that on or off for every Account (a preview with exit 10 until --yes).
49
50
  */
50
51
  export declare function cmdRemoteControl(a: ParsedArgs, io: Io): Promise<number>;
51
52
  /**
@@ -74,6 +75,8 @@ export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
74
75
  * a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
75
76
  */
76
77
  export declare function cmdDesktop(a: ParsedArgs, io: Io): Promise<number>;
78
+ /** `sideby browser [--enable|--disable] [--yes]` and `sideby browser open <account> [--profile <p>]` (spec §3.24). */
79
+ export declare function cmdBrowser(a: ParsedArgs, io: Io): Promise<number>;
77
80
  export declare function cmdAlias(a: ParsedArgs, io: Io): Promise<number>;
78
81
  /** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
79
82
  export declare function cmdFamilies(a: ParsedArgs, io: Io): Promise<number>;
@@ -1,17 +1,18 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { aliasesByAccount } from '../core/accounts.js';
3
3
  import { listAppProfiles, openAppProfile, parseProfileRef, removeAppProfile } from '../core/app-profiles.js';
4
+ import { browserReport, openInBrowser, setBrowserSignIns } from '../core/browser-profiles.js';
4
5
  import { aliasRefs } from '../core/config.js';
5
6
  import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
6
7
  import { UserError } from '../core/errors.js';
7
8
  import { requestHandoff } from '../core/handoff-chain.js';
8
9
  import { explain, readyDecision, runWithHandoff } from '../core/handoff-run.js';
9
10
  import { configureHandoff, handoffHint, handoffStatus } from '../core/handoff-status.js';
10
- import { runHost } from '../core/launch.js';
11
+ import { installHint, runHost } from '../core/launch.js';
11
12
  import { accountModel, setAccountModel } from '../core/model-choice.js';
12
13
  import { resolvePaths, tildify } from '../core/paths.js';
13
14
  import { USAGE_DAYS } from '../core/quota-levels.js';
14
- import { groupsToTurnOff, groupsToUndo, } from '../core/remote-control.js';
15
+ import { groupsToTurnOff, groupsToTurnOn, groupsToUndo, } from '../core/remote-control.js';
15
16
  import { SHELLS } from '../core/shell-init.js';
16
17
  import { suggestAlias, suggestName } from '../panel/page-logic.js';
17
18
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
@@ -50,7 +51,12 @@ async function runAccountNow(rt, ref, args, io) {
50
51
  }
51
52
  return code;
52
53
  }
53
- 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));
54
60
  function stripFix(f) {
55
61
  const { fix: _fix, ...rest } = f;
56
62
  return rest;
@@ -85,12 +91,13 @@ export async function cmdList(a, io) {
85
91
  io.out('No accounts found yet.');
86
92
  const installed = families.filter((f) => f.installed);
87
93
  if (installed.length === 0)
88
- io.out(`Install a host first: ${families.map((f) => `${f.title} (${f.installUrl})`).join(', ')}`);
94
+ io.out(`Install a host first: ${families.map((f) => `${f.title} (${installHint(f)})`).join(', ')}`);
89
95
  else
90
96
  io.out(`Run ${installed.map((f) => f.bin).join(' or ')} once, then \`sideby new <family> <name>\`.`);
91
97
  }
92
98
  else {
93
99
  // `= main`: the other Accounts of its Same Login, which share its Quota (spec §3.21).
100
+ const brokenOf = (id) => families.find((f) => f.id === id)?.brokenBin;
94
101
  const sameMark = (refs) => refs?.length ? ` ${c.dim(`= ${refs.map((r) => r.slice(r.indexOf(':') + 1)).join(', ')}`)}` : '';
95
102
  const rows = statuses.map((s) => [
96
103
  `${c.bold(s.ref)}${sameMark(s.sameLogin)}`,
@@ -104,10 +111,17 @@ export async function cmdList(a, io) {
104
111
  : c.dim('?'),
105
112
  s.identity?.email ?? c.dim('—'),
106
113
  s.model ?? c.dim('—'),
107
- s.hostInstalled ? tildify(s.dir, home) : `${tildify(s.dir, home)} ${c.yellow('(host not installed)')}`,
114
+ s.hostInstalled
115
+ ? tildify(s.dir, home)
116
+ : `${tildify(s.dir, home)} ${c.yellow(brokenOf(s.family) ? '(host install broken)' : '(host not installed)')}`,
108
117
  ...(hasDesktop ? [s.desktopApp?.running ? c.green('open') : c.dim('—')] : []),
109
118
  ]);
110
119
  io.out(table(rows, ['ACCOUNT', 'TYPE', 'LOGIN', 'EMAIL', 'MODEL', 'DIR', ...(hasDesktop ? ['DESKTOP'] : [])]));
120
+ // One line per Host that cannot run, with what to do: the rows only say which accounts it affects.
121
+ for (const f of families.filter((x) => !x.installed && statuses.some((s) => s.family === x.id)))
122
+ io.out(c.yellow(f.brokenBin
123
+ ? `\n${f.title}'s install is broken (${tildify(f.brokenBin, home)} cannot run, often after an interrupted upgrade); reinstall it: ${installHint(f)}`
124
+ : `\n${f.title} is not installed; install it: ${installHint(f)}`));
111
125
  if (statuses.some((s) => s.sameLogin?.length))
112
126
  io.out(c.dim('\n= <account>: signed in as the same login as that account, so they share one quota.'));
113
127
  io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
@@ -471,13 +485,14 @@ function rcGroupLine(g, home) {
471
485
  return ` ${paint(state)} ${c.bold(g.accounts.join(', '))} ${c.dim(where)}`;
472
486
  }
473
487
  /**
474
- * `sideby remote-control [off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
475
- * claude.ai by themselves, and turning that off for every Account (a preview with exit 10 until --yes).
488
+ * `sideby remote-control [on|off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
489
+ * claude.ai by themselves, and turning that on or off for every Account (a preview with exit 10 until --yes).
476
490
  */
477
491
  export async function cmdRemoteControl(a, io) {
478
492
  const [action, extra] = a.positionals;
479
- if (extra !== undefined || (action !== undefined && action !== 'off' && action !== 'undo'))
480
- throw new UsageError('usage: sideby remote-control [off|undo] [--yes] [--json]');
493
+ if (extra !== undefined ||
494
+ (action !== undefined && action !== 'on' && action !== 'off' && action !== 'undo'))
495
+ throw new UsageError('usage: sideby remote-control [on|off|undo] [--yes] [--json]');
481
496
  const wantJson = a.flags.has('json');
482
497
  const rt = await createRuntime();
483
498
  const home = rt.paths.home;
@@ -501,9 +516,15 @@ export async function cmdRemoteControl(a, io) {
501
516
  io.out(c.cyan('\nRun `sideby remote-control off` so no session connects by itself (it shows the change first).'));
502
517
  return 0;
503
518
  }
504
- const todo = action === 'off' ? groupsToTurnOff(groups) : groupsToUndo(groups);
519
+ const apply = () => action === 'off' ? rt.remoteControlOff() : action === 'on' ? rt.remoteControlOn() : rt.remoteControlUndo();
520
+ const todo = action === 'off'
521
+ ? groupsToTurnOff(groups)
522
+ : action === 'on'
523
+ ? groupsToTurnOn(groups)
524
+ : groupsToUndo(groups);
525
+ const verb = action === 'off' ? 'turn it off' : action === 'on' ? 'turn it on' : 'undo';
505
526
  if (todo.length === 0) {
506
- const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
527
+ const r = await apply();
507
528
  if (wantJson)
508
529
  out({ applied: false, groups: show(groups), message: r.message });
509
530
  else
@@ -515,26 +536,30 @@ export async function cmdRemoteControl(a, io) {
515
536
  out({
516
537
  applied: false,
517
538
  groups: show(todo),
518
- message: `Run again with --yes to ${action === 'off' ? 'turn it off' : 'undo'}.`,
539
+ message: `Run again with --yes to ${verb}.`,
519
540
  });
520
541
  else {
521
542
  io.out(action === 'off'
522
543
  ? 'Turn off Remote Control at startup?'
523
- : 'Put back the Remote Control setting sideby replaced?');
544
+ : action === 'on'
545
+ ? 'Turn on Remote Control at startup?'
546
+ : 'Put back the Remote Control setting sideby replaced?');
524
547
  for (const g of todo) {
525
- const to = action === 'off' ? 'false' : 'what it was before';
548
+ const to = action === 'off' ? 'false' : action === 'on' ? 'true' : 'what it was before';
526
549
  const from = g.state === 'default' ? 'not set' : g.state === 'on' ? 'true' : 'false';
527
550
  io.out(` ${tildify(g.file, home)}: remoteControlAtStartup ${from} -> ${to}`);
528
551
  io.out(c.dim(` used by ${g.accounts.join(', ')}`));
529
552
  }
530
553
  io.out(action === 'off'
531
554
  ? 'Sessions already running stay connected until you restart them. You can still start it in a session with /remote-control, and undo this with `sideby remote-control undo`.'
532
- : 'New sessions go back to the previous setting.');
533
- io.out(`Run again with --yes to ${action === 'off' ? 'turn it off' : 'undo'}.`);
555
+ : action === 'on'
556
+ ? 'Every new session then connects to claude.ai and the Claude app, where every device signed in to that account can read it. Undo this with `sideby remote-control undo`.'
557
+ : 'New sessions go back to the previous setting.');
558
+ io.out(`Run again with --yes to ${verb}.`);
534
559
  }
535
560
  return 10;
536
561
  }
537
- const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
562
+ const r = await apply();
538
563
  const results = r.results.map((x) => ({ ...x, file: tildify(x.file, home) }));
539
564
  if (wantJson)
540
565
  out({ applied: r.ok, results, message: r.message });
@@ -1031,11 +1056,41 @@ export async function cmdUpdate(a, io) {
1031
1056
  const asJson = a.flags.has('json');
1032
1057
  const plan = await u.planUpdate(paths, process.env);
1033
1058
  const shown = u.shownPlan(plan);
1059
+ const sk = plan.skill;
1060
+ const skillLine = (s) => s.canRun
1061
+ ? `the sideby skill (for ${s.installed.map((v) => v ?? 'no version').join(', ')}) is updated too:\n ${s.command}`
1062
+ : `the sideby skill is not updated by sideby: ${s.reason}.\n ${s.command}`;
1034
1063
  if (!plan.available) {
1064
+ // The CLI is current; an outdated skill is the one thing left to update.
1065
+ if (sk?.outdated && sk.canRun) {
1066
+ if (!a.flags.has('yes')) {
1067
+ if (asJson)
1068
+ json(io, { ...shown, updated: false });
1069
+ else {
1070
+ io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
1071
+ io.out(`\nUpdating ${skillLine(sk)}`);
1072
+ io.out(c.cyan('\nRun it with `sideby update --yes`.'));
1073
+ }
1074
+ return 10;
1075
+ }
1076
+ if (!asJson)
1077
+ io.out(c.dim(`$ ${sk.command}`));
1078
+ const r = await u.applySkillUpdate(sk, process.env, asJson ? undefined : (t) => process.stderr.write(t));
1079
+ if (asJson)
1080
+ json(io, { ...shown, updated: false, skillUpdated: r?.ok ?? false, message: r?.message ?? '' });
1081
+ else if (r?.ok)
1082
+ io.out(`${c.green('✓')} ${r.message}; agents load it in their next session.`);
1083
+ else
1084
+ io.err(c.red(`✗ ${r?.message}`));
1085
+ return r?.ok ? 0 : 1;
1086
+ }
1035
1087
  if (asJson)
1036
1088
  json(io, { ...shown, updated: false });
1037
- else
1089
+ else {
1038
1090
  io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
1091
+ if (sk?.outdated)
1092
+ io.out(c.yellow(skillLine(sk)));
1093
+ }
1039
1094
  return 0;
1040
1095
  }
1041
1096
  if (!plan.canRun) {
@@ -1053,6 +1108,8 @@ export async function cmdUpdate(a, io) {
1053
1108
  else {
1054
1109
  io.out(`sideby ${c.bold(plan.latest)} is available (you have ${plan.current}). What changed: ${plan.releaseNotes}`);
1055
1110
  io.out(`\nUpdating runs:\n ${plan.command}`);
1111
+ if (sk?.outdated)
1112
+ io.out(`Then ${skillLine(sk)}`);
1056
1113
  io.out(c.cyan('\nRun it with `sideby update --yes`.'));
1057
1114
  }
1058
1115
  return 10;
@@ -1069,7 +1126,9 @@ export async function cmdUpdate(a, io) {
1069
1126
  updated: r.ok,
1070
1127
  message: r.message,
1071
1128
  restartedPanel: r.restartedPanel,
1129
+ refreshedApp: r.refreshedApp,
1072
1130
  skill: SKILL_INSTALL,
1131
+ ...(r.skillUpdated === undefined ? {} : { skillUpdated: r.skillUpdated }),
1073
1132
  ...(r.ok ? {} : { output: r.output }),
1074
1133
  });
1075
1134
  return r.ok ? 0 : 1;
@@ -1081,8 +1140,17 @@ export async function cmdUpdate(a, io) {
1081
1140
  io.out(`${c.green('✓')} ${r.message}.`);
1082
1141
  if (r.restartedPanel)
1083
1142
  io.out(' The background panel is restarting with the new version; open pages reload by themselves.');
1143
+ if (r.refreshedApp)
1144
+ io.out(' The desktop app now starts the new version and shows its icon.');
1084
1145
  io.out(' A `sideby ui` running in a terminal and sessions already running keep the old version until you restart them.');
1085
- io.out(` If you use the sideby skill, update it too: ${SKILL_INSTALL}`);
1146
+ if (r.skillUpdated)
1147
+ io.out(' The sideby skill was updated too; agents load it in their next session.');
1148
+ else if (r.skillUpdated === false)
1149
+ io.err(c.yellow(` ${r.skillMessage}`));
1150
+ else if (sk?.outdated)
1151
+ io.out(c.yellow(` ${skillLine(sk)}`));
1152
+ else if (!sk)
1153
+ io.out(` If you use the sideby skill, install it with: ${SKILL_INSTALL}`);
1086
1154
  return 0;
1087
1155
  }
1088
1156
  /** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
@@ -1180,6 +1248,83 @@ export async function cmdDesktop(a, io) {
1180
1248
  io.err(c.cyan(n));
1181
1249
  return 0;
1182
1250
  }
1251
+ /** `sideby browser [--enable|--disable] [--yes]` and `sideby browser open <account> [--profile <p>]` (spec §3.24). */
1252
+ export async function cmdBrowser(a, io) {
1253
+ const [sub, ref, ...extra] = a.positionals;
1254
+ const rt = await createRuntime();
1255
+ const home = rt.paths.home;
1256
+ if (sub === 'open') {
1257
+ if (!ref || extra.length || a.rest.length)
1258
+ throw new UsageError('usage: sideby browser open <account> [--profile <folder|name>]');
1259
+ const profile = a.flags.get('profile');
1260
+ const r = await openInBrowser(rt, await rt.resolve(ref), typeof profile === 'string' ? profile : undefined);
1261
+ if (a.flags.has('json'))
1262
+ json(io, r);
1263
+ else
1264
+ io.out(`${c.green('✓')} opened ${r.url} in Chrome as ${c.bold(r.profile.name)} for ${c.bold(r.ref)}`);
1265
+ return 0;
1266
+ }
1267
+ if (sub !== undefined || a.rest.length)
1268
+ throw new UsageError('usage: sideby browser [--enable|--disable] [--yes] | sideby browser open <account>');
1269
+ const enable = a.flags.has('enable');
1270
+ if (enable || a.flags.has('disable')) {
1271
+ if (enable && a.flags.has('disable'))
1272
+ throw new UsageError('pass --enable or --disable, not both');
1273
+ const r = await setBrowserSignIns(rt, enable, { apply: a.flags.has('yes') });
1274
+ if (a.flags.has('json'))
1275
+ json(io, r);
1276
+ else if (!r.changed)
1277
+ io.out(`Finding browser sign-ins is already ${enable ? 'on' : 'off'}; nothing to change.`);
1278
+ else if (!r.applied) {
1279
+ if (enable) {
1280
+ io.out('sideby will read, on this computer only (nothing is sent anywhere):');
1281
+ for (const x of r.reads)
1282
+ io.out(` - ${x}`);
1283
+ io.out('It never opens cookies, saved passwords or the Keychain, and never changes Chrome.');
1284
+ }
1285
+ io.out(`\nChange to ${tildify(r.file, home)}: browser.signIns ${enable ? 'off → on' : 'on → off'}`);
1286
+ io.out(c.cyan(`Apply it with \`sideby browser --${enable ? 'enable' : 'disable'} --yes\`.`));
1287
+ }
1288
+ else
1289
+ io.out(`${c.green('✓')} finding browser sign-ins is ${enable ? 'on' : 'off'}.`);
1290
+ return r.changed && !r.applied ? 10 : 0;
1291
+ }
1292
+ const accounts = await rt.accounts();
1293
+ const r = await browserReport(rt, accounts);
1294
+ if (a.flags.has('json')) {
1295
+ json(io, r);
1296
+ return 0;
1297
+ }
1298
+ for (const p of r.problems)
1299
+ io.err(c.yellow(p));
1300
+ if (!r.enabled) {
1301
+ io.out('Finding which Chrome browser user signed in to each account is off.');
1302
+ io.out(c.cyan('Turn it on with `sideby browser --enable` (it shows what it reads first).'));
1303
+ io.out(c.dim('Without it, open a website in a browser user you name: sideby browser open <account> --profile <name>'));
1304
+ return 0;
1305
+ }
1306
+ if (!r.found) {
1307
+ io.out('Google Chrome has no data on this computer yet; open it once, then try again.');
1308
+ return 0;
1309
+ }
1310
+ const signedIn = new Set(r.profiles.flatMap((p) => p.signIns));
1311
+ const now = new Date();
1312
+ io.out(table(r.profiles.map((p) => [
1313
+ p.name,
1314
+ p.email ?? c.dim('-'),
1315
+ p.dir,
1316
+ p.signIns.join(', ') || c.dim('-'),
1317
+ p.changedAt ? clock(p.changedAt, now) : '-',
1318
+ ]), ['Browser user', 'Google account', 'Folder', 'Signed in to', 'Changed']));
1319
+ const missing = accounts.filter((x) => r.families.matched.includes(x.family) && x.kind !== 'api' && !signedIn.has(x.ref));
1320
+ if (missing.length)
1321
+ io.out(c.dim(`\nNo browser user found for: ${missing.map((x) => x.ref).join(', ')}`));
1322
+ if (r.families.openOnly.length)
1323
+ io.out(c.dim(`${r.families.openOnly.join(', ')}: the website keeps no id to match; open it with --profile.`));
1324
+ io.out(c.dim('\nRead from Chrome on this computer only; a browser user that signed out may still show.'));
1325
+ io.out(c.dim('Open one: sideby browser open <account> [--profile <name>]'));
1326
+ return 0;
1327
+ }
1183
1328
  export async function cmdAlias(a, io) {
1184
1329
  const [sub, alias, target, ...more] = a.positionals;
1185
1330
  const usage = 'usage: sideby alias add <short-command> <account> [-- host args] | sideby alias rm <short-command>';
@@ -1221,7 +1366,7 @@ export async function cmdFamilies(a, io) {
1221
1366
  families.forEach((f, i) => {
1222
1367
  if (i)
1223
1368
  io.out('');
1224
- io.out(`${c.bold(f.title)} (${f.id}) ${f.installed ? c.green('installed') : c.yellow(`not installed: ${f.installUrl}`)}`);
1369
+ io.out(`${c.bold(f.title)} (${f.id}) ${f.installed ? c.green('installed') : c.yellow(f.brokenBin ? `install broken (${tildify(f.brokenBin, rt.paths.home)} cannot run); reinstall: ${installHint(f)}` : `not installed: ${installHint(f)}`)}`);
1225
1370
  io.out(` Accounts ~/${f.layout.main} (main), ~/${f.layout.account}; selected with ${f.selectVar}`);
1226
1371
  io.out(` Sign in sideby login ${f.id}:<name> ${c.dim(f.login.args ? `(runs ${[f.bin, ...f.login.args].join(' ')})` : `(${f.login.hint})`)}`);
1227
1372
  const yes = (b) => (b ? c.green('yes') : c.dim('no public source'));
@@ -50,7 +50,9 @@ const FamilyInfo = Type.Object({
50
50
  title: Str,
51
51
  bin: Str,
52
52
  installUrl: Str,
53
+ installCommand: Type.Optional(Str),
53
54
  installed: Type.Boolean(),
55
+ brokenBin: Type.Optional(Str),
54
56
  plugin: Str,
55
57
  tuning: Type.Boolean(),
56
58
  });
@@ -210,6 +212,30 @@ const AppProfile = Type.Object({
210
212
  }, {
211
213
  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>`.',
212
214
  });
215
+ /**
216
+ * On any `--json` output when there is something to mention once the task is done (spec §3.23): a newer sideby in the
217
+ * cached Update Check, or an installed sideby skill for another version. Off with SIDEBY_NO_UPDATE_NOTIFIER.
218
+ */
219
+ const Notice = Type.Object({
220
+ update: Type.Optional(Type.Object({
221
+ current: Str,
222
+ latest: Str,
223
+ command: Type.String({
224
+ description: 'Run it after the user agrees: it shows the plan and exits 10; then --yes.',
225
+ }),
226
+ message: Str,
227
+ })),
228
+ skill: Type.Optional(Type.Object({
229
+ installed: Type.Union([Str, Type.Null()], {
230
+ description: "The skill's version; null when it has none.",
231
+ }),
232
+ current: Str,
233
+ command: Type.String({
234
+ description: 'Updates the installed sideby skill (run it after the user agrees).',
235
+ }),
236
+ message: Str,
237
+ })),
238
+ }, { description: 'Not the answer to the task: mention it briefly afterwards and offer to update.' });
213
239
  export const OUTPUT_SCHEMAS = {
214
240
  list: Type.Object({
215
241
  schemaVersion: Version,
@@ -256,7 +282,12 @@ export const OUTPUT_SCHEMAS = {
256
282
  }, { title: 'sideby tune apply|undo --json' }),
257
283
  'remote-control': Type.Object({
258
284
  schemaVersion: Version,
259
- action: Type.Union([Type.Literal('status'), Type.Literal('off'), Type.Literal('undo')]),
285
+ action: Type.Union([
286
+ Type.Literal('status'),
287
+ Type.Literal('on'),
288
+ Type.Literal('off'),
289
+ Type.Literal('undo'),
290
+ ]),
260
291
  applied: Type.Optional(Type.Boolean({ description: 'True once the change was written (or undone).' })),
261
292
  groups: Type.Optional(Type.Array(Type.Object({
262
293
  file: Str,
@@ -491,6 +522,47 @@ export const OUTPUT_SCHEMAS = {
491
522
  description: 'False when only the plan was shown; run again with --yes (exit 10).',
492
523
  }),
493
524
  }, { title: 'sideby desktop grok-bot:<name> --remove --json' }),
525
+ browser: Type.Object({
526
+ schemaVersion: Version,
527
+ enabled: Type.Boolean({
528
+ description: 'config `browser.signIns`. When false nothing of the browser was read and `profiles` is empty.',
529
+ }),
530
+ browser: Type.String({ description: 'The browser read, `Google Chrome`.' }),
531
+ found: Type.Boolean({ description: "Chrome's user data folder exists on this Mac." }),
532
+ profiles: Type.Array(Type.Object({
533
+ dir: Type.String({ description: "The browser user's folder, such as `Default` or `Profile 7`." }),
534
+ name: Type.String({ description: 'The name Chrome shows; it may be an email.' }),
535
+ email: Type.Optional(Type.String({ description: 'The Google account this browser user is signed in to Chrome with.' })),
536
+ signIns: Type.Array(Str, {
537
+ description: "Refs of the accounts this browser user signed in to on the vendor's website, as far as its local data shows; it may be left over from before a sign-out.",
538
+ }),
539
+ changedAt: Type.Optional(Type.String({ description: 'When its Local Storage last changed (ISO 8601).' })),
540
+ }), { description: 'Most recently changed first.' }),
541
+ families: Type.Object({
542
+ matched: Type.Array(Str, { description: 'Families whose accounts can be matched (claude, codex).' }),
543
+ openOnly: Type.Array(Str, {
544
+ description: 'Families that can only be opened in a browser user given with --profile (grok).',
545
+ }),
546
+ }),
547
+ problems: Type.Array(Str),
548
+ }, { title: 'sideby browser --json' }),
549
+ 'browser-switch': Type.Object({
550
+ schemaVersion: Version,
551
+ enabled: Type.Boolean({ description: 'The value asked for.' }),
552
+ changed: Type.Boolean(),
553
+ applied: Type.Boolean({
554
+ description: 'False when only the change was shown; run again with --yes (exit 10).',
555
+ }),
556
+ file: Type.String({ description: 'The config file.' }),
557
+ reads: Type.Array(Str, { description: 'What sideby reads while it is on, all on this computer.' }),
558
+ }, { title: 'sideby browser --enable|--disable --json' }),
559
+ 'browser-open': Type.Object({
560
+ schemaVersion: Version,
561
+ ref: Str,
562
+ url: Str,
563
+ profile: Type.Object({ dir: Str, name: Str }),
564
+ command: Type.Array(Str, { description: 'The `open` command sideby ran.' }),
565
+ }, { title: 'sideby browser open --json' }),
494
566
  update: Type.Object({
495
567
  schemaVersion: Version,
496
568
  current: Type.String({ description: 'The running sideby version.' }),
@@ -513,7 +585,28 @@ export const OUTPUT_SCHEMAS = {
513
585
  restartedPanel: Type.Optional(Type.Boolean({
514
586
  description: 'A background Panel was running and starts again with the new version.',
515
587
  })),
516
- skill: Type.Optional(Type.String({ description: 'The command that updates the sideby skill; sideby never runs it.' })),
588
+ refreshedApp: Type.Optional(Type.Boolean({
589
+ description: 'The desktop app (`sideby app install`) was installed and was written again by the new version.',
590
+ })),
591
+ skill: Type.Optional(Type.String({ description: 'The command that installs the sideby skill by hand.' })),
592
+ skillUpdate: Type.Optional(Type.Object({
593
+ installed: Type.Array(Type.Union([Str, Type.Null()]), {
594
+ description: 'Versions of the installed sideby skills; null for one without a version.',
595
+ }),
596
+ outdated: Type.Boolean({
597
+ description: 'Some installed skill is for another version than sideby will run.',
598
+ }),
599
+ command: Str,
600
+ canRun: Type.Boolean({
601
+ description: 'False when sideby leaves the skill alone (see reason): give the user command.',
602
+ }),
603
+ reason: Type.Optional(Str),
604
+ }, {
605
+ description: 'The installed sideby skill, which the Update brings along; absent when none is installed.',
606
+ })),
607
+ skillUpdated: Type.Optional(Type.Boolean({
608
+ description: 'The skill was updated too (true) or that failed (false); absent when not tried.',
609
+ })),
517
610
  output: Type.Optional(Type.String({ description: 'What the failed install command printed.' })),
518
611
  }, { title: 'sideby update --json' }),
519
612
  error: Type.Object({
@@ -525,3 +618,8 @@ export const OUTPUT_SCHEMAS = {
525
618
  }, { title: 'sideby error --json' }),
526
619
  config: ConfigSchema,
527
620
  };
621
+ for (const [name, schema] of Object.entries(OUTPUT_SCHEMAS)) {
622
+ const props = schema.properties;
623
+ if (name !== 'config' && props)
624
+ props._notice = Type.Optional(Notice);
625
+ }
@@ -34,7 +34,7 @@ Usage:
34
34
  from your last 7 days of sessions (read-only)
35
35
  sideby model <account> [<model>|default] [--effort <level>|default] [--yes]
36
36
  show or set the model and effort sideby starts this account with
37
- sideby remote-control [off|undo] [--yes]
37
+ sideby remote-control [on|off|undo] [--yes]
38
38
  whether Claude sessions connect to claude.ai by themselves;
39
39
  off stops that for every account (shows the change first)
40
40
  sideby handoff status what Auto Handoff still needs on this machine
@@ -52,6 +52,10 @@ Usage:
52
52
  sideby desktop <account> open the desktop app (Claude Desktop, the Codex app) as this
53
53
  account, next to the others (macOS)
54
54
  sideby desktop grok-bot:<name> open a Grok Bot window, a new one for a new name (macOS)
55
+ sideby browser [--enable [--yes]] which Chrome browser user signed in to each account's website
56
+ (read on this computer only; off until you enable it)
57
+ sideby browser open <account> [--profile <name>]
58
+ open claude.ai, chatgpt.com or grok.com in that browser user
55
59
  sideby app install [--url <url>] add a sideby app that opens the panel (macOS, Linux)
56
60
  sideby app uninstall remove that app
57
61
  sideby shell-init zsh|bash print shell functions, e.g. eval "$(sideby shell-init zsh)"
@@ -62,8 +66,8 @@ Usage:
62
66
 
63
67
  Options:
64
68
  --json machine-readable output (list, new, next, alias, doctor, quota, tune, model, remote-control, handoff,
65
- families, plugins, app, desktop, update)
66
- -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop
69
+ families, plugins, app, desktop, browser, update)
70
+ -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop, browser
67
71
  -v, --version show the version
68
72
 
69
73
  An <account> is <family>:<name> (claude:work) or just <name> when it is unique.
@@ -164,6 +168,30 @@ Examples:
164
168
  sideby desktop grok-bot:002 --remove --yes
165
169
  Exit: 0 opened, brought to the front or removed; 1 not macOS, no desktop app for that host, API account, app not
166
170
  installed, a bad window name, or the app did not open; 10 --remove without --yes; 2 malformed command line.`,
171
+ browser: `sideby browser [--enable|--disable] [--yes] [--json]
172
+ sideby browser open <account> [--profile <folder|name>] [--json]
173
+
174
+ Find which Google Chrome browser user signed in to each account's website (claude.ai for claude accounts,
175
+ chatgpt.com for codex accounts), and open the website in that browser user. macOS only for now.
176
+
177
+ Everything is read on this computer and sent nowhere. sideby reads each browser user's name, the key names (never
178
+ the values) claude.ai and chatgpt.com keep in that browser user's Local Storage, and the account id in each Claude and
179
+ Codex login file, only to compare. It never opens cookies, saved passwords or the Keychain, and never changes Chrome.
180
+ It is off until you turn it on: --enable shows what it reads and changes config browser.signIns after --yes.
181
+
182
+ "Signed in to" means that browser user's data shows a sign-in as that account; it may be left over from before a
183
+ sign-out. grok.com keeps no id to match, so grok accounts can only be opened with --profile.
184
+
185
+ open uses the one browser user that signed in to the account, or the one --profile names (folder such as
186
+ "Profile 7", or the name Chrome shows). It runs: open -na "Google Chrome" --args --profile-directory=<folder> <url>.
187
+
188
+ Examples:
189
+ sideby browser --enable --yes
190
+ sideby browser
191
+ sideby browser open cc008
192
+ sideby browser open grok:main --profile Work
193
+ Exit: 0 shown, changed or opened; 1 not macOS, Chrome not installed, no or several browser users to choose from, or
194
+ Chrome did not open; 10 --enable/--disable without --yes; 2 malformed command line.`,
167
195
  families: `sideby families [family] [--json]
168
196
 
169
197
  For each host (built in or from a plugin): where its accounts live, the variable that selects one, how to sign
@@ -204,6 +232,7 @@ const COMMANDS = {
204
232
  ui: { flags: ['no-open', 'background', 'stop', 'serve-detached'], valued: ['port'] },
205
233
  app: { flags: ['json'], valued: ['url'] },
206
234
  desktop: { flags: ['json', 'remove', 'yes'] },
235
+ browser: { flags: ['json', 'enable', 'disable', 'yes'], valued: ['profile'] },
207
236
  'shell-init': { flags: ['write'] },
208
237
  plugins: { flags: ['json'] },
209
238
  families: { flags: ['json'] },
@@ -220,6 +249,38 @@ function noticeAllowed(cmd, parsed) {
220
249
  return false;
221
250
  return true;
222
251
  }
252
+ /**
253
+ * Before a `--json` command (spec §3.23): the `_notice` its output carries, from the cached Update Check and the
254
+ * installed sideby skills, so an agent that reads JSON learns of a newer version too. Starts the next check detached
255
+ * when the last one is a day old, as afterCommand does on a terminal. Never fails the command.
256
+ */
257
+ async function jsonNoticeFor() {
258
+ try {
259
+ const u = await import('../core/update.js');
260
+ const { resolvePaths } = await import('../core/paths.js');
261
+ const { loadConfig } = await import('../core/config.js');
262
+ const { packageVersion } = await import('../core/version.js');
263
+ const { installedSkillVersions, SKILL_INSTALL } = await import('../core/skill-check.js');
264
+ const paths = resolvePaths(process.env);
265
+ const config = await loadConfig(paths.configFile).catch(() => ({}));
266
+ const cache = await u.readUpdateCache(paths);
267
+ const skills = await installedSkillVersions(paths.home);
268
+ const n = u.jsonNotice(cache, packageVersion(), config, process.env, skills, SKILL_INSTALL);
269
+ if (!u.checksOff(process.env, config) && u.checkDue(cache, new Date())) {
270
+ const { spawn } = await import('node:child_process');
271
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'update-check'], {
272
+ detached: true,
273
+ stdio: 'ignore',
274
+ });
275
+ child.on('error', () => { });
276
+ child.unref();
277
+ }
278
+ return n;
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ }
223
284
  /**
224
285
  * After a command (spec §3.23): print the Update Notice and the result of an Auto Update, each at most once, and
225
286
  * start the next Update Check detached when the last one is a day old. Only on a terminal; never fails the command.
@@ -290,6 +351,7 @@ export async function main(argv) {
290
351
  // Only sideby's own options count: arguments after `--` belong to the host.
291
352
  const own = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
292
353
  const wantsJson = Boolean(spec?.flags.includes('json')) && own.includes('--json');
354
+ let notice = null;
293
355
  try {
294
356
  if (!spec)
295
357
  throw new UsageError(`unknown command "${cmd}"; run \`sideby --help\``);
@@ -325,6 +387,10 @@ export async function main(argv) {
325
387
  return 0;
326
388
  }
327
389
  const cmds = await import('./commands.js');
390
+ if (wantsJson && noticeAllowed(cmd, parsed)) {
391
+ notice = await jsonNoticeFor();
392
+ cmds.setJsonNotice(notice);
393
+ }
328
394
  const code = await dispatch(cmd, parsed, io, cmds);
329
395
  if (!wantsJson && noticeAllowed(cmd, parsed))
330
396
  await afterCommand();
@@ -336,7 +402,7 @@ export async function main(argv) {
336
402
  if (wantsJson) {
337
403
  const { UserError } = await import('../core/errors.js');
338
404
  const code = usage ? 'usage' : err instanceof UserError && err.code ? err.code : 'error';
339
- console.log(JSON.stringify({ schemaVersion: 1, error: message, code }, null, 2));
405
+ console.log(JSON.stringify({ schemaVersion: 1, error: message, code, ...(notice ? { _notice: notice } : {}) }, null, 2));
340
406
  }
341
407
  else
342
408
  console.error(`sideby: ${message}`);
@@ -379,6 +445,8 @@ async function dispatch(cmd, parsed, io, cmds) {
379
445
  return await cmds.cmdApp(parsed, io);
380
446
  case 'desktop':
381
447
  return await cmds.cmdDesktop(parsed, io);
448
+ case 'browser':
449
+ return await cmds.cmdBrowser(parsed, io);
382
450
  case 'shell-init':
383
451
  return await cmds.cmdShellInit(parsed, io);
384
452
  case 'plugins':