sideby 0.9.3 → 0.11.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 (49) hide show
  1. package/README.md +23 -16
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +9 -1
  4. package/dist/src/cli/commands.js +145 -5
  5. package/dist/src/cli/json-schemas.js +55 -1
  6. package/dist/src/cli/main.js +23 -7
  7. package/dist/src/core/app-profiles.d.ts +50 -0
  8. package/dist/src/core/app-profiles.js +206 -0
  9. package/dist/src/core/brand-mark.d.ts +29 -0
  10. package/dist/src/core/brand-mark.js +42 -0
  11. package/dist/src/core/claude-settings-file.d.ts +17 -0
  12. package/dist/src/core/claude-settings-file.js +53 -0
  13. package/dist/src/core/desktop-app.d.ts +18 -1
  14. package/dist/src/core/desktop-app.js +22 -12
  15. package/dist/src/core/handoff-chain.d.ts +16 -2
  16. package/dist/src/core/handoff-chain.js +19 -6
  17. package/dist/src/core/handoff-run.js +43 -18
  18. package/dist/src/core/handoff-select.d.ts +4 -0
  19. package/dist/src/core/handoff-select.js +3 -2
  20. package/dist/src/core/remote-control.d.ts +45 -0
  21. package/dist/src/core/remote-control.js +157 -0
  22. package/dist/src/core/tuning-apply.js +2 -49
  23. package/dist/src/desktop/icon.js +33 -33
  24. package/dist/src/families/claude/handoff.js +10 -0
  25. package/dist/src/families/desktop.d.ts +25 -0
  26. package/dist/src/families/desktop.js +8 -2
  27. package/dist/src/families/grok/bot.d.ts +8 -0
  28. package/dist/src/families/grok/bot.js +18 -0
  29. package/dist/src/handoff/hook.js +21 -4
  30. package/dist/src/handoff/messages.d.ts +4 -0
  31. package/dist/src/handoff/messages.js +12 -0
  32. package/dist/src/panel/handler.d.ts +9 -0
  33. package/dist/src/panel/handler.js +61 -2
  34. package/dist/src/panel/i18n.d.ts +46 -0
  35. package/dist/src/panel/i18n.js +92 -0
  36. package/dist/src/panel/page-script.js +205 -5
  37. package/dist/src/panel/page-styles.js +15 -0
  38. package/dist/src/panel/page.js +2 -1
  39. package/dist/src/runtime.d.ts +10 -1
  40. package/dist/src/runtime.js +17 -0
  41. package/dist/src/types.d.ts +5 -0
  42. package/package.json +2 -2
  43. package/schemas/desktop-remove.json +33 -0
  44. package/schemas/desktop.json +2 -1
  45. package/schemas/handoff-ready.json +4 -0
  46. package/schemas/list.json +47 -0
  47. package/schemas/remote-control.json +125 -0
  48. package/skills/sideby/SKILL.md +4 -1
  49. package/skills/sideby/references/handoff.md +1 -1
package/README.md CHANGED
@@ -16,28 +16,17 @@
16
16
 
17
17
  <p align="center">English | <a href="README.zh-CN.md">中文</a></p>
18
18
 
19
- ## Why not just an alias?
20
-
21
- `CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)
22
-
23
- | | At the same time | Skills, hooks, settings | Quota and usage | Setup |
24
- |---|---|---|---|---|
25
- | `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |
26
- | [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |
27
- | sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |
28
-
29
- cc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's "open terminal" can start one provider without changing the global config.
30
-
31
19
  ## Quick start
32
20
 
33
21
  Requires Node 22.18+ and at least one Host CLI (Claude Code, Codex, Grok Build or pi) installed. AI agents: follow the setup steps in [`llms.txt`](llms.txt) ("Set up sideby for a user") instead.
34
22
 
35
23
  ```sh
36
24
  npm i -g sideby
37
- sideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks
38
- sideby login claude:work # sign in once
39
- sideby run work # Claude Code on the work account
40
- sideby ui # every account's quota at http://127.0.0.1:17420
25
+ sideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks
26
+ sideby login claude:work # sign in once
27
+ sideby run work # Claude Code on the work account
28
+ sideby quota setup claude --yes # Claude quota in the panel; undo: sideby quota teardown claude
29
+ sideby ui # every account's quota at http://127.0.0.1:17420
41
30
  ```
42
31
 
43
32
  `sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.
@@ -55,6 +44,10 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
55
44
 
56
45
  ## What it looks like
57
46
 
47
+ The 25-second story: one account buried in tasks, then accounts side by side sharing one setup, and a task moving on when one runs out.
48
+
49
+ https://github.com/user-attachments/assets/3a470c03-9009-4f1d-b7f6-8b4625d74958
50
+
58
51
  <img alt="Two terminals: the personal account and the work account running at the same time, both linked to the main account's skills and settings, then the local quota panel, and at 95% Auto Handoff moving work's session to the next account" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif" width="1280">
59
52
 
60
53
  <picture>
@@ -63,6 +56,18 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
63
56
  <img alt="sideby panel: every account's 5h and 7d quota, 7-day token usage and health in one page" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png" width="1280">
64
57
  </picture>
65
58
 
59
+ ## Why not just an alias?
60
+
61
+ `CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)
62
+
63
+ | | At the same time | Skills, hooks, settings | Quota and usage | Setup |
64
+ |---|---|---|---|---|
65
+ | `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |
66
+ | [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |
67
+ | sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |
68
+
69
+ cc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's "open terminal" can start one provider without changing the global config.
70
+
66
71
  ## What else it does
67
72
 
68
73
  | You want to | Run | Guide |
@@ -72,11 +77,13 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
72
77
  | See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |
73
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) |
74
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) |
75
81
  | Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |
76
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) |
77
83
  | Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
78
84
  | Reopen a session from Orca and the like in the account that holds it | `"resumeRouting": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |
79
85
  | Open Claude Desktop or the Codex app as one account, several side by side (macOS) | `sideby desktop claude:work` (or the panel) | [Desktop apps](docs/guide/usage.md#desktop-apps) |
86
+ | Keep several Grok Bot windows open, each signed in to its own account (macOS) | `sideby desktop grok-bot:002` (or the panel) | [Grok Bot windows](docs/guide/usage.md#grok-bot-windows) |
80
87
  | Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |
81
88
  | Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |
82
89
  | Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261015T124638-3ce884"}
1
+ {"id":"20261016T040419-834a8d"}
@@ -43,6 +43,11 @@ export declare function cmdResume(a: ParsedArgs, io: Io): Promise<number>;
43
43
  /** `sideby next <family>`: recommend the Account with the most room, then start it (spec §3.13). */
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
+ /**
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).
49
+ */
50
+ export declare function cmdRemoteControl(a: ParsedArgs, io: Io): Promise<number>;
46
51
  /**
47
52
  * `sideby model <account> [<model>|default] [--effort <level>|default] [--yes]` (spec §3.19, ADR-0014): shows the
48
53
  * Account's model and effort; with a change, shows it (exit 10) and writes it with --yes.
@@ -57,7 +62,10 @@ export declare function cmdPlugins(a: ParsedArgs, io: Io): Promise<number>;
57
62
  export declare function cmdShellInit(a: ParsedArgs, io: Io): Promise<number>;
58
63
  export declare function cmdUi(a: ParsedArgs, io: Io): Promise<number>;
59
64
  export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
60
- /** `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20). */
65
+ /**
66
+ * `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
67
+ * a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
68
+ */
61
69
  export declare function cmdDesktop(a: ParsedArgs, io: Io): Promise<number>;
62
70
  export declare function cmdAlias(a: ParsedArgs, io: Io): Promise<number>;
63
71
  /** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { aliasesByAccount } from '../core/accounts.js';
3
+ import { listAppProfiles, openAppProfile, parseProfileRef, removeAppProfile } from '../core/app-profiles.js';
3
4
  import { aliasRefs } from '../core/config.js';
4
5
  import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
5
6
  import { UserError } from '../core/errors.js';
@@ -10,6 +11,7 @@ import { runHost } from '../core/launch.js';
10
11
  import { accountModel, setAccountModel } from '../core/model-choice.js';
11
12
  import { resolvePaths, tildify } from '../core/paths.js';
12
13
  import { USAGE_DAYS } from '../core/quota-levels.js';
14
+ import { groupsToTurnOff, groupsToUndo, } from '../core/remote-control.js';
13
15
  import { SHELLS } from '../core/shell-init.js';
14
16
  import { suggestAlias, suggestName } from '../panel/page-logic.js';
15
17
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
@@ -54,11 +56,14 @@ function tilde(text, home) {
54
56
  export async function cmdList(a, io) {
55
57
  const rt = await createRuntime();
56
58
  const accounts = await rt.accounts();
57
- const statuses = await withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x))));
59
+ const [statuses, appProfiles] = await Promise.all([
60
+ withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))),
61
+ listAppProfiles(rt),
62
+ ]);
58
63
  const hasDesktop = statuses.some((s) => s.desktopApp);
59
64
  const families = await rt.familyInfo();
60
65
  if (a.flags.has('json')) {
61
- json(io, { families, accounts: statuses, pluginErrors: [...rt.pluginErrors] });
66
+ json(io, { families, accounts: statuses, appProfiles, pluginErrors: [...rt.pluginErrors] });
62
67
  return 0;
63
68
  }
64
69
  const home = rt.paths.home;
@@ -93,6 +98,25 @@ export async function cmdList(a, io) {
93
98
  io.out(c.dim('\n= <account>: signed in as the same login as that account, so they share one quota.'));
94
99
  io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
95
100
  }
101
+ // App Profiles (ADR-0019): windows of an app that signs in apart from the Accounts above.
102
+ for (const app of new Set(appProfiles.map((p) => p.app))) {
103
+ const list = appProfiles.filter((p) => p.app === app);
104
+ io.out(`\n${c.bold(app)} ${c.dim('(signs in on its own, not with the accounts above)')}`);
105
+ io.out(table(list.map((p) => [
106
+ c.bold(p.ref),
107
+ p.running ? c.green('open') : c.dim('—'),
108
+ p.signedIn === true
109
+ ? c.green('signed in')
110
+ : p.signedIn === false
111
+ ? c.yellow('not signed in')
112
+ : c.dim('?'),
113
+ p.appData ? tildify(p.appData, home) : c.dim('default'),
114
+ ]), ['WINDOW', 'STATE', 'SIGN-IN', 'DATA']));
115
+ if (!list[0]?.installed)
116
+ io.out(c.yellow(`${app} is not installed; these windows open again once it is`));
117
+ const id = list[0]?.ref.slice(0, list[0].ref.indexOf(':'));
118
+ io.out(c.dim(`sideby desktop ${id}:<name> opens one (a new name makes a new window)`));
119
+ }
96
120
  for (const s of statuses)
97
121
  for (const p of s.problems ?? [])
98
122
  io.err(c.yellow(`${s.ref}: ${p}`));
@@ -413,6 +437,100 @@ async function cmdTuneChange(a, io, action) {
413
437
  io.out(r.message);
414
438
  return r.ok ? 0 : 1;
415
439
  }
440
+ const RC_STATE = {
441
+ on: 'on',
442
+ default: 'default',
443
+ off: 'off',
444
+ missing: 'no file',
445
+ blocked: 'cannot change',
446
+ };
447
+ function rcGroupLine(g, home) {
448
+ const state = RC_STATE[g.state].padEnd(13);
449
+ const paint = g.state === 'on'
450
+ ? c.yellow
451
+ : g.state === 'off'
452
+ ? c.green
453
+ : g.state === 'default'
454
+ ? (x) => x
455
+ : c.dim;
456
+ const where = g.reason ? tilde(g.reason, home) : tildify(g.file, home);
457
+ return ` ${paint(state)} ${c.bold(g.accounts.join(', '))} ${c.dim(where)}`;
458
+ }
459
+ /**
460
+ * `sideby remote-control [off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
461
+ * claude.ai by themselves, and turning that off for every Account (a preview with exit 10 until --yes).
462
+ */
463
+ export async function cmdRemoteControl(a, io) {
464
+ const [action, extra] = a.positionals;
465
+ if (extra !== undefined || (action !== undefined && action !== 'off' && action !== 'undo'))
466
+ throw new UsageError('usage: sideby remote-control [off|undo] [--yes] [--json]');
467
+ const wantJson = a.flags.has('json');
468
+ const rt = await createRuntime();
469
+ const home = rt.paths.home;
470
+ const show = (gs) => gs.map((g) => ({ ...g, file: tildify(g.file, home) }));
471
+ const out = (data) => json(io, { action: action ?? 'status', ...data });
472
+ const groups = await rt.remoteControl();
473
+ if (!action) {
474
+ if (wantJson) {
475
+ out({ groups: show(groups) });
476
+ return 0;
477
+ }
478
+ if (groups.length === 0) {
479
+ io.out('No Claude accounts found.');
480
+ return 0;
481
+ }
482
+ io.out('Remote Control: whether Claude sessions connect to claude.ai/code and the Claude app by themselves');
483
+ for (const g of groups)
484
+ io.out(rcGroupLine(g, home));
485
+ io.out(c.dim(' on: every session connects; default: Claude Code or your organization decides; off: only when you run /remote-control'));
486
+ if (groupsToTurnOff(groups).length)
487
+ io.out(c.cyan('\nRun `sideby remote-control off` so no session connects by itself (it shows the change first).'));
488
+ return 0;
489
+ }
490
+ const todo = action === 'off' ? groupsToTurnOff(groups) : groupsToUndo(groups);
491
+ if (todo.length === 0) {
492
+ const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
493
+ if (wantJson)
494
+ out({ applied: false, groups: show(groups), message: r.message });
495
+ else
496
+ io.out(tilde(r.message, home));
497
+ return r.ok ? 0 : 1;
498
+ }
499
+ if (!a.flags.has('yes')) {
500
+ if (wantJson)
501
+ out({
502
+ applied: false,
503
+ groups: show(todo),
504
+ message: `Run again with --yes to ${action === 'off' ? 'turn it off' : 'undo'}.`,
505
+ });
506
+ else {
507
+ io.out(action === 'off'
508
+ ? 'Turn off Remote Control at startup?'
509
+ : 'Put back the Remote Control setting sideby replaced?');
510
+ for (const g of todo) {
511
+ const to = action === 'off' ? 'false' : 'what it was before';
512
+ const from = g.state === 'default' ? 'not set' : g.state === 'on' ? 'true' : 'false';
513
+ io.out(` ${tildify(g.file, home)}: remoteControlAtStartup ${from} -> ${to}`);
514
+ io.out(c.dim(` used by ${g.accounts.join(', ')}`));
515
+ }
516
+ io.out(action === 'off'
517
+ ? '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`.'
518
+ : 'New sessions go back to the previous setting.');
519
+ io.out(`Run again with --yes to ${action === 'off' ? 'turn it off' : 'undo'}.`);
520
+ }
521
+ return 10;
522
+ }
523
+ const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
524
+ const results = r.results.map((x) => ({ ...x, file: tildify(x.file, home) }));
525
+ if (wantJson)
526
+ out({ applied: r.ok, results, message: r.message });
527
+ else {
528
+ for (const x of results)
529
+ io.out(` ${x.ok ? c.green('ok') : c.red('failed')} ${x.file}: ${x.message}`);
530
+ io.out(r.message);
531
+ }
532
+ return r.ok ? 0 : 1;
533
+ }
416
534
  const FROM = {
417
535
  sideby: 'set by sideby',
418
536
  host: 'from the host config',
@@ -765,7 +883,10 @@ export async function cmdHandoff(a, io) {
765
883
  requested: true,
766
884
  ...(next ? { next } : {}),
767
885
  ...(request.brief ? { brief: request.brief } : {}),
886
+ ...(request.now ? { now: true } : {}),
768
887
  });
888
+ else if (request.now)
889
+ io.out(`This session was waiting for its quota; ${next ?? 'the next account'} takes over now.`);
769
890
  else
770
891
  io.out(`${next ? `${next} takes over` : 'The next account starts'} when this turn ends; finish the turn now.`);
771
892
  return 0;
@@ -924,13 +1045,32 @@ export async function cmdApp(a, io) {
924
1045
  }
925
1046
  return r.skipped.length ? 1 : 0;
926
1047
  }
927
- /** `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20). */
1048
+ /**
1049
+ * `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
1050
+ * a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
1051
+ */
928
1052
  export async function cmdDesktop(a, io) {
929
1053
  const [ref, ...extra] = a.positionals;
930
1054
  if (!ref || extra.length || a.rest.length)
931
- throw new UsageError('usage: sideby desktop <account>');
1055
+ throw new UsageError('usage: sideby desktop <account> | sideby desktop grok-bot:<name> [--remove [--yes]]');
932
1056
  const rt = await createRuntime();
933
- const r = await openDesktopApp(rt, ref);
1057
+ const profile = parseProfileRef(ref) !== undefined;
1058
+ if (a.flags.has('remove')) {
1059
+ if (!profile)
1060
+ throw new UsageError('--remove works on Grok Bot windows only: sideby desktop grok-bot:<name> --remove');
1061
+ const r = await removeAppProfile(rt, ref, { yes: a.flags.has('yes') });
1062
+ if (a.flags.has('json'))
1063
+ json(io, r);
1064
+ else if (r.removed)
1065
+ io.out(`${c.green('✓')} removed the ${r.app} window ${c.bold(r.ref)} and its data`);
1066
+ else {
1067
+ io.out(`Remove the ${r.app} window ${c.bold(r.ref)}?`);
1068
+ io.out(` deletes ${tildify(r.appData, rt.paths.home)}: its sign-in, bots and local data on this Mac`);
1069
+ io.out('Run again with --yes to remove it.');
1070
+ }
1071
+ return r.removed ? 0 : 10;
1072
+ }
1073
+ const r = profile ? await openAppProfile(rt, ref) : await openDesktopApp(rt, ref);
934
1074
  if (a.flags.has('json')) {
935
1075
  json(io, r);
936
1076
  return 0;
@@ -194,11 +194,30 @@ const ShellInitFiles = Type.Array(Type.Object({
194
194
  action: Type.Optional(Type.Union([Type.Literal('written'), Type.Literal('unchanged')])),
195
195
  message: Type.Optional(Str),
196
196
  }), { description: 'One entry per config `shellInitFile`, rewritten after the change.' });
197
+ const AppProfile = Type.Object({
198
+ ref: Type.String({ description: "`grok-bot:<name>`; `grok-bot:main` is the app's own default data." }),
199
+ app: Type.String({ description: 'The app, for example `Grok Bot`.' }),
200
+ name: Str,
201
+ isMain: Type.Boolean(),
202
+ appData: Type.Optional(Type.String({
203
+ description: "This window's data folder; absent for the main one, which uses the app's default.",
204
+ })),
205
+ installed: Type.Boolean(),
206
+ running: Type.Boolean({ description: "This window's own instance runs now." }),
207
+ signedIn: Type.Optional(Type.Boolean({
208
+ description: 'What the app last wrote about its sign-in (it keeps the last value after the app quits); absent when unknown. Which account is signed in is not known.',
209
+ })),
210
+ }, {
211
+ 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
+ });
197
213
  export const OUTPUT_SCHEMAS = {
198
214
  list: Type.Object({
199
215
  schemaVersion: Version,
200
216
  families: Type.Array(FamilyInfo),
201
217
  accounts: Type.Array(AccountStatus),
218
+ appProfiles: Type.Optional(Type.Array(AppProfile, {
219
+ description: 'Grok Bot windows (macOS, when Grok Bot is installed or has windows), the main one first. Not accounts.',
220
+ })),
202
221
  pluginErrors: Type.Array(PluginError),
203
222
  }, { title: 'sideby list --json' }),
204
223
  doctor: Type.Object({
@@ -235,6 +254,30 @@ export const OUTPUT_SCHEMAS = {
235
254
  })),
236
255
  message: Type.Optional(Str),
237
256
  }, { title: 'sideby tune apply|undo --json' }),
257
+ 'remote-control': Type.Object({
258
+ schemaVersion: Version,
259
+ action: Type.Union([Type.Literal('status'), Type.Literal('off'), Type.Literal('undo')]),
260
+ applied: Type.Optional(Type.Boolean({ description: 'True once the change was written (or undone).' })),
261
+ groups: Type.Optional(Type.Array(Type.Object({
262
+ file: Str,
263
+ accounts: Type.Array(Str, { description: 'Claude accounts whose settings.json is this file.' }),
264
+ state: Type.Union([
265
+ Type.Literal('on'),
266
+ Type.Literal('default'),
267
+ Type.Literal('off'),
268
+ Type.Literal('missing'),
269
+ Type.Literal('blocked'),
270
+ ], {
271
+ description: 'on: remoteControlAtStartup is true; default: not set; off: false; missing: no settings.json; blocked: cannot be changed safely (see reason).',
272
+ }),
273
+ reason: Type.Optional(Str),
274
+ undo: Type.Boolean({
275
+ description: 'sideby turned it off here and can put the earlier value back.',
276
+ }),
277
+ }), { description: 'For status, every file; for a preview, the files that would change.' })),
278
+ results: Type.Optional(Type.Array(Type.Object({ file: Str, accounts: Type.Array(Str), ok: Type.Boolean(), message: Str }))),
279
+ message: Type.Optional(Str),
280
+ }, { title: 'sideby remote-control [off|undo] --json' }),
238
281
  model: Type.Object({
239
282
  schemaVersion: Version,
240
283
  account: Str,
@@ -356,6 +399,8 @@ export const OUTPUT_SCHEMAS = {
356
399
  requested: Type.Literal(true),
357
400
  next: Type.Optional(Str),
358
401
  brief: Type.Optional(Str),
402
+ /** The session was waiting for its quota, so the handoff starts now instead of after a turn (ADR-0018). */
403
+ now: Type.Optional(Type.Literal(true)),
359
404
  }, { title: 'sideby handoff ready --json' }),
360
405
  'handoff-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby handoff setup --json' }),
361
406
  'handoff-teardown': Type.Object({ schemaVersion: Version, family: Str, ok: Type.Boolean(), message: Str, diff: Type.Optional(Str) }, { title: 'sideby handoff teardown --json' }),
@@ -421,7 +466,7 @@ export const OUTPUT_SCHEMAS = {
421
466
  desktop: Type.Object({
422
467
  schemaVersion: Version,
423
468
  ref: Str,
424
- family: Str,
469
+ family: Type.String({ description: 'The family, or `grok-bot` for a Grok Bot window.' }),
425
470
  app: Type.String({ description: 'The desktop app, for example `Claude Desktop`.' }),
426
471
  appPath: Str,
427
472
  action: Type.Union([Type.Literal('started'), Type.Literal('focused'), Type.Literal('already-running')], {
@@ -437,6 +482,15 @@ export const OUTPUT_SCHEMAS = {
437
482
  command: Type.Optional(Type.Array(Str, { description: 'The `open` command sideby ran.' })),
438
483
  notices: Type.Array(Str),
439
484
  }, { title: 'sideby desktop --json' }),
485
+ 'desktop-remove': Type.Object({
486
+ schemaVersion: Version,
487
+ ref: Str,
488
+ app: Str,
489
+ appData: Type.String({ description: 'The folder that is (or would be) deleted.' }),
490
+ removed: Type.Boolean({
491
+ description: 'False when only the plan was shown; run again with --yes (exit 10).',
492
+ }),
493
+ }, { title: 'sideby desktop grok-bot:<name> --remove --json' }),
440
494
  error: Type.Object({
441
495
  schemaVersion: Version,
442
496
  error: Type.String({ description: 'What went wrong and what to do next.' }),
@@ -34,6 +34,9 @@ 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]
38
+ whether Claude sessions connect to claude.ai by themselves;
39
+ off stops that for every account (shows the change first)
37
40
  sideby handoff status what Auto Handoff still needs on this machine
38
41
  sideby handoff enable [--yes] turn it on (shows the config change first); --same-family,
39
42
  --order claude=codex001,cc002, --threshold n, --prepare-at n
@@ -48,6 +51,7 @@ Usage:
48
51
  sideby ui --stop stop the background panel
49
52
  sideby desktop <account> open the desktop app (Claude Desktop, the Codex app) as this
50
53
  account, next to the others (macOS)
54
+ sideby desktop grok-bot:<name> open a Grok Bot window, a new one for a new name (macOS)
51
55
  sideby app install [--url <url>] add a sideby app that opens the panel (macOS, Linux)
52
56
  sideby app uninstall remove that app
53
57
  sideby shell-init zsh|bash print shell functions, e.g. eval "$(sideby shell-init zsh)"
@@ -56,8 +60,8 @@ Usage:
56
60
  sideby plugins list loaded plugins and load errors
57
61
 
58
62
  Options:
59
- --json machine-readable output (list, new, next, alias, doctor, quota, tune, handoff, families, plugins, app,
60
- desktop)
63
+ --json machine-readable output (list, new, next, alias, doctor, quota, tune, model, remote-control, handoff,
64
+ families, plugins, app, desktop)
61
65
  -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop
62
66
  -v, --version show the version
63
67
 
@@ -130,6 +134,7 @@ Examples:
130
134
  sideby doctor claude:008 --fix
131
135
  Exit: 0 when nothing fails (warnings allowed); 1 when a check fails.`,
132
136
  desktop: `sideby desktop <account> [--json]
137
+ sideby desktop grok-bot:<name> [--remove [--yes]] [--json]
133
138
 
134
139
  Open the host's desktop app as one account: Claude Desktop for claude accounts, the Codex app (ChatGPT.app) for codex
135
140
  accounts. Each account gets its own window, side by side with the others; macOS only for now.
@@ -139,14 +144,22 @@ directory (CLAUDE_CONFIG_DIR, CODEX_HOME) and its own app data folder,
139
144
  ~/Library/Application Support/sideby/<family>/<name>/. When that account's window is already open, sideby brings it
140
145
  to the front instead of starting another one. API accounts are refused: the apps sign in with a subscription only.
141
146
 
142
- sideby never copies or changes the app, and never closes a window. The first time, sign in inside Claude Desktop;
143
- close other Claude Desktop windows while you do, because the sign-in may come back to one of them.
147
+ Grok Bot signs in with a Cursor account, apart from every account above, so its windows have names of their own:
148
+ grok-bot:<name> opens that window, and makes it first when the name is new (a new window, signed out). grok-bot or
149
+ grok-bot:main is Grok Bot's own default window. --remove deletes a window you made, which signs it out and deletes
150
+ its data on this Mac; it asks for --yes first (exit 10) and refuses while that window is open. \`sideby list\` shows
151
+ the Grok Bot windows.
152
+
153
+ sideby never copies or changes the app, and never closes a window. The first time, sign in inside the new window;
154
+ close the app's other windows while you do, because the sign-in may come back to one of them.
144
155
 
145
156
  Examples:
146
157
  sideby desktop claude:work
147
158
  sideby desktop cc008 --json
148
- Exit: 0 opened or brought to the front; 1 not macOS, no desktop app for that host, API account, app not installed,
149
- or the app did not open; 2 malformed command line.`,
159
+ sideby desktop grok-bot:002
160
+ sideby desktop grok-bot:002 --remove --yes
161
+ Exit: 0 opened, brought to the front or removed; 1 not macOS, no desktop app for that host, API account, app not
162
+ installed, a bad window name, or the app did not open; 10 --remove without --yes; 2 malformed command line.`,
150
163
  families: `sideby families [family] [--json]
151
164
 
152
165
  For each host (built in or from a plugin): where its accounts live, the variable that selects one, how to sign
@@ -179,13 +192,14 @@ const COMMANDS = {
179
192
  quota: { flags: ['json', 'yes'] },
180
193
  tune: { flags: ['json', 'yes'] },
181
194
  model: { flags: ['json', 'yes'], valued: ['effort'] },
195
+ 'remote-control': { flags: ['json', 'yes'] },
182
196
  handoff: {
183
197
  flags: ['json', 'yes', 'same-family', 'no-same-family'],
184
198
  valued: ['brief', 'to', 'order', 'prepare-at', 'threshold', 'wait-minutes', 'countdown'],
185
199
  },
186
200
  ui: { flags: ['no-open', 'background', 'stop', 'serve-detached'], valued: ['port'] },
187
201
  app: { flags: ['json'], valued: ['url'] },
188
- desktop: { flags: ['json'] },
202
+ desktop: { flags: ['json', 'remove', 'yes'] },
189
203
  'shell-init': { flags: ['write'] },
190
204
  plugins: { flags: ['json'] },
191
205
  families: { flags: ['json'] },
@@ -283,6 +297,8 @@ export async function main(argv) {
283
297
  return await cmds.cmdTune(parsed, io);
284
298
  case 'model':
285
299
  return await cmds.cmdModel(parsed, io);
300
+ case 'remote-control':
301
+ return await cmds.cmdRemoteControl(parsed, io);
286
302
  case 'handoff':
287
303
  return await cmds.cmdHandoff(parsed, io);
288
304
  case 'ui':
@@ -0,0 +1,50 @@
1
+ import { type ProfileAppDef } from '../families/desktop.ts';
2
+ import type { Runtime } from '../runtime.ts';
3
+ import { type DesktopOpenResult } from './desktop-app.ts';
4
+ export interface AppProfileStatus {
5
+ /** `grok-bot:<name>`; the Main Profile is `grok-bot:main`. */
6
+ ref: string;
7
+ /** The app as messages name it, for example `Grok Bot`. */
8
+ app: string;
9
+ name: string;
10
+ isMain: boolean;
11
+ /** The Profile's app data folder; absent for the Main Profile, which uses the app's default. */
12
+ appData?: string;
13
+ installed: boolean;
14
+ /** That Profile's own window runs now. */
15
+ running: boolean;
16
+ /** What the app last wrote about its sign-in; absent when unknown. */
17
+ signedIn?: boolean;
18
+ }
19
+ export interface AppProfileRemoveResult {
20
+ ref: string;
21
+ app: string;
22
+ appData: string;
23
+ /** False when only the plan was shown (no `--yes`). */
24
+ removed: boolean;
25
+ }
26
+ /** The app and Profile name a ref names, or undefined when it is not an App Profile ref. */
27
+ export declare function parseProfileRef(ref: string): {
28
+ def: ProfileAppDef;
29
+ name: string;
30
+ } | undefined;
31
+ /** Where the App Profiles of an app live: `~/Library/Application Support/sideby/<app>`. */
32
+ export declare function profilesRoot(home: string, def: ProfileAppDef): string;
33
+ /**
34
+ * Every App Profile with its state, for `sideby list` and the Panel: only on macOS, and per app only when it is
35
+ * installed or has Profiles. The Main Profile first. Never throws.
36
+ */
37
+ export declare function listAppProfiles(rt: Runtime): Promise<AppProfileStatus[]>;
38
+ /**
39
+ * Opens an App Profile's window, creating its folder first when it is new, or brings its running window to the front.
40
+ * Never starts a second window for the same Profile and never ends a process.
41
+ */
42
+ export declare function openAppProfile(rt: Runtime, ref: string): Promise<DesktopOpenResult>;
43
+ /**
44
+ * Removes a non-main App Profile's folder, which signs that window out and deletes its local data. Without `yes` it
45
+ * only returns what would go. Refuses the Main Profile, a running window, a missing folder and anything that is not a
46
+ * real folder inside sideby's own; never follows a link.
47
+ */
48
+ export declare function removeAppProfile(rt: Runtime, ref: string, opts: {
49
+ yes: boolean;
50
+ }): Promise<AppProfileRemoveResult>;