sideby 0.10.0 → 0.12.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 (53) hide show
  1. package/README.md +24 -18
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +11 -1
  4. package/dist/src/cli/commands.js +144 -7
  5. package/dist/src/cli/json-schemas.js +56 -1
  6. package/dist/src/cli/main.js +119 -48
  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 +83 -0
  10. package/dist/src/core/brand-mark.js +88 -0
  11. package/dist/src/core/config.d.ts +16 -0
  12. package/dist/src/core/config.js +17 -2
  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/update.d.ts +209 -0
  21. package/dist/src/core/update.js +631 -0
  22. package/dist/src/desktop/icon.js +293 -62
  23. package/dist/src/families/claude/handoff.js +10 -0
  24. package/dist/src/families/desktop.d.ts +25 -0
  25. package/dist/src/families/desktop.js +8 -2
  26. package/dist/src/families/grok/bot.d.ts +8 -0
  27. package/dist/src/families/grok/bot.js +18 -0
  28. package/dist/src/handoff/hook.js +21 -4
  29. package/dist/src/handoff/messages.d.ts +4 -0
  30. package/dist/src/handoff/messages.js +12 -0
  31. package/dist/src/panel/background.d.ts +2 -0
  32. package/dist/src/panel/background.js +7 -0
  33. package/dist/src/panel/handler.d.ts +35 -0
  34. package/dist/src/panel/handler.js +132 -6
  35. package/dist/src/panel/i18n.d.ts +59 -0
  36. package/dist/src/panel/i18n.js +118 -0
  37. package/dist/src/panel/page-script.js +282 -6
  38. package/dist/src/panel/page-styles.js +21 -2
  39. package/dist/src/panel/page.d.ts +2 -0
  40. package/dist/src/panel/page.js +6 -3
  41. package/dist/src/runtime.d.ts +3 -1
  42. package/dist/src/runtime.js +7 -0
  43. package/dist/src/types.d.ts +5 -0
  44. package/dist/testing/index.js +2 -0
  45. package/package.json +2 -2
  46. package/schemas/config.json +15 -0
  47. package/schemas/desktop-remove.json +33 -0
  48. package/schemas/desktop.json +2 -1
  49. package/schemas/handoff-ready.json +4 -0
  50. package/schemas/list.json +47 -0
  51. package/schemas/update.json +115 -0
  52. package/skills/sideby/SKILL.md +7 -1
  53. package/skills/sideby/references/handoff.md +1 -1
package/README.md CHANGED
@@ -16,31 +16,20 @@
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
- `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`.
32
+ `sideby update` installs a newer sideby when there is one; sideby says so after a command and in the panel. `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`.
44
33
 
45
34
  ## Set up with your AI agent
46
35
 
@@ -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 |
@@ -78,6 +83,7 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
78
83
  | Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
79
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) |
80
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) |
81
87
  | Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |
82
88
  | Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |
83
89
  | Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |
@@ -104,7 +110,7 @@ macOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 2
104
110
  ## Safe by design
105
111
 
106
112
  - It never edits a Host's global config to switch accounts. It moves a task to another account only when you turn on Auto Handoff, and never proxies requests or pools logins.
107
- - It never reads, prints or sends a credential value. Nothing leaves your machine: sideby calls no remote service.
113
+ - It never reads, prints or sends a credential value. Nothing about your accounts leaves your machine: the only request sideby makes on its own is a daily check of the npm registry for its newest version (off with `NO_UPDATE_NOTIFIER=1`; see [Updates](docs/guide/configuration.md#updates)).
108
114
  - `doctor --fix` never replaces a real file and never changes where a link points.
109
115
 
110
116
  Details and the terms-of-service question are in the [FAQ](docs/guide/faq.md).
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261015T150956-8c8f4c"}
1
+ {"id":"20261016T071613-e5f0f1"}
@@ -61,8 +61,18 @@ export declare function cmdHandoff(a: ParsedArgs, io: Io): Promise<number>;
61
61
  export declare function cmdPlugins(a: ParsedArgs, io: Io): Promise<number>;
62
62
  export declare function cmdShellInit(a: ParsedArgs, io: Io): Promise<number>;
63
63
  export declare function cmdUi(a: ParsedArgs, io: Io): Promise<number>;
64
+ /**
65
+ * `sideby update [--yes] [--json]` (spec §3.23): asks the registry now, shows what an Update runs, and runs it with
66
+ * `--yes` (exit 10 before). An install sideby cannot update gets the command to run and exit 1.
67
+ */
68
+ export declare function cmdUpdate(a: ParsedArgs, io: Io): Promise<number>;
69
+ /** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
70
+ export declare function cmdUpdateCheck(_a: ParsedArgs, _io: Io): Promise<number>;
64
71
  export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
65
- /** `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20). */
72
+ /**
73
+ * `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
74
+ * a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
75
+ */
66
76
  export declare function cmdDesktop(a: ParsedArgs, io: Io): Promise<number>;
67
77
  export declare function cmdAlias(a: ParsedArgs, io: Io): Promise<number>;
68
78
  /** `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';
@@ -16,8 +17,22 @@ import { suggestAlias, suggestName } from '../panel/page-logic.js';
16
17
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
17
18
  import { UsageError } from './args.js';
18
19
  import { c, clock, compactNumber, quotaText, table, tuningLines, usageText } from './format.js';
20
+ /** Runs `fn` while a record under the state directory says a Host runs (also for `login`), so an Auto Update waits (spec §3.23). */
21
+ async function whileRunning(rt, fn) {
22
+ const { markRunning } = await import('../core/update.js');
23
+ const done = await markRunning(rt.paths);
24
+ try {
25
+ return await fn();
26
+ }
27
+ finally {
28
+ await done().catch(() => { });
29
+ }
30
+ }
19
31
  /** Runs the Account with Auto Handoff when it applies (spec §3.17), else the Host as usual. */
20
32
  async function runAccount(rt, ref, args, io) {
33
+ return whileRunning(rt, () => runAccountNow(rt, ref, args, io));
34
+ }
35
+ async function runAccountNow(rt, ref, args, io) {
21
36
  const handed = await runWithHandoff(rt, ref, args, {
22
37
  err: (l) => io.err(c.dim(l)),
23
38
  stdin: io.stdin ?? process.stdin,
@@ -55,11 +70,14 @@ function tilde(text, home) {
55
70
  export async function cmdList(a, io) {
56
71
  const rt = await createRuntime();
57
72
  const accounts = await rt.accounts();
58
- const statuses = await withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x))));
73
+ const [statuses, appProfiles] = await Promise.all([
74
+ withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))),
75
+ listAppProfiles(rt),
76
+ ]);
59
77
  const hasDesktop = statuses.some((s) => s.desktopApp);
60
78
  const families = await rt.familyInfo();
61
79
  if (a.flags.has('json')) {
62
- json(io, { families, accounts: statuses, pluginErrors: [...rt.pluginErrors] });
80
+ json(io, { families, accounts: statuses, appProfiles, pluginErrors: [...rt.pluginErrors] });
63
81
  return 0;
64
82
  }
65
83
  const home = rt.paths.home;
@@ -94,6 +112,25 @@ export async function cmdList(a, io) {
94
112
  io.out(c.dim('\n= <account>: signed in as the same login as that account, so they share one quota.'));
95
113
  io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
96
114
  }
115
+ // App Profiles (ADR-0019): windows of an app that signs in apart from the Accounts above.
116
+ for (const app of new Set(appProfiles.map((p) => p.app))) {
117
+ const list = appProfiles.filter((p) => p.app === app);
118
+ io.out(`\n${c.bold(app)} ${c.dim('(signs in on its own, not with the accounts above)')}`);
119
+ io.out(table(list.map((p) => [
120
+ c.bold(p.ref),
121
+ p.running ? c.green('open') : c.dim('—'),
122
+ p.signedIn === true
123
+ ? c.green('signed in')
124
+ : p.signedIn === false
125
+ ? c.yellow('not signed in')
126
+ : c.dim('?'),
127
+ p.appData ? tildify(p.appData, home) : c.dim('default'),
128
+ ]), ['WINDOW', 'STATE', 'SIGN-IN', 'DATA']));
129
+ if (!list[0]?.installed)
130
+ io.out(c.yellow(`${app} is not installed; these windows open again once it is`));
131
+ const id = list[0]?.ref.slice(0, list[0].ref.indexOf(':'));
132
+ io.out(c.dim(`sideby desktop ${id}:<name> opens one (a new name makes a new window)`));
133
+ }
97
134
  for (const s of statuses)
98
135
  for (const p of s.problems ?? [])
99
136
  io.err(c.yellow(`${s.ref}: ${p}`));
@@ -185,7 +222,7 @@ export async function cmdRun(a, io, command) {
185
222
  const prepared = await rt.prepareLaunch(ref, [...extra, ...a.rest], command);
186
223
  if (prepared.notice)
187
224
  io.err(c.cyan(prepared.notice));
188
- return runHost(prepared);
225
+ return whileRunning(rt, () => runHost(prepared));
189
226
  }
190
227
  /**
191
228
  * `sideby resume <family>`: what the Host-named shell function calls (spec §3.15). Starts the Account that holds the
@@ -201,7 +238,7 @@ export async function cmdResume(a, io) {
201
238
  io.err(c.dim(`sideby: session ${route.sessionId} was never saved; starting a new session in ${route.account.ref}`));
202
239
  else if (route.account)
203
240
  io.err(c.dim(`sideby: session ${route.sessionId} is in ${route.account.ref}; starting it there`));
204
- return runHost(route.launch);
241
+ return whileRunning(rt, () => runHost(route.launch));
205
242
  }
206
243
  /** Quotes a Host argument for a command line the user may paste; plain words stay as they are. */
207
244
  function shellQuote(arg) {
@@ -860,7 +897,10 @@ export async function cmdHandoff(a, io) {
860
897
  requested: true,
861
898
  ...(next ? { next } : {}),
862
899
  ...(request.brief ? { brief: request.brief } : {}),
900
+ ...(request.now ? { now: true } : {}),
863
901
  });
902
+ else if (request.now)
903
+ io.out(`This session was waiting for its quota; ${next ?? 'the next account'} takes over now.`);
864
904
  else
865
905
  io.out(`${next ? `${next} takes over` : 'The next account starts'} when this turn ends; finish the turn now.`);
866
906
  return 0;
@@ -977,6 +1017,84 @@ export async function cmdUi(a, io) {
977
1017
  });
978
1018
  return 0;
979
1019
  }
1020
+ /**
1021
+ * `sideby update [--yes] [--json]` (spec §3.23): asks the registry now, shows what an Update runs, and runs it with
1022
+ * `--yes` (exit 10 before). An install sideby cannot update gets the command to run and exit 1.
1023
+ */
1024
+ export async function cmdUpdate(a, io) {
1025
+ if (a.positionals.length)
1026
+ throw new UsageError('usage: sideby update [--yes] [--json]');
1027
+ const u = await import('../core/update.js');
1028
+ const { SKILL_INSTALL } = await import('../core/skill-check.js');
1029
+ const { backgroundPanelPort } = await import('../panel/background.js');
1030
+ const paths = resolvePaths(process.env);
1031
+ const asJson = a.flags.has('json');
1032
+ const plan = await u.planUpdate(paths, process.env);
1033
+ const shown = u.shownPlan(plan);
1034
+ if (!plan.available) {
1035
+ if (asJson)
1036
+ json(io, { ...shown, updated: false });
1037
+ else
1038
+ io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
1039
+ return 0;
1040
+ }
1041
+ if (!plan.canRun) {
1042
+ if (asJson)
1043
+ json(io, { ...shown, updated: false });
1044
+ else {
1045
+ io.out(`sideby ${c.bold(plan.latest)} is available (you have ${plan.current}). What changed: ${plan.releaseNotes}`);
1046
+ io.err(c.yellow(`sideby cannot update itself: ${plan.method.reason}. Run it yourself:\n ${plan.command}`));
1047
+ }
1048
+ return 1;
1049
+ }
1050
+ if (!a.flags.has('yes')) {
1051
+ if (asJson)
1052
+ json(io, { ...shown, updated: false });
1053
+ else {
1054
+ io.out(`sideby ${c.bold(plan.latest)} is available (you have ${plan.current}). What changed: ${plan.releaseNotes}`);
1055
+ io.out(`\nUpdating runs:\n ${plan.command}`);
1056
+ io.out(c.cyan('\nRun it with `sideby update --yes`.'));
1057
+ }
1058
+ return 10;
1059
+ }
1060
+ if (!asJson)
1061
+ io.out(c.dim(`$ ${plan.command}`));
1062
+ const r = await u.applyUpdate(paths, plan, process.env, {
1063
+ ...(asJson ? {} : { onOutput: (text) => process.stderr.write(text) }),
1064
+ panelPort: () => backgroundPanelPort(paths),
1065
+ });
1066
+ if (asJson) {
1067
+ json(io, {
1068
+ ...shown,
1069
+ updated: r.ok,
1070
+ message: r.message,
1071
+ restartedPanel: r.restartedPanel,
1072
+ skill: SKILL_INSTALL,
1073
+ ...(r.ok ? {} : { output: r.output }),
1074
+ });
1075
+ return r.ok ? 0 : 1;
1076
+ }
1077
+ if (!r.ok) {
1078
+ io.err(c.red(`✗ ${r.message}`));
1079
+ return 1;
1080
+ }
1081
+ io.out(`${c.green('✓')} ${r.message}.`);
1082
+ if (r.restartedPanel)
1083
+ io.out(' The background panel is restarting with the new version; open pages reload by themselves.');
1084
+ 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}`);
1086
+ return 0;
1087
+ }
1088
+ /** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
1089
+ export async function cmdUpdateCheck(_a, _io) {
1090
+ const { backgroundCheck } = await import('../core/update.js');
1091
+ const { loadConfig } = await import('../core/config.js');
1092
+ const { backgroundPanelPort } = await import('../panel/background.js');
1093
+ const paths = resolvePaths(process.env);
1094
+ const config = await loadConfig(paths.configFile).catch(() => ({}));
1095
+ await backgroundCheck(paths, process.env, config, () => backgroundPanelPort(paths));
1096
+ return 0;
1097
+ }
980
1098
  export async function cmdApp(a, io) {
981
1099
  const sub = a.positionals[0];
982
1100
  const usage = 'usage: sideby app install [--url <url>] | sideby app uninstall';
@@ -1019,13 +1137,32 @@ export async function cmdApp(a, io) {
1019
1137
  }
1020
1138
  return r.skipped.length ? 1 : 0;
1021
1139
  }
1022
- /** `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20). */
1140
+ /**
1141
+ * `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
1142
+ * a Grok Bot window, new or open, and with `--remove` its removal (ADR-0019).
1143
+ */
1023
1144
  export async function cmdDesktop(a, io) {
1024
1145
  const [ref, ...extra] = a.positionals;
1025
1146
  if (!ref || extra.length || a.rest.length)
1026
- throw new UsageError('usage: sideby desktop <account>');
1147
+ throw new UsageError('usage: sideby desktop <account> | sideby desktop grok-bot:<name> [--remove [--yes]]');
1027
1148
  const rt = await createRuntime();
1028
- const r = await openDesktopApp(rt, ref);
1149
+ const profile = parseProfileRef(ref) !== undefined;
1150
+ if (a.flags.has('remove')) {
1151
+ if (!profile)
1152
+ throw new UsageError('--remove works on Grok Bot windows only: sideby desktop grok-bot:<name> --remove');
1153
+ const r = await removeAppProfile(rt, ref, { yes: a.flags.has('yes') });
1154
+ if (a.flags.has('json'))
1155
+ json(io, r);
1156
+ else if (r.removed)
1157
+ io.out(`${c.green('✓')} removed the ${r.app} window ${c.bold(r.ref)} and its data`);
1158
+ else {
1159
+ io.out(`Remove the ${r.app} window ${c.bold(r.ref)}?`);
1160
+ io.out(` deletes ${tildify(r.appData, rt.paths.home)}: its sign-in, bots and local data on this Mac`);
1161
+ io.out('Run again with --yes to remove it.');
1162
+ }
1163
+ return r.removed ? 0 : 10;
1164
+ }
1165
+ const r = profile ? await openAppProfile(rt, ref) : await openDesktopApp(rt, ref);
1029
1166
  if (a.flags.has('json')) {
1030
1167
  json(io, r);
1031
1168
  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({
@@ -380,6 +399,8 @@ export const OUTPUT_SCHEMAS = {
380
399
  requested: Type.Literal(true),
381
400
  next: Type.Optional(Str),
382
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)),
383
404
  }, { title: 'sideby handoff ready --json' }),
384
405
  'handoff-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby handoff setup --json' }),
385
406
  'handoff-teardown': Type.Object({ schemaVersion: Version, family: Str, ok: Type.Boolean(), message: Str, diff: Type.Optional(Str) }, { title: 'sideby handoff teardown --json' }),
@@ -445,7 +466,7 @@ export const OUTPUT_SCHEMAS = {
445
466
  desktop: Type.Object({
446
467
  schemaVersion: Version,
447
468
  ref: Str,
448
- family: Str,
469
+ family: Type.String({ description: 'The family, or `grok-bot` for a Grok Bot window.' }),
449
470
  app: Type.String({ description: 'The desktop app, for example `Claude Desktop`.' }),
450
471
  appPath: Str,
451
472
  action: Type.Union([Type.Literal('started'), Type.Literal('focused'), Type.Literal('already-running')], {
@@ -461,6 +482,40 @@ export const OUTPUT_SCHEMAS = {
461
482
  command: Type.Optional(Type.Array(Str, { description: 'The `open` command sideby ran.' })),
462
483
  notices: Type.Array(Str),
463
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' }),
494
+ update: Type.Object({
495
+ schemaVersion: Version,
496
+ current: Type.String({ description: 'The running sideby version.' }),
497
+ latest: Type.String({ description: 'The `latest` version on the npm registry.' }),
498
+ available: Type.Boolean({ description: '`latest` is newer than `current`.' }),
499
+ method: Type.Object({
500
+ kind: Type.Union(['npm', 'pnpm', 'bun', 'volta', 'npx', 'project', 'source'].map((k) => Type.Literal(k)), { description: 'How this copy was installed.' }),
501
+ canUpdate: Type.Boolean({ description: 'sideby can run the Update itself.' }),
502
+ reason: Type.Optional(Type.String({ description: 'Why it cannot.' })),
503
+ }),
504
+ command: Type.String({
505
+ description: 'What an Update runs, or what to run by hand when `canRun` is false.',
506
+ }),
507
+ canRun: Type.Boolean(),
508
+ releaseNotes: Type.String({ description: 'The release notes of `latest`.' }),
509
+ updated: Type.Boolean({
510
+ description: 'False when only the plan was shown (exit 10 with --yes still to add) or nothing to do.',
511
+ }),
512
+ message: Type.Optional(Type.String()),
513
+ restartedPanel: Type.Optional(Type.Boolean({
514
+ description: 'A background Panel was running and starts again with the new version.',
515
+ })),
516
+ skill: Type.Optional(Type.String({ description: 'The command that updates the sideby skill; sideby never runs it.' })),
517
+ output: Type.Optional(Type.String({ description: 'What the failed install command printed.' })),
518
+ }, { title: 'sideby update --json' }),
464
519
  error: Type.Object({
465
520
  schemaVersion: Version,
466
521
  error: Type.String({ description: 'What went wrong and what to do next.' }),
@@ -51,16 +51,18 @@ Usage:
51
51
  sideby ui --stop stop the background panel
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
+ sideby desktop grok-bot:<name> open a Grok Bot window, a new one for a new name (macOS)
54
55
  sideby app install [--url <url>] add a sideby app that opens the panel (macOS, Linux)
55
56
  sideby app uninstall remove that app
56
57
  sideby shell-init zsh|bash print shell functions, e.g. eval "$(sideby shell-init zsh)"
57
58
  sideby shell-init [zsh|bash] --write
58
59
  rewrite the config's shellInitFile with them
59
60
  sideby plugins list loaded plugins and load errors
61
+ sideby update [--yes] update sideby to the newest version (shows the command first)
60
62
 
61
63
  Options:
62
64
  --json machine-readable output (list, new, next, alias, doctor, quota, tune, model, remote-control, handoff,
63
- families, plugins, app, desktop)
65
+ families, plugins, app, desktop, update)
64
66
  -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop
65
67
  -v, --version show the version
66
68
 
@@ -72,7 +74,10 @@ For AI agents:
72
74
  Setup steps and the full contract: https://github.com/Adonis0123/sideby/blob/main/llms.txt
73
75
  Run everything else yourself, but hand the user these as one line each: \`sideby login <account>\`
74
76
  (browser sign-in), filling in proxy.env, and \`sideby run\` or \`sideby next\` (interactive sessions).
75
- Exit 10 means a change was shown: ask the user, then run it again with --yes.`;
77
+ Exit 10 means a change was shown: ask the user, then run it again with --yes.
78
+
79
+ Updates: once a day sideby asks the npm registry for its newest version and says so after a command.
80
+ Turn it off with NO_UPDATE_NOTIFIER=1 or config "update": { "check": false }; "auto": true installs by itself.`;
76
81
  /** `sideby <command> --help` for the commands agents use to set up and repair accounts (spec §3.16). */
77
82
  const COMMAND_HELP = {
78
83
  new: `sideby new <family> <name> [--api] [--alias <short>] [--json]
@@ -133,6 +138,7 @@ Examples:
133
138
  sideby doctor claude:008 --fix
134
139
  Exit: 0 when nothing fails (warnings allowed); 1 when a check fails.`,
135
140
  desktop: `sideby desktop <account> [--json]
141
+ sideby desktop grok-bot:<name> [--remove [--yes]] [--json]
136
142
 
137
143
  Open the host's desktop app as one account: Claude Desktop for claude accounts, the Codex app (ChatGPT.app) for codex
138
144
  accounts. Each account gets its own window, side by side with the others; macOS only for now.
@@ -142,14 +148,22 @@ directory (CLAUDE_CONFIG_DIR, CODEX_HOME) and its own app data folder,
142
148
  ~/Library/Application Support/sideby/<family>/<name>/. When that account's window is already open, sideby brings it
143
149
  to the front instead of starting another one. API accounts are refused: the apps sign in with a subscription only.
144
150
 
145
- sideby never copies or changes the app, and never closes a window. The first time, sign in inside Claude Desktop;
146
- close other Claude Desktop windows while you do, because the sign-in may come back to one of them.
151
+ Grok Bot signs in with a Cursor account, apart from every account above, so its windows have names of their own:
152
+ grok-bot:<name> opens that window, and makes it first when the name is new (a new window, signed out). grok-bot or
153
+ grok-bot:main is Grok Bot's own default window. --remove deletes a window you made, which signs it out and deletes
154
+ its data on this Mac; it asks for --yes first (exit 10) and refuses while that window is open. \`sideby list\` shows
155
+ the Grok Bot windows.
156
+
157
+ sideby never copies or changes the app, and never closes a window. The first time, sign in inside the new window;
158
+ close the app's other windows while you do, because the sign-in may come back to one of them.
147
159
 
148
160
  Examples:
149
161
  sideby desktop claude:work
150
162
  sideby desktop cc008 --json
151
- Exit: 0 opened or brought to the front; 1 not macOS, no desktop app for that host, API account, app not installed,
152
- or the app did not open; 2 malformed command line.`,
163
+ sideby desktop grok-bot:002
164
+ sideby desktop grok-bot:002 --remove --yes
165
+ Exit: 0 opened, brought to the front or removed; 1 not macOS, no desktop app for that host, API account, app not
166
+ installed, a bad window name, or the app did not open; 10 --remove without --yes; 2 malformed command line.`,
153
167
  families: `sideby families [family] [--json]
154
168
 
155
169
  For each host (built in or from a plugin): where its accounts live, the variable that selects one, how to sign
@@ -189,11 +203,58 @@ const COMMANDS = {
189
203
  },
190
204
  ui: { flags: ['no-open', 'background', 'stop', 'serve-detached'], valued: ['port'] },
191
205
  app: { flags: ['json'], valued: ['url'] },
192
- desktop: { flags: ['json'] },
206
+ desktop: { flags: ['json', 'remove', 'yes'] },
193
207
  'shell-init': { flags: ['write'] },
194
208
  plugins: { flags: ['json'] },
195
209
  families: { flags: ['json'] },
210
+ update: { flags: ['json', 'yes'] },
211
+ 'update-check': { flags: [] },
196
212
  };
213
+ /** Commands that never print an Update Notice: their output is read by a shell or an agent, or they are internal. */
214
+ function noticeAllowed(cmd, parsed) {
215
+ if (cmd === 'shell-init' || cmd === 'update' || cmd === 'update-check')
216
+ return false;
217
+ if (cmd === 'ui' && parsed.flags.has('serve-detached'))
218
+ return false;
219
+ if (cmd === 'handoff' && parsed.positionals[0] === 'ready')
220
+ return false;
221
+ return true;
222
+ }
223
+ /**
224
+ * After a command (spec §3.23): print the Update Notice and the result of an Auto Update, each at most once, and
225
+ * start the next Update Check detached when the last one is a day old. Only on a terminal; never fails the command.
226
+ */
227
+ async function afterCommand() {
228
+ if (!process.stderr.isTTY)
229
+ return;
230
+ try {
231
+ const u = await import('../core/update.js');
232
+ const { resolvePaths } = await import('../core/paths.js');
233
+ const { loadConfig } = await import('../core/config.js');
234
+ const { packageVersion } = await import('../core/version.js');
235
+ const paths = resolvePaths(process.env);
236
+ const config = await loadConfig(paths.configFile).catch(() => ({}));
237
+ const cache = await u.readUpdateCache(paths);
238
+ const plan = u.afterCommandPlan(cache, packageVersion(), config, process.env, new Date());
239
+ const dim = process.env.NO_COLOR ? (t) => t : (t) => `\x1b[2m${t}\x1b[22m`;
240
+ for (const l of plan.lines)
241
+ console.error(dim(l));
242
+ if (plan.record)
243
+ await u.updateUpdateCache(paths, plan.record);
244
+ if (plan.startCheck) {
245
+ const { spawn } = await import('node:child_process');
246
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'update-check'], {
247
+ detached: true,
248
+ stdio: 'ignore',
249
+ });
250
+ child.on('error', () => { });
251
+ child.unref();
252
+ }
253
+ }
254
+ catch {
255
+ // an Update Notice is never worth failing a command
256
+ }
257
+ }
197
258
  export async function main(argv) {
198
259
  const [first, ...rest] = argv;
199
260
  // Claude Code runs this on every status-line refresh. It must still launch the original command when Node is
@@ -264,47 +325,10 @@ export async function main(argv) {
264
325
  return 0;
265
326
  }
266
327
  const cmds = await import('./commands.js');
267
- switch (cmd) {
268
- case 'list':
269
- return await cmds.cmdList(parsed, io);
270
- case 'run':
271
- return await cmds.cmdRun(parsed, io, 'run');
272
- case 'login':
273
- return await cmds.cmdRun(parsed, io, 'login');
274
- case 'resume':
275
- return await cmds.cmdResume(parsed, io);
276
- case 'next':
277
- return await cmds.cmdNext(parsed, io);
278
- case 'new':
279
- return await cmds.cmdNew(parsed, io);
280
- case 'alias':
281
- return await cmds.cmdAlias(parsed, io);
282
- case 'doctor':
283
- return await cmds.cmdDoctor(parsed, io);
284
- case 'quota':
285
- return await cmds.cmdQuota(parsed, io);
286
- case 'tune':
287
- return await cmds.cmdTune(parsed, io);
288
- case 'model':
289
- return await cmds.cmdModel(parsed, io);
290
- case 'remote-control':
291
- return await cmds.cmdRemoteControl(parsed, io);
292
- case 'handoff':
293
- return await cmds.cmdHandoff(parsed, io);
294
- case 'ui':
295
- return await cmds.cmdUi(parsed, io);
296
- case 'app':
297
- return await cmds.cmdApp(parsed, io);
298
- case 'desktop':
299
- return await cmds.cmdDesktop(parsed, io);
300
- case 'shell-init':
301
- return await cmds.cmdShellInit(parsed, io);
302
- case 'plugins':
303
- return await cmds.cmdPlugins(parsed, io);
304
- case 'families':
305
- return await cmds.cmdFamilies(parsed, io);
306
- }
307
- return 2;
328
+ const code = await dispatch(cmd, parsed, io, cmds);
329
+ if (!wantsJson && noticeAllowed(cmd, parsed))
330
+ await afterCommand();
331
+ return code;
308
332
  }
309
333
  catch (err) {
310
334
  const usage = err instanceof UsageError;
@@ -321,6 +345,53 @@ export async function main(argv) {
321
345
  return usage ? 2 : 1;
322
346
  }
323
347
  }
348
+ async function dispatch(cmd, parsed, io, cmds) {
349
+ switch (cmd) {
350
+ case 'list':
351
+ return await cmds.cmdList(parsed, io);
352
+ case 'run':
353
+ return await cmds.cmdRun(parsed, io, 'run');
354
+ case 'login':
355
+ return await cmds.cmdRun(parsed, io, 'login');
356
+ case 'resume':
357
+ return await cmds.cmdResume(parsed, io);
358
+ case 'next':
359
+ return await cmds.cmdNext(parsed, io);
360
+ case 'new':
361
+ return await cmds.cmdNew(parsed, io);
362
+ case 'alias':
363
+ return await cmds.cmdAlias(parsed, io);
364
+ case 'doctor':
365
+ return await cmds.cmdDoctor(parsed, io);
366
+ case 'quota':
367
+ return await cmds.cmdQuota(parsed, io);
368
+ case 'tune':
369
+ return await cmds.cmdTune(parsed, io);
370
+ case 'model':
371
+ return await cmds.cmdModel(parsed, io);
372
+ case 'remote-control':
373
+ return await cmds.cmdRemoteControl(parsed, io);
374
+ case 'handoff':
375
+ return await cmds.cmdHandoff(parsed, io);
376
+ case 'ui':
377
+ return await cmds.cmdUi(parsed, io);
378
+ case 'app':
379
+ return await cmds.cmdApp(parsed, io);
380
+ case 'desktop':
381
+ return await cmds.cmdDesktop(parsed, io);
382
+ case 'shell-init':
383
+ return await cmds.cmdShellInit(parsed, io);
384
+ case 'plugins':
385
+ return await cmds.cmdPlugins(parsed, io);
386
+ case 'families':
387
+ return await cmds.cmdFamilies(parsed, io);
388
+ case 'update':
389
+ return await cmds.cmdUpdate(parsed, io);
390
+ case 'update-check':
391
+ return await cmds.cmdUpdateCheck(parsed, io);
392
+ }
393
+ return 2;
394
+ }
324
395
  // Run when executed directly, also through symlinks (npm's bin shim, a user alias like `sb`).
325
396
  function isEntry() {
326
397
  const arg = process.argv[1];