sideby 0.12.0 → 0.13.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 (60) hide show
  1. package/README.md +1 -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 +117 -17
  5. package/dist/src/cli/json-schemas.js +52 -1
  6. package/dist/src/cli/main.js +34 -3
  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/update.d.ts +10 -3
  23. package/dist/src/core/update.js +38 -3
  24. package/dist/src/core/version.d.ts +11 -0
  25. package/dist/src/core/version.js +26 -0
  26. package/dist/src/desktop/app.d.ts +2 -0
  27. package/dist/src/desktop/app.js +39 -3
  28. package/dist/src/families/browser.d.ts +20 -0
  29. package/dist/src/families/browser.js +23 -0
  30. package/dist/src/families/claude/browser.d.ts +3 -0
  31. package/dist/src/families/claude/browser.js +14 -0
  32. package/dist/src/families/claude/desktop.js +1 -0
  33. package/dist/src/families/codex/browser.d.ts +7 -0
  34. package/dist/src/families/codex/browser.js +26 -0
  35. package/dist/src/families/desktop.d.ts +5 -1
  36. package/dist/src/families/pi/index.js +2 -0
  37. package/dist/src/panel/handler.d.ts +6 -0
  38. package/dist/src/panel/handler.js +49 -6
  39. package/dist/src/panel/i18n.d.ts +75 -4
  40. package/dist/src/panel/i18n.js +162 -20
  41. package/dist/src/panel/page-logic.d.ts +23 -0
  42. package/dist/src/panel/page-logic.js +44 -0
  43. package/dist/src/panel/page-script.js +609 -180
  44. package/dist/src/panel/page-styles.js +72 -17
  45. package/dist/src/panel/page.d.ts +5 -0
  46. package/dist/src/panel/page.js +10 -9
  47. package/dist/src/runtime.d.ts +6 -1
  48. package/dist/src/runtime.js +22 -11
  49. package/dist/src/types.d.ts +2 -0
  50. package/package.json +5 -3
  51. package/schemas/browser-open.json +47 -0
  52. package/schemas/browser-switch.json +42 -0
  53. package/schemas/browser.json +99 -0
  54. package/schemas/config.json +11 -0
  55. package/schemas/families.json +6 -0
  56. package/schemas/list.json +6 -0
  57. package/schemas/remote-control.json +4 -0
  58. package/schemas/update.json +4 -0
  59. package/skills/sideby/SKILL.md +5 -3
  60. package/skills/sideby/references/setup.md +1 -1
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":"20261016T071613-e5f0f1"}
1
+ {"id":"20261016T155413-627722"}
@@ -44,8 +44,8 @@ export declare function cmdResume(a: ParsedArgs, io: Io): Promise<number>;
44
44
  export declare function cmdNext(a: ParsedArgs, io: Io): Promise<number>;
45
45
  export declare function cmdDoctor(a: ParsedArgs, io: Io): Promise<number>;
46
46
  /**
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).
47
+ * `sideby remote-control [on|off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
48
+ * claude.ai by themselves, and turning that on or off for every Account (a preview with exit 10 until --yes).
49
49
  */
50
50
  export declare function cmdRemoteControl(a: ParsedArgs, io: Io): Promise<number>;
51
51
  /**
@@ -74,6 +74,8 @@ export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
74
74
  * a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
75
75
  */
76
76
  export declare function cmdDesktop(a: ParsedArgs, io: Io): Promise<number>;
77
+ /** `sideby browser [--enable|--disable] [--yes]` and `sideby browser open <account> [--profile <p>]` (spec §3.24). */
78
+ export declare function cmdBrowser(a: ParsedArgs, io: Io): Promise<number>;
77
79
  export declare function cmdAlias(a: ParsedArgs, io: Io): Promise<number>;
78
80
  /** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
79
81
  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';
@@ -85,12 +86,13 @@ export async function cmdList(a, io) {
85
86
  io.out('No accounts found yet.');
86
87
  const installed = families.filter((f) => f.installed);
87
88
  if (installed.length === 0)
88
- io.out(`Install a host first: ${families.map((f) => `${f.title} (${f.installUrl})`).join(', ')}`);
89
+ io.out(`Install a host first: ${families.map((f) => `${f.title} (${installHint(f)})`).join(', ')}`);
89
90
  else
90
91
  io.out(`Run ${installed.map((f) => f.bin).join(' or ')} once, then \`sideby new <family> <name>\`.`);
91
92
  }
92
93
  else {
93
94
  // `= main`: the other Accounts of its Same Login, which share its Quota (spec §3.21).
95
+ const brokenOf = (id) => families.find((f) => f.id === id)?.brokenBin;
94
96
  const sameMark = (refs) => refs?.length ? ` ${c.dim(`= ${refs.map((r) => r.slice(r.indexOf(':') + 1)).join(', ')}`)}` : '';
95
97
  const rows = statuses.map((s) => [
96
98
  `${c.bold(s.ref)}${sameMark(s.sameLogin)}`,
@@ -104,10 +106,17 @@ export async function cmdList(a, io) {
104
106
  : c.dim('?'),
105
107
  s.identity?.email ?? c.dim('—'),
106
108
  s.model ?? c.dim('—'),
107
- s.hostInstalled ? tildify(s.dir, home) : `${tildify(s.dir, home)} ${c.yellow('(host not installed)')}`,
109
+ s.hostInstalled
110
+ ? tildify(s.dir, home)
111
+ : `${tildify(s.dir, home)} ${c.yellow(brokenOf(s.family) ? '(host install broken)' : '(host not installed)')}`,
108
112
  ...(hasDesktop ? [s.desktopApp?.running ? c.green('open') : c.dim('—')] : []),
109
113
  ]);
110
114
  io.out(table(rows, ['ACCOUNT', 'TYPE', 'LOGIN', 'EMAIL', 'MODEL', 'DIR', ...(hasDesktop ? ['DESKTOP'] : [])]));
115
+ // One line per Host that cannot run, with what to do: the rows only say which accounts it affects.
116
+ for (const f of families.filter((x) => !x.installed && statuses.some((s) => s.family === x.id)))
117
+ io.out(c.yellow(f.brokenBin
118
+ ? `\n${f.title}'s install is broken (${tildify(f.brokenBin, home)} cannot run, often after an interrupted upgrade); reinstall it: ${installHint(f)}`
119
+ : `\n${f.title} is not installed; install it: ${installHint(f)}`));
111
120
  if (statuses.some((s) => s.sameLogin?.length))
112
121
  io.out(c.dim('\n= <account>: signed in as the same login as that account, so they share one quota.'));
113
122
  io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
@@ -471,13 +480,14 @@ function rcGroupLine(g, home) {
471
480
  return ` ${paint(state)} ${c.bold(g.accounts.join(', '))} ${c.dim(where)}`;
472
481
  }
473
482
  /**
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).
483
+ * `sideby remote-control [on|off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
484
+ * claude.ai by themselves, and turning that on or off for every Account (a preview with exit 10 until --yes).
476
485
  */
477
486
  export async function cmdRemoteControl(a, io) {
478
487
  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]');
488
+ if (extra !== undefined ||
489
+ (action !== undefined && action !== 'on' && action !== 'off' && action !== 'undo'))
490
+ throw new UsageError('usage: sideby remote-control [on|off|undo] [--yes] [--json]');
481
491
  const wantJson = a.flags.has('json');
482
492
  const rt = await createRuntime();
483
493
  const home = rt.paths.home;
@@ -501,9 +511,15 @@ export async function cmdRemoteControl(a, io) {
501
511
  io.out(c.cyan('\nRun `sideby remote-control off` so no session connects by itself (it shows the change first).'));
502
512
  return 0;
503
513
  }
504
- const todo = action === 'off' ? groupsToTurnOff(groups) : groupsToUndo(groups);
514
+ const apply = () => action === 'off' ? rt.remoteControlOff() : action === 'on' ? rt.remoteControlOn() : rt.remoteControlUndo();
515
+ const todo = action === 'off'
516
+ ? groupsToTurnOff(groups)
517
+ : action === 'on'
518
+ ? groupsToTurnOn(groups)
519
+ : groupsToUndo(groups);
520
+ const verb = action === 'off' ? 'turn it off' : action === 'on' ? 'turn it on' : 'undo';
505
521
  if (todo.length === 0) {
506
- const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
522
+ const r = await apply();
507
523
  if (wantJson)
508
524
  out({ applied: false, groups: show(groups), message: r.message });
509
525
  else
@@ -515,26 +531,30 @@ export async function cmdRemoteControl(a, io) {
515
531
  out({
516
532
  applied: false,
517
533
  groups: show(todo),
518
- message: `Run again with --yes to ${action === 'off' ? 'turn it off' : 'undo'}.`,
534
+ message: `Run again with --yes to ${verb}.`,
519
535
  });
520
536
  else {
521
537
  io.out(action === 'off'
522
538
  ? 'Turn off Remote Control at startup?'
523
- : 'Put back the Remote Control setting sideby replaced?');
539
+ : action === 'on'
540
+ ? 'Turn on Remote Control at startup?'
541
+ : 'Put back the Remote Control setting sideby replaced?');
524
542
  for (const g of todo) {
525
- const to = action === 'off' ? 'false' : 'what it was before';
543
+ const to = action === 'off' ? 'false' : action === 'on' ? 'true' : 'what it was before';
526
544
  const from = g.state === 'default' ? 'not set' : g.state === 'on' ? 'true' : 'false';
527
545
  io.out(` ${tildify(g.file, home)}: remoteControlAtStartup ${from} -> ${to}`);
528
546
  io.out(c.dim(` used by ${g.accounts.join(', ')}`));
529
547
  }
530
548
  io.out(action === 'off'
531
549
  ? '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'}.`);
550
+ : action === 'on'
551
+ ? '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`.'
552
+ : 'New sessions go back to the previous setting.');
553
+ io.out(`Run again with --yes to ${verb}.`);
534
554
  }
535
555
  return 10;
536
556
  }
537
- const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
557
+ const r = await apply();
538
558
  const results = r.results.map((x) => ({ ...x, file: tildify(x.file, home) }));
539
559
  if (wantJson)
540
560
  out({ applied: r.ok, results, message: r.message });
@@ -1069,6 +1089,7 @@ export async function cmdUpdate(a, io) {
1069
1089
  updated: r.ok,
1070
1090
  message: r.message,
1071
1091
  restartedPanel: r.restartedPanel,
1092
+ refreshedApp: r.refreshedApp,
1072
1093
  skill: SKILL_INSTALL,
1073
1094
  ...(r.ok ? {} : { output: r.output }),
1074
1095
  });
@@ -1081,6 +1102,8 @@ export async function cmdUpdate(a, io) {
1081
1102
  io.out(`${c.green('✓')} ${r.message}.`);
1082
1103
  if (r.restartedPanel)
1083
1104
  io.out(' The background panel is restarting with the new version; open pages reload by themselves.');
1105
+ if (r.refreshedApp)
1106
+ io.out(' The desktop app now starts the new version and shows its icon.');
1084
1107
  io.out(' A `sideby ui` running in a terminal and sessions already running keep the old version until you restart them.');
1085
1108
  io.out(` If you use the sideby skill, update it too: ${SKILL_INSTALL}`);
1086
1109
  return 0;
@@ -1180,6 +1203,83 @@ export async function cmdDesktop(a, io) {
1180
1203
  io.err(c.cyan(n));
1181
1204
  return 0;
1182
1205
  }
1206
+ /** `sideby browser [--enable|--disable] [--yes]` and `sideby browser open <account> [--profile <p>]` (spec §3.24). */
1207
+ export async function cmdBrowser(a, io) {
1208
+ const [sub, ref, ...extra] = a.positionals;
1209
+ const rt = await createRuntime();
1210
+ const home = rt.paths.home;
1211
+ if (sub === 'open') {
1212
+ if (!ref || extra.length || a.rest.length)
1213
+ throw new UsageError('usage: sideby browser open <account> [--profile <folder|name>]');
1214
+ const profile = a.flags.get('profile');
1215
+ const r = await openInBrowser(rt, await rt.resolve(ref), typeof profile === 'string' ? profile : undefined);
1216
+ if (a.flags.has('json'))
1217
+ json(io, r);
1218
+ else
1219
+ io.out(`${c.green('✓')} opened ${r.url} in Chrome as ${c.bold(r.profile.name)} for ${c.bold(r.ref)}`);
1220
+ return 0;
1221
+ }
1222
+ if (sub !== undefined || a.rest.length)
1223
+ throw new UsageError('usage: sideby browser [--enable|--disable] [--yes] | sideby browser open <account>');
1224
+ const enable = a.flags.has('enable');
1225
+ if (enable || a.flags.has('disable')) {
1226
+ if (enable && a.flags.has('disable'))
1227
+ throw new UsageError('pass --enable or --disable, not both');
1228
+ const r = await setBrowserSignIns(rt, enable, { apply: a.flags.has('yes') });
1229
+ if (a.flags.has('json'))
1230
+ json(io, r);
1231
+ else if (!r.changed)
1232
+ io.out(`Finding browser sign-ins is already ${enable ? 'on' : 'off'}; nothing to change.`);
1233
+ else if (!r.applied) {
1234
+ if (enable) {
1235
+ io.out('sideby will read, on this computer only (nothing is sent anywhere):');
1236
+ for (const x of r.reads)
1237
+ io.out(` - ${x}`);
1238
+ io.out('It never opens cookies, saved passwords or the Keychain, and never changes Chrome.');
1239
+ }
1240
+ io.out(`\nChange to ${tildify(r.file, home)}: browser.signIns ${enable ? 'off → on' : 'on → off'}`);
1241
+ io.out(c.cyan(`Apply it with \`sideby browser --${enable ? 'enable' : 'disable'} --yes\`.`));
1242
+ }
1243
+ else
1244
+ io.out(`${c.green('✓')} finding browser sign-ins is ${enable ? 'on' : 'off'}.`);
1245
+ return r.changed && !r.applied ? 10 : 0;
1246
+ }
1247
+ const accounts = await rt.accounts();
1248
+ const r = await browserReport(rt, accounts);
1249
+ if (a.flags.has('json')) {
1250
+ json(io, r);
1251
+ return 0;
1252
+ }
1253
+ for (const p of r.problems)
1254
+ io.err(c.yellow(p));
1255
+ if (!r.enabled) {
1256
+ io.out('Finding which Chrome browser user signed in to each account is off.');
1257
+ io.out(c.cyan('Turn it on with `sideby browser --enable` (it shows what it reads first).'));
1258
+ io.out(c.dim('Without it, open a website in a browser user you name: sideby browser open <account> --profile <name>'));
1259
+ return 0;
1260
+ }
1261
+ if (!r.found) {
1262
+ io.out('Google Chrome has no data on this computer yet; open it once, then try again.');
1263
+ return 0;
1264
+ }
1265
+ const signedIn = new Set(r.profiles.flatMap((p) => p.signIns));
1266
+ const now = new Date();
1267
+ io.out(table(r.profiles.map((p) => [
1268
+ p.name,
1269
+ p.email ?? c.dim('-'),
1270
+ p.dir,
1271
+ p.signIns.join(', ') || c.dim('-'),
1272
+ p.changedAt ? clock(p.changedAt, now) : '-',
1273
+ ]), ['Browser user', 'Google account', 'Folder', 'Signed in to', 'Changed']));
1274
+ const missing = accounts.filter((x) => r.families.matched.includes(x.family) && x.kind !== 'api' && !signedIn.has(x.ref));
1275
+ if (missing.length)
1276
+ io.out(c.dim(`\nNo browser user found for: ${missing.map((x) => x.ref).join(', ')}`));
1277
+ if (r.families.openOnly.length)
1278
+ io.out(c.dim(`${r.families.openOnly.join(', ')}: the website keeps no id to match; open it with --profile.`));
1279
+ io.out(c.dim('\nRead from Chrome on this computer only; a browser user that signed out may still show.'));
1280
+ io.out(c.dim('Open one: sideby browser open <account> [--profile <name>]'));
1281
+ return 0;
1282
+ }
1183
1283
  export async function cmdAlias(a, io) {
1184
1284
  const [sub, alias, target, ...more] = a.positionals;
1185
1285
  const usage = 'usage: sideby alias add <short-command> <account> [-- host args] | sideby alias rm <short-command>';
@@ -1221,7 +1321,7 @@ export async function cmdFamilies(a, io) {
1221
1321
  families.forEach((f, i) => {
1222
1322
  if (i)
1223
1323
  io.out('');
1224
- io.out(`${c.bold(f.title)} (${f.id}) ${f.installed ? c.green('installed') : c.yellow(`not installed: ${f.installUrl}`)}`);
1324
+ 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
1325
  io.out(` Accounts ~/${f.layout.main} (main), ~/${f.layout.account}; selected with ${f.selectVar}`);
1226
1326
  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
1327
  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
  });
@@ -256,7 +258,12 @@ export const OUTPUT_SCHEMAS = {
256
258
  }, { title: 'sideby tune apply|undo --json' }),
257
259
  'remote-control': Type.Object({
258
260
  schemaVersion: Version,
259
- action: Type.Union([Type.Literal('status'), Type.Literal('off'), Type.Literal('undo')]),
261
+ action: Type.Union([
262
+ Type.Literal('status'),
263
+ Type.Literal('on'),
264
+ Type.Literal('off'),
265
+ Type.Literal('undo'),
266
+ ]),
260
267
  applied: Type.Optional(Type.Boolean({ description: 'True once the change was written (or undone).' })),
261
268
  groups: Type.Optional(Type.Array(Type.Object({
262
269
  file: Str,
@@ -491,6 +498,47 @@ export const OUTPUT_SCHEMAS = {
491
498
  description: 'False when only the plan was shown; run again with --yes (exit 10).',
492
499
  }),
493
500
  }, { title: 'sideby desktop grok-bot:<name> --remove --json' }),
501
+ browser: Type.Object({
502
+ schemaVersion: Version,
503
+ enabled: Type.Boolean({
504
+ description: 'config `browser.signIns`. When false nothing of the browser was read and `profiles` is empty.',
505
+ }),
506
+ browser: Type.String({ description: 'The browser read, `Google Chrome`.' }),
507
+ found: Type.Boolean({ description: "Chrome's user data folder exists on this Mac." }),
508
+ profiles: Type.Array(Type.Object({
509
+ dir: Type.String({ description: "The browser user's folder, such as `Default` or `Profile 7`." }),
510
+ name: Type.String({ description: 'The name Chrome shows; it may be an email.' }),
511
+ email: Type.Optional(Type.String({ description: 'The Google account this browser user is signed in to Chrome with.' })),
512
+ signIns: Type.Array(Str, {
513
+ 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.",
514
+ }),
515
+ changedAt: Type.Optional(Type.String({ description: 'When its Local Storage last changed (ISO 8601).' })),
516
+ }), { description: 'Most recently changed first.' }),
517
+ families: Type.Object({
518
+ matched: Type.Array(Str, { description: 'Families whose accounts can be matched (claude, codex).' }),
519
+ openOnly: Type.Array(Str, {
520
+ description: 'Families that can only be opened in a browser user given with --profile (grok).',
521
+ }),
522
+ }),
523
+ problems: Type.Array(Str),
524
+ }, { title: 'sideby browser --json' }),
525
+ 'browser-switch': Type.Object({
526
+ schemaVersion: Version,
527
+ enabled: Type.Boolean({ description: 'The value asked for.' }),
528
+ changed: Type.Boolean(),
529
+ applied: Type.Boolean({
530
+ description: 'False when only the change was shown; run again with --yes (exit 10).',
531
+ }),
532
+ file: Type.String({ description: 'The config file.' }),
533
+ reads: Type.Array(Str, { description: 'What sideby reads while it is on, all on this computer.' }),
534
+ }, { title: 'sideby browser --enable|--disable --json' }),
535
+ 'browser-open': Type.Object({
536
+ schemaVersion: Version,
537
+ ref: Str,
538
+ url: Str,
539
+ profile: Type.Object({ dir: Str, name: Str }),
540
+ command: Type.Array(Str, { description: 'The `open` command sideby ran.' }),
541
+ }, { title: 'sideby browser open --json' }),
494
542
  update: Type.Object({
495
543
  schemaVersion: Version,
496
544
  current: Type.String({ description: 'The running sideby version.' }),
@@ -513,6 +561,9 @@ export const OUTPUT_SCHEMAS = {
513
561
  restartedPanel: Type.Optional(Type.Boolean({
514
562
  description: 'A background Panel was running and starts again with the new version.',
515
563
  })),
564
+ refreshedApp: Type.Optional(Type.Boolean({
565
+ description: 'The desktop app (`sideby app install`) was installed and was written again by the new version.',
566
+ })),
516
567
  skill: Type.Optional(Type.String({ description: 'The command that updates the sideby skill; sideby never runs it.' })),
517
568
  output: Type.Optional(Type.String({ description: 'What the failed install command printed.' })),
518
569
  }, { title: 'sideby update --json' }),
@@ -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'] },
@@ -379,6 +408,8 @@ async function dispatch(cmd, parsed, io, cmds) {
379
408
  return await cmds.cmdApp(parsed, io);
380
409
  case 'desktop':
381
410
  return await cmds.cmdDesktop(parsed, io);
411
+ case 'browser':
412
+ return await cmds.cmdBrowser(parsed, io);
382
413
  case 'shell-init':
383
414
  return await cmds.cmdShellInit(parsed, io);
384
415
  case 'plugins':
@@ -64,12 +64,6 @@ export declare const MARK_VIEWBOX: {
64
64
  readonly y: 3;
65
65
  readonly size: 69;
66
66
  };
67
- /** The square around one buddy at full size, for the small cut (favicon). */
68
- export declare const BUDDY_VIEWBOX: {
69
- readonly x: 3;
70
- readonly y: 10.5;
71
- readonly size: 58;
72
- };
73
67
  /**
74
68
  * The mark as SVG children (no outer <svg>). With `front` omitted the blue buddy's body has no fill of its own, so it
75
69
  * takes the surrounding `fill` (the Panel's `--logo-fg`, which Portals set with `logoColor`); everything else is fixed.
@@ -79,5 +73,9 @@ export declare function markSvgBody(front?: string): string;
79
73
  export declare const MARK_SVG: string;
80
74
  /** `<basePath>/icon.svg`: the whole mark on a transparent square, for a Portal's tab or menu (ADR-0006 amendment). */
81
75
  export declare const ICON_SVG: string;
82
- /** `<basePath>/favicon.svg`: the small cut, the blue buddy alone, for sizes below about 24 px (ADR-0022). */
76
+ /**
77
+ * `<basePath>/favicon.svg`: the small cut for sizes below about 24 px, redrawn for a 16 pt browser tab (ADR-0022):
78
+ * the blue buddy alone on a 32 grid, its body filling the width, short feet and no arms, `>` or mouth, bigger eyes,
79
+ * and a 2-unit outline (about 6% of the canvas). The same rules as the Local tools Portal's tab icons.
80
+ */
83
81
  export declare const FAVICON_SVG: string;
@@ -46,8 +46,6 @@ export const MARK = {
46
46
  };
47
47
  /** The square the pair is drawn in, in mark units (the Panel's 40 px tile and the app icon tile). */
48
48
  export const MARK_VIEWBOX = { x: -2.5, y: 3, size: 69 };
49
- /** The square around one buddy at full size, for the small cut (favicon). */
50
- export const BUDDY_VIEWBOX = { x: 3, y: 10.5, size: 58 };
51
49
  function buddySvg(b, line, bodyFill) {
52
50
  const color = (fill) => {
53
51
  if (fill === 'none')
@@ -83,6 +81,21 @@ export const MARK_SVG = `<svg viewBox="${viewBox(MARK_VIEWBOX)}">${markSvgBody()
83
81
  /** `<basePath>/icon.svg`: the whole mark on a transparent square, for a Portal's tab or menu (ADR-0006 amendment). */
84
82
  export const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox(MARK_VIEWBOX)}" role="img" aria-label="sideby">` +
85
83
  `${markSvgBody(MARK_COLORS.blue.body)}</svg>`;
86
- /** `<basePath>/favicon.svg`: the small cut, the blue buddy alone, for sizes below about 24 px (ADR-0022). */
87
- export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox(BUDDY_VIEWBOX)}">` +
88
- `<g ${OUTLINE}>${buddySvg({ ...MARK.front, x: 0, y: 0, scale: 1 }, MARK.line)}</g></svg>`;
84
+ /**
85
+ * `<basePath>/favicon.svg`: the small cut for sizes below about 24 px, redrawn for a 16 pt browser tab (ADR-0022):
86
+ * the blue buddy alone on a 32 grid, its body filling the width, short feet and no arms, `>` or mouth, bigger eyes,
87
+ * and a 2-unit outline (about 6% of the canvas). The same rules as the Local tools Portal's tab icons.
88
+ */
89
+ export const FAVICON_SVG = (() => {
90
+ const blue = MARK_COLORS.blue;
91
+ const ink = MARK_COLORS.ink;
92
+ const light = MARK_COLORS.light;
93
+ const body = BUDDY.paths[2].d;
94
+ return (`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="sideby">` +
95
+ `<g stroke-linecap="round" stroke-linejoin="round" transform="translate(0 -1.3)">` +
96
+ `<path d="M8.5 25 Q7.6 31 11.5 30.6 Q14 30.4 14 26.6 M18 26.6 Q18 30.4 20.5 30.6 Q24.4 31 23.5 25" fill="${blue.shade}" stroke="${ink}" stroke-width="2"/>` +
97
+ `<g transform="translate(-7.2 -12.675) scale(0.725)"><path d="${body}" fill="${blue.body}" stroke="${ink}" stroke-width="2.759"/>` +
98
+ `<ellipse cx="22.5" cy="38" rx="5.5" ry="7" fill="${ink}"/><ellipse cx="41.5" cy="38" rx="5.5" ry="7" fill="${ink}"/>` +
99
+ `<ellipse cx="20.6" cy="35.4" rx="2.1" ry="2.6" fill="${light}"/><ellipse cx="39.6" cy="35.4" rx="2.1" ry="2.6" fill="${light}"/>` +
100
+ `</g></g></svg>`);
101
+ })();
@@ -0,0 +1,71 @@
1
+ import type { Runtime } from '../runtime.ts';
2
+ import type { Account } from '../types.ts';
3
+ export declare const CHROME: {
4
+ title: string;
5
+ bundleId: string;
6
+ bundleNames: string[];
7
+ };
8
+ export declare const CHROME_DOWNLOAD = "https://www.google.com/chrome/";
9
+ /** Chrome's user data folder on macOS. */
10
+ export declare function chromeUserData(home: string): string;
11
+ export interface BrowserProfile {
12
+ /** The profile's folder inside Chrome's user data, such as `Default` or `Profile 7`. */
13
+ dir: string;
14
+ /** The name Chrome shows for it. May be an email; the Panel masks it with identities. */
15
+ name: string;
16
+ /** The Google account this browser user is signed in to Chrome with, if any. */
17
+ email?: string;
18
+ /** Refs of the Accounts this profile signed in to, as far as its local data shows. */
19
+ signIns: string[];
20
+ /** When its Local Storage last changed (ISO 8601), if known. */
21
+ changedAt?: string;
22
+ }
23
+ export interface BrowserReport {
24
+ /** config `browser.signIns`; when false nothing of the browser was read. */
25
+ enabled: boolean;
26
+ browser: string;
27
+ /** Chrome's user data folder exists (macOS, and Windows' Chrome on WSL). */
28
+ found: boolean;
29
+ /** Newest change first. */
30
+ profiles: BrowserProfile[];
31
+ /** Families whose Accounts can be matched, and those that can only be opened in a profile the user picks. */
32
+ families: {
33
+ matched: string[];
34
+ openOnly: string[];
35
+ };
36
+ /** Problems met while reading, without file contents. */
37
+ problems: string[];
38
+ }
39
+ /** `id` appears in `key` as a whole segment: not glued to another letter, digit or underscore. */
40
+ export declare function keyHasId(key: string, id: string): boolean;
41
+ /** The Browser Report for `sideby browser` and the Panel. Reads nothing of the browser while it is off. */
42
+ export declare function browserReport(rt: Runtime, accounts: readonly Account[]): Promise<BrowserReport>;
43
+ export interface BrowserOpenResult {
44
+ ref: string;
45
+ url: string;
46
+ profile: {
47
+ dir: string;
48
+ name: string;
49
+ };
50
+ command: string[];
51
+ }
52
+ /**
53
+ * Opens an Account's vendor website in one Browser Profile: the one given (folder or name), else the only profile
54
+ * that signed in to it. Several or none: refuses with the choices, so the caller can ask. `profile` must be a folder
55
+ * Chrome lists in `Local State`.
56
+ */
57
+ export declare function openInBrowser(rt: Runtime, account: Account, profile?: string): Promise<BrowserOpenResult>;
58
+ /** What turning Browser Sign-ins on reads, for the confirmation in the CLI and the Panel. */
59
+ export declare const BROWSER_READS: readonly ["the name of each Google Chrome browser user and the Google account email it uses in Chrome (from Chrome's Local State)", 'the key names claude.ai and chatgpt.com keep in each browser user’s Local Storage, never their values', 'the account id in each Claude and Codex login file, only to compare; it is never shown or saved'];
60
+ export interface BrowserSwitch {
61
+ enabled: boolean;
62
+ changed: boolean;
63
+ applied: boolean;
64
+ file: string;
65
+ /** What is read while it is on. */
66
+ reads: readonly string[];
67
+ }
68
+ /** Turns Browser Sign-ins on or off in config `browser.signIns`; without `apply`, shows the change only. */
69
+ export declare function setBrowserSignIns(rt: Runtime, on: boolean, opts: {
70
+ apply: boolean;
71
+ }): Promise<BrowserSwitch>;