sideby 0.13.1 → 0.15.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 (85) hide show
  1. package/README.md +2 -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 +194 -11
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +104 -3
  7. package/dist/src/cli/main.js +27 -7
  8. package/dist/src/core/config.d.ts +28 -0
  9. package/dist/src/core/config.js +54 -1
  10. package/dist/src/core/handoff-enable.js +10 -5
  11. package/dist/src/core/launch.d.ts +5 -0
  12. package/dist/src/core/launch.js +14 -3
  13. package/dist/src/core/quota-fetch.d.ts +22 -0
  14. package/dist/src/core/quota-fetch.js +98 -0
  15. package/dist/src/core/quota-levels.d.ts +3 -2
  16. package/dist/src/core/quota-levels.js +8 -4
  17. package/dist/src/core/quota-store.d.ts +19 -0
  18. package/dist/src/core/quota-store.js +62 -0
  19. package/dist/src/core/skill-check.d.ts +16 -0
  20. package/dist/src/core/skill-check.js +47 -3
  21. package/dist/src/core/update.d.ts +67 -1
  22. package/dist/src/core/update.js +147 -37
  23. package/dist/src/families/grok/handoff-hook.d.ts +6 -0
  24. package/dist/src/families/grok/handoff-hook.js +45 -2
  25. package/dist/src/families/grok/handoff.d.ts +6 -1
  26. package/dist/src/families/grok/handoff.js +30 -13
  27. package/dist/src/families/grok/index.js +5 -0
  28. package/dist/src/families/grok/quota-log.d.ts +30 -0
  29. package/dist/src/families/grok/quota-log.js +140 -0
  30. package/dist/src/families/grok/quota.d.ts +5 -0
  31. package/dist/src/families/grok/quota.js +112 -0
  32. package/dist/src/panel/handler.d.ts +13 -3
  33. package/dist/src/panel/handler.js +81 -8
  34. package/dist/src/panel/i18n.d.ts +138 -8
  35. package/dist/src/panel/i18n.js +301 -41
  36. package/dist/src/panel/page-logic.d.ts +59 -2
  37. package/dist/src/panel/page-logic.js +99 -6
  38. package/dist/src/panel/page-script.js +769 -175
  39. package/dist/src/panel/page-styles.js +83 -15
  40. package/dist/src/panel/page.js +3 -0
  41. package/dist/src/runtime.d.ts +32 -2
  42. package/dist/src/runtime.js +64 -21
  43. package/dist/src/types.d.ts +49 -1
  44. package/package.json +3 -2
  45. package/schemas/alias.json +1 -1
  46. package/schemas/app-install.json +1 -1
  47. package/schemas/app-uninstall.json +1 -1
  48. package/schemas/browser-open.json +1 -1
  49. package/schemas/browser-switch.json +1 -1
  50. package/schemas/browser.json +1 -1
  51. package/schemas/config.json +60 -0
  52. package/schemas/desktop-remove.json +1 -1
  53. package/schemas/desktop.json +1 -1
  54. package/schemas/doctor.json +1 -1
  55. package/schemas/error.json +1 -1
  56. package/schemas/families.json +1 -1
  57. package/schemas/handoff-disable.json +1 -1
  58. package/schemas/handoff-enable.json +1 -1
  59. package/schemas/handoff-ready.json +1 -1
  60. package/schemas/handoff-setup.json +1 -1
  61. package/schemas/handoff-status.json +282 -1
  62. package/schemas/handoff-teardown.json +1 -1
  63. package/schemas/list.json +6 -1
  64. package/schemas/model.json +1 -1
  65. package/schemas/new.json +1 -1
  66. package/schemas/next.json +1 -1
  67. package/schemas/order.json +148 -0
  68. package/schemas/pin.json +126 -0
  69. package/schemas/plugins.json +1 -1
  70. package/schemas/quota-check.json +88 -0
  71. package/schemas/quota-setup.json +1 -1
  72. package/schemas/quota-teardown.json +1 -1
  73. package/schemas/quota.json +15 -2
  74. package/schemas/remote-control.json +1 -1
  75. package/schemas/tune-change.json +1 -1
  76. package/schemas/tune.json +1 -1
  77. package/schemas/unpin.json +126 -0
  78. package/schemas/update-auto.json +84 -0
  79. package/schemas/update.json +79 -1
  80. package/skills/sideby/SKILL.md +7 -3
  81. package/skills/sideby/references/diagnose.md +2 -1
  82. package/skills/sideby/references/handoff.md +9 -1
  83. package/skills/sideby/references/panel.md +28 -0
  84. package/skills/sideby/references/quota.md +11 -1
  85. package/skills/sideby/references/update.md +6 -2
@@ -108,6 +108,7 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
108
108
  .ic { width: 15px; height: 15px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
109
109
 
110
110
  /* Summary: four quiet numbers in one surface. */
111
+ .summary[hidden], #checkup[hidden] { display: none; }
111
112
  .summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 18px; overflow: hidden; }
112
113
  .stat { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; min-width: 0; padding: 14px 18px; border-left: 1px solid var(--border); text-align: left; }
113
114
  .stat:first-child { border-left: 0; }
@@ -170,20 +171,65 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
170
171
  /* List rows: identity, one column per quota window, last used, menu; a click opens the details (model, tokens,
171
172
  Health) under them. Every column but the first has a fixed width, so the rows of all groups line up under the
172
173
  same column titles. --qn is the number of quota columns; the page sets it on #accounts. */
173
- #accounts { --qn: 2; --q-w: 176px; --l-w: 92px; --m-w: 28px; --col-gap: 28px; }
174
+ #accounts { --qn: 2; --q-w: 176px; --l-w: 92px; --m-w: 92px; --col-gap: 28px; }
174
175
  .row, .col-head { display: grid; grid-template-columns: minmax(0, 1fr) calc(var(--qn) * var(--q-w) + (var(--qn) - 1) * var(--col-gap)) var(--l-w) var(--m-w); column-gap: var(--col-gap); align-items: center; min-width: 0; }
175
176
  .row { grid-template-areas: "id quota last more" "detail detail detail detail" "notes notes notes notes"; padding: 11px 10px 11px 16px; border-top: 1px solid var(--border); transition: background-color .12s; }
176
177
  .rows .row:hover { background: color-mix(in srgb, var(--hover) 55%, transparent); }
177
178
  .row:first-child { border-top: 0; }
178
179
  .row:last-child { border-radius: 0 0 var(--radius) var(--radius); }
179
180
  .row-error .ident-name .name { color: var(--fail); }
180
- /* A login group (spec §3.21): the head carries the email and the one quota; the directories sit indented under it. */
181
- .login { border-top: 1px solid var(--border); }
182
- .login:first-child { border-top: 0; }
183
- .rows .login > .login-head { border-top: 0; padding-bottom: 4px; }
184
- .rows .login > .login-head:hover { background: transparent; }
185
- .login-rows > .row, .login-rows > .row:first-child { border-top: 1px dashed var(--border); padding-left: 40px; }
186
- .login-rows > .row:last-child { border-radius: 0 0 var(--radius) var(--radius); }
181
+ .row { position: relative; }
182
+ /* Old quota keeps its colour (the colour is how full it is) and says its age in words. */
183
+ .att-tag { font-weight: 500; }
184
+ .att-tag .dot { width: 6px; height: 6px; }
185
+ .more-opts { display: flex; flex-direction: column; gap: 12px; }
186
+ .more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
187
+ .more-opts[open] > summary { margin-bottom: 4px; }
188
+ .tune-custom > summary { cursor: pointer; color: var(--muted); font-size: 13px; margin: 4px 0; }
189
+ .ai-asks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
190
+ .ai-ask { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--ctl-radius); background: var(--surface); color: var(--text); font: inherit; text-align: left; cursor: pointer; }
191
+ .ai-ask:hover { border-color: var(--btn-hover-border); color: var(--accent); }
192
+ .ai-ask .ic { width: 14px; height: 14px; color: var(--muted); flex: none; }
193
+ .arrange-text .link-btn { margin-left: 6px; }
194
+ .q-asof { grid-column: 1 / -1; display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-size: 11px; }
195
+ .q-asof .ic { width: 11px; height: 11px; }
196
+ .stat-act { align-self: flex-end; color: var(--accent); font-size: 12px; white-space: nowrap; }
197
+ .next-why { margin: 0 0 10px; max-width: 280px; line-height: 1.5; }
198
+ .next-cmd { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; }
199
+ /* A Same Login (spec §3.21, ADR-0026): one row; opened, a short line per directory. */
200
+ .tag-login { font-weight: 400; }
201
+ .login-detail { flex-direction: column; gap: 8px; }
202
+ .login-detail .rd-note { margin: 0; }
203
+ .login-dirs { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--ctl-radius); background: var(--surface); }
204
+ .login-dir { display: grid; grid-template-columns: minmax(120px, 1fr) auto var(--l-w) minmax(0, 1.4fr) auto auto auto; column-gap: 14px; align-items: center; padding: 6px 6px 6px 12px; }
205
+ .login-dir + .login-dir { border-top: 1px solid var(--border); }
206
+ .login-dir .ld-name { display: flex; align-items: center; gap: 6px; min-width: 0; }
207
+ .login-dir .ld-name .name { font-weight: var(--strong-weight); }
208
+ .login-dir .usage { justify-content: flex-start; }
209
+ .login-dir .btn .ic { width: 13px; height: 13px; }
210
+ /* The arrange mode (spec §3.25): a bar over the list, short rows, grips, up and down, pin. Drag only here. */
211
+ .arrange-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; padding: 8px 10px 8px 14px; border: 1px solid var(--primary-border); border-radius: var(--radius); background: var(--accent-soft); color: var(--text); }
212
+ .arrange-text { display: inline-flex; align-items: center; gap: 8px; }
213
+ .arrange-text .ic { width: 14px; height: 14px; color: var(--accent); }
214
+ .arrange-actions { display: inline-flex; align-items: center; gap: 12px; }
215
+ .row.row-arrange { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px 6px 8px; }
216
+ .row-arrange .ident { flex: 1; }
217
+ .row-arrange.is-pinned { background: color-mix(in srgb, var(--accent-soft) 60%, transparent); }
218
+ .arrange-tools { display: inline-flex; align-items: center; gap: 2px; }
219
+ .arrange-tools .icon-btn { width: 28px; height: 28px; }
220
+ .arrange-tools .icon-btn .ic { width: 14px; height: 14px; }
221
+ .pin-btn { color: var(--faint); }
222
+ .pin-btn[aria-pressed="true"] { color: var(--accent); }
223
+ .pin-btn[aria-pressed="true"] .ic { fill: currentColor; }
224
+ .drag-handle { width: 20px; height: 28px; color: var(--muted); cursor: grab; }
225
+ .drag-handle:hover { color: var(--accent); }
226
+ .drag-handle .ic { width: 14px; height: 14px; }
227
+ .drag-handle:active { cursor: grabbing; }
228
+ .group-head > .drag-handle { margin-right: -8px; }
229
+ .is-dragging { opacity: .45; }
230
+ .drop-before { box-shadow: inset 0 2px 0 var(--accent); }
231
+ .drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
232
+ .menu-head { padding: 6px 10px 2px; color: var(--faint); font-size: 11px; font-weight: var(--strong-weight); }
187
233
  /* The arrow at a row's start opens and closes its details; so does a click anywhere else in the row. */
188
234
  .row-toggle { width: 28px; height: 28px; margin: -4px -6px -4px -8px; }
189
235
  .fold-hit { cursor: pointer; }
@@ -233,7 +279,20 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
233
279
  .plan { text-transform: capitalize; }
234
280
  .row > .quota { grid-area: quota; }
235
281
  .row > .last { grid-area: last; }
236
- .row > .more { grid-area: more; }
282
+ .row > .row-end { grid-area: more; display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
283
+ /* The row's pin (spec §3.25): filled and always there on a pinned row; on the others it shows on hover or focus. */
284
+ .row-run { width: 28px; height: 28px; color: var(--muted); }
285
+ .row-run .ic { width: 15px; height: 15px; }
286
+ .row-run:hover { color: var(--accent); }
287
+ .row-pin { width: 28px; height: 28px; color: var(--faint); opacity: 0; transition: opacity .12s, color .12s; }
288
+ .row-pin .ic { width: 15px; height: 15px; }
289
+ .row:hover .row-pin, .card:hover .row-pin, .row-pin:focus-visible, .row-pin.is-on { opacity: 1; }
290
+ .card-head > .row-end { display: flex; align-items: center; gap: 2px; margin-left: auto; }
291
+ .row-pin:hover { color: var(--accent); }
292
+ .row-pin.is-on { color: var(--accent); }
293
+ .row-pin.is-on .ic { fill: currentColor; }
294
+ .row-pin[aria-disabled="true"] { cursor: not-allowed; }
295
+ @media (hover: none) { .row-pin { opacity: .6; } }
237
296
  .row-notes { grid-area: notes; display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
238
297
 
239
298
  /* Quota meters: percent and countdown over a bar colored by level (ok, warn at 60%, fail at 85% used). */
@@ -245,17 +304,14 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
245
304
  .m-pct { font-weight: var(--strong-weight); font-size: 13px; font-variant-numeric: tabular-nums; }
246
305
  .m-unit { color: var(--faint); font-weight: 400; font-size: 11.5px; }
247
306
  .m-reset { margin-left: auto; color: var(--faint); font-size: 11.5px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; }
248
- .m-passed { font-style: italic; }
307
+ .m-passed { color: var(--muted); }
249
308
  .m-none { color: var(--faint); }
250
309
  .bar { height: 6px; border-radius: 999px; background: var(--track); overflow: hidden; margin-top: 5px; }
251
310
  .bar > span { display: block; height: 100%; min-width: 0; border-radius: inherit; background: var(--ok-bar); transition: width .4s ease; }
252
311
  .lvl-warn .bar > span { background: var(--warn-bar); } .lvl-warn .m-pct { color: var(--warn); }
253
312
  .lvl-fail .bar > span { background: var(--fail-bar); } .lvl-fail .m-pct { color: var(--fail); }
254
313
  .lvl-reset .m-pct { color: var(--muted); }
255
- .lvl-stale.lvl-ok .m-pct { color: var(--muted); }
256
- .is-stale .bar > span, .lvl-stale .bar > span { background: var(--faint); opacity: .5; }
257
- .q-asof { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; color: var(--faint); font-size: 11.5px; line-height: 1.3; }
258
- .q-asof .ic { width: 12px; height: 12px; }
314
+ /* Old numbers keep their level's color, paler, so the bar and the number agree. */
259
315
  .q-note { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; color: var(--muted); font-size: var(--small); }
260
316
  .link-btn { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; font-weight: var(--strong-weight); cursor: pointer; }
261
317
  .link-btn:hover:not(:disabled) { text-decoration: underline; }
@@ -541,6 +597,9 @@ select:is(:hover, :focus) { --chevron: var(--text); }
541
597
  .set-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
542
598
  .set-label { color: var(--muted); font-size: 13px; white-space: nowrap; }
543
599
  .set-row .seg { height: var(--ctl-h-sm); }
600
+ .set-check { align-items: flex-start; }
601
+ .set-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
602
+ .set-hint { color: var(--faint); font-size: 12px; max-width: 260px; }
544
603
  .set-link { margin: 0 -8px; width: calc(100% + 16px); }
545
604
  .set-link .chip { margin-left: auto; }
546
605
  .set-dot { background: var(--warn-bar); }
@@ -599,7 +658,7 @@ select:is(:hover, :focus) { --chevron: var(--text); }
599
658
  .app-actions { width: 100%; justify-content: flex-start; }
600
659
  .row { grid-template-columns: minmax(0, 1fr) auto var(--m-w); grid-template-areas: "id last more" "quota quota quota" "detail detail detail" "notes notes notes"; row-gap: 10px; }
601
660
  .row-detail { margin-left: 0; }
602
- .login-rows > .row, .login-rows > .row:first-child { padding-left: 28px; }
661
+
603
662
  .row > .quota { grid-template-columns: repeat(var(--qn), minmax(0, 1fr)); max-width: 560px; }
604
663
  .rows .m-label, .rows .m-unit, .rows .u-unit { display: inline; }
605
664
  .rows .usage { justify-content: flex-start; }
@@ -631,6 +690,15 @@ select:is(:hover, :focus) { --chevron: var(--text); }
631
690
  .toolbar > .btn-primary { flex: 1; }
632
691
  .row { padding: 10px 12px; }
633
692
  #accounts { --col-gap: 14px; }
693
+ /* One window per line, so the reset time is never cut off. */
694
+ .row > .quota { grid-template-columns: 1fr; row-gap: 10px; max-width: none; }
695
+ /* On a phone the summary keeps what needs a look and the next reset. */
696
+ .stat:nth-child(1), .stat:nth-child(3) { display: none; }
697
+ .stat:nth-child(n) { border-top: 0; }
698
+ .stat:nth-child(4) { border-left: 1px solid var(--border); }
699
+ .row-end .icon-btn { width: 36px; height: 36px; }
700
+ .login-dir { grid-template-columns: minmax(0, 1fr) auto auto; row-gap: 6px; }
701
+ .login-dir .usage, .login-dir .health { display: none; }
634
702
  .grid { grid-template-columns: 1fr; }
635
703
  .panel { padding: 14px; }
636
704
  .dlg-head { padding: 14px 14px 2px; } .dlg-body { padding: 10px 14px 14px; } .dlg-foot { padding: 10px 14px; }
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { FAVICON_SVG, MARK_SVG } from '../core/brand-mark.js';
6
6
  import { ALIAS_NAME, RESERVED_ALIASES } from '../core/config.js';
7
7
  import { QUOTA_FAIL_PERCENT, QUOTA_WARN_PERCENT, USAGE_DAYS } from '../core/quota-levels.js';
8
+ import { SKILL_INSTALL } from '../core/skill-check.js';
8
9
  import { MESSAGES } from './i18n.js';
9
10
  import { PAGE_SCRIPT } from './page-script.js';
10
11
  import { pageStyles } from './page-styles.js';
@@ -47,6 +48,8 @@ export function renderPage(opts) {
47
48
  // The alias rules of the config, so the new-account dialog checks a short command as sideby will.
48
49
  aliasPattern: ALIAS_NAME.source,
49
50
  reservedAliases: [...RESERVED_ALIASES],
51
+ // The command that installs the sideby Agent Skill, which the Panel offers so an AI assistant can do the same.
52
+ skillInstall: SKILL_INSTALL,
50
53
  });
51
54
  return `<!doctype html>
52
55
  <html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
@@ -1,5 +1,5 @@
1
1
  import { type AccountRefError } from './core/accounts.ts';
2
- import { type Config, type HandoffSettings } from './core/config.ts';
2
+ import { type Config, type DisplayConfig, type HandoffSettings } from './core/config.ts';
3
3
  import { type CreateResult } from './core/create.ts';
4
4
  import { type DesktopDeps } from './core/desktop-app.ts';
5
5
  import { type DoctorReport } from './core/doctor.ts';
@@ -9,6 +9,7 @@ import { type HandoffDecision } from './core/handoff-select.ts';
9
9
  import { type DoctorHistory } from './core/last-doctor.ts';
10
10
  import { type HostLaunch, type PreparedLaunch } from './core/launch.ts';
11
11
  import { type Paths } from './core/paths.ts';
12
+ import { type QuotaFetchMode } from './core/quota-fetch.ts';
12
13
  import { type RemoteControlChange, type RemoteControlGroup } from './core/remote-control.ts';
13
14
  import { type Shell, type ShellInitWrite } from './core/shell-init.ts';
14
15
  import { type TuneEntry, type TuneGroup } from './core/tuning.ts';
@@ -111,6 +112,8 @@ export interface AccountStatus extends Account {
111
112
  * The other Accounts signed in as the same login, which share its Quota (spec §3.21); absent when there are none.
112
113
  */
113
114
  sameLogin?: string[];
115
+ /** Pinned to the top of its Family (spec §3.25); set by `sideby list` only, absent when not pinned. */
116
+ pinned?: true;
114
117
  /** Plugin errors met while reading this Account (for example an unreadable settings file). */
115
118
  problems?: string[];
116
119
  /**
@@ -209,7 +212,24 @@ export interface Runtime {
209
212
  * The Account's Quota. For an Account of a Same Login (spec §3.21) it is the group's freshest reading, with `from`
210
213
  * naming the Account it was read from when that is another one.
211
214
  */
212
- quota(account: Account): Promise<QuotaResult>;
215
+ quota(account: Account, opts?: {
216
+ fetch?: QuotaFetchMode;
217
+ }): Promise<QuotaResult>;
218
+ /**
219
+ * Asks every Family that fetches Quota (spec §3.26) again now, for `only` or every signed-in subscription Account,
220
+ * and waits for the answers. Fetching that is turned off stays off.
221
+ */
222
+ refreshQuota(only?: readonly Account[]): Promise<void>;
223
+ /** Families that fetch Quota, with their switch (config `quota.check.<family>`) and why it is off, if it is. */
224
+ quotaChecks(): QuotaCheckInfo[];
225
+ /** Turns a Family's Quota fetching on (removes the key) or off (`quota.check.<family>: false`). */
226
+ setQuotaCheck(family: string, on: boolean): Promise<{
227
+ changed: boolean;
228
+ }>;
229
+ /** The config `display` (spec §3.25), or an empty object. */
230
+ displayOrder(): DisplayConfig;
231
+ /** Replaces the config `display`; undefined or empty removes it. Returns what the file now holds. */
232
+ setDisplayOrder(display: DisplayConfig | undefined): Promise<DisplayConfig>;
213
233
  /** The other Accounts signed in as the same login as this one (spec §3.21), in discovery order; empty when none. */
214
234
  sameLogin(account: Account): Promise<string[]>;
215
235
  usage(account: Account): Promise<UsageResult>;
@@ -263,6 +283,16 @@ export interface Runtime {
263
283
  }
264
284
  export declare class UnknownFamilyError extends UserError {
265
285
  }
286
+ export interface QuotaCheckInfo {
287
+ family: string;
288
+ title: string;
289
+ summary: string;
290
+ everyMinutes: number;
291
+ /** False when config `quota.check.<family>` is false. */
292
+ on: boolean;
293
+ /** Why no fetch happens even though it is on (CI), or undefined. */
294
+ blocked?: string;
295
+ }
266
296
  export declare function createRuntime(opts?: {
267
297
  env?: Env;
268
298
  builtins?: BuiltinPlugin[];
@@ -1,6 +1,6 @@
1
1
  // The one service layer the CLI and the Panel share. Frozen contract for v0.1 (plan: 审核修订).
2
2
  import { aliasesByAccount, discoverAccounts, resolveRef } from './core/accounts.js';
3
- import { addConfigAlias, aliasAccount, aliasArgs, aliasProblem, aliasRefs, handoffSettings, loadConfig, removeConfigAlias, } from './core/config.js';
3
+ import { addConfigAlias, aliasAccount, aliasArgs, aliasProblem, aliasRefs, editConfigDisplay, editConfigQuotaCheck, handoffSettings, loadConfig, removeConfigAlias, } from './core/config.js';
4
4
  import { createAccount } from './core/create.js';
5
5
  import { bindDesktopDeps, checkAppData } from './core/desktop-app.js';
6
6
  import { applyFixes, runDoctor } from './core/doctor.js';
@@ -10,9 +10,10 @@ import { handoffFindings } from './core/handoff-doctor.js';
10
10
  import { selectNext } from './core/handoff-select.js';
11
11
  import { identityOf } from './core/identity.js';
12
12
  import { mergeDoctorHistory, readDoctorHistory, writeDoctorHistory, } from './core/last-doctor.js';
13
- import { brokenOnPath, prepareLaunch, which } from './core/launch.js';
13
+ import { accountEnv, brokenOnPath, prepareLaunch, which, } from './core/launch.js';
14
14
  import { memoStore } from './core/memo-store.js';
15
15
  import { resolvePaths } from './core/paths.js';
16
+ import { fetchedQuota, quotaCheckOff, shownQuota } from './core/quota-fetch.js';
16
17
  import { remoteControlGroups, turnRemoteControlOff, turnRemoteControlOn, undoRemoteControlOff, } from './core/remote-control.js';
17
18
  import { findResumeRoute } from './core/resume.js';
18
19
  import { SAME_LOGIN_FAMILIES, sameLoginGroups, sharedQuota } from './core/same-login.js';
@@ -106,31 +107,43 @@ export async function createRuntime(opts = {}) {
106
107
  return list.filter((a) => peers.includes(a.ref));
107
108
  };
108
109
  /** Every Account's Quota as shown (shared within a Same Login), reading each Account's own cache once. */
109
- const quotasOf = async (list) => {
110
+ const quotasOf = async (list, mode = 'wait') => {
110
111
  const [own, groups] = await Promise.all([
111
- Promise.all(list.map(async (a) => [a.ref, await ownQuota(a)])).then((e) => new Map(e)),
112
+ Promise.all(list.map(async (a) => [a.ref, await ownQuota(a, mode)])).then((e) => new Map(e)),
112
113
  groupsOf(list),
113
114
  ]);
114
115
  return new Map(list.map((a) => [a.ref, sharedQuota(a.ref, own, groups.get(a.ref) ?? [])]));
115
116
  };
116
- const ownQuota = async (account) => {
117
+ /** A reader's own `from`, `fetched` and `fetchError` are dropped: only sideby sets them (spec §3.21, §3.26). */
118
+ const readerOnly = (q) => {
119
+ if (q.status !== 'ok')
120
+ return q;
121
+ const { from: _from, fetched: _fetched, fetchError: _error, ...own } = q;
122
+ return own;
123
+ };
124
+ const ownQuota = async (account, mode = 'wait') => {
117
125
  if (account.kind === 'api')
118
126
  return { status: 'unavailable', reason: 'api-account' };
119
127
  const f = familyOf(account.family);
120
- if (!f.readQuota)
128
+ if (!f.readQuota && !f.fetchQuota)
121
129
  return { status: 'unavailable', reason: 'no-source' };
122
130
  await memos.ready();
123
131
  try {
124
- const q = await f.readQuota(account, readContext());
125
- // Only sideby marks a reading as borrowed from another Account (spec §3.21); a reader's own `from` is dropped.
126
- if (q.status === 'ok' && q.from !== undefined) {
127
- const { from: _from, ...own } = q;
128
- return own;
129
- }
130
- return q;
131
- }
132
- catch (err) {
133
- return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
132
+ let local = null;
133
+ if (f.readQuota)
134
+ try {
135
+ local = readerOnly(await f.readQuota(account, readContext()));
136
+ }
137
+ catch (err) {
138
+ local = { status: 'unavailable', reason: 'unrecognized', detail: err.message };
139
+ }
140
+ if (!f.fetchQuota)
141
+ return local;
142
+ const off = quotaCheckOff(f.id, config, env);
143
+ // A signed-out Account has nothing to ask about; its kept answer still shows.
144
+ const ask = off || mode === 'never' || (await loginOf(account)) === 'logged-out' ? 'never' : mode;
145
+ const kept = await fetchedQuota(f.fetchQuota, account, readContext(), accountEnv(f, account, env), ask);
146
+ return shownQuota(local, kept, off);
134
147
  }
135
148
  finally {
136
149
  memos.saveSoon();
@@ -233,7 +246,7 @@ export async function createRuntime(opts = {}) {
233
246
  layout: { main: f.layout.main, account: f.layout.account },
234
247
  selectVar: f.selectVar,
235
248
  login: { args: f.login.args ? [...f.login.args] : null, hint: f.login.hint },
236
- quota: Boolean(f.readQuota),
249
+ quota: Boolean(f.readQuota || f.fetchQuota),
237
250
  usage: Boolean(f.readUsage),
238
251
  quotaSetup: Boolean(f.quotaSetup),
239
252
  shared: f.sharedItems.map((item) => ({
@@ -497,11 +510,41 @@ export async function createRuntime(opts = {}) {
497
510
  account: route.account,
498
511
  };
499
512
  },
500
- async quota(account) {
513
+ async quota(account, o = {}) {
501
514
  const peers = await peersOf(account);
502
515
  if (!peers.length)
503
- return ownQuota(account);
504
- return (await quotasOf([account, ...peers])).get(account.ref);
516
+ return ownQuota(account, o.fetch);
517
+ return (await quotasOf([account, ...peers], o.fetch)).get(account.ref);
518
+ },
519
+ async refreshQuota(only) {
520
+ const list = (only ?? (await accounts())).filter((a) => a.kind === 'subscription' && families.get(a.family)?.fetchQuota);
521
+ await Promise.all(list.map((a) => ownQuota(a, 'force')));
522
+ },
523
+ quotaChecks() {
524
+ const ci = quotaCheckOff('', {}, env);
525
+ return [...families.values()]
526
+ .filter((f) => f.fetchQuota)
527
+ .map((f) => ({
528
+ family: f.id,
529
+ title: f.title,
530
+ summary: f.fetchQuota.summary,
531
+ everyMinutes: f.fetchQuota.everyMinutes,
532
+ on: config.quota?.check?.[f.id] !== false,
533
+ ...(ci ? { blocked: ci } : {}),
534
+ }));
535
+ },
536
+ async setQuotaCheck(family, on) {
537
+ if (!families.get(family)?.fetchQuota)
538
+ throw new UserError(`${family} does not ask for quota, so there is nothing to turn on or off`);
539
+ const r = await editConfigQuotaCheck(paths.configFile, family, on);
540
+ config.quota = r.after;
541
+ return { changed: r.changed };
542
+ },
543
+ displayOrder: () => config.display ?? {},
544
+ async setDisplayOrder(display) {
545
+ const r = await editConfigDisplay(paths.configFile, display);
546
+ config.display = r.after;
547
+ return r.after ?? {};
505
548
  },
506
549
  async sameLogin(account) {
507
550
  return (await peersOf(account)).map((a) => a.ref);
@@ -616,7 +659,7 @@ export async function createRuntime(opts = {}) {
616
659
  async handoff(familyId, o = {}) {
617
660
  const f = familyOf(familyId);
618
661
  const list = (await accounts()).filter((a) => a.family === f.id);
619
- if (!f.readQuota)
662
+ if (!f.readQuota && !f.fetchQuota)
620
663
  throw new UserError(`${f.title} has no public quota source, so sideby cannot tell which account has room; pick one yourself: ${list.map((a) => `sideby run ${a.ref}`).join(', ') || `sideby new ${f.id} <name>`}`, { code: 'no-quota-source' });
621
664
  if (list.length === 0)
622
665
  throw new UserError(`no ${f.title} accounts yet; create one with \`sideby new ${f.id} <name>\``, {
@@ -87,7 +87,13 @@ export interface FamilyDef {
87
87
  * error is treated the same way and never shown as a problem.
88
88
  */
89
89
  identity?(account: Account): Promise<AccountIdentity | undefined>;
90
+ /** Quota from local files only; read every time sideby shows Quota. */
90
91
  readQuota?(account: Account, ctx: ReadContext): Promise<QuotaResult>;
92
+ /**
93
+ * Quota that costs a request, such as asking the Host's own CLI (spec §3.26, ADR-0025). sideby decides when to call
94
+ * it and keeps its last answer; the newer of that answer and `readQuota`'s is shown.
95
+ */
96
+ fetchQuota?: QuotaFetch;
91
97
  readUsage?(account: Account, ctx: ReadContext): Promise<UsageResult>;
92
98
  /**
93
99
  * Estimates the best Compaction Threshold per model from the Account's recent local session records
@@ -185,6 +191,12 @@ export interface QuotaSetup {
185
191
  message: string;
186
192
  diff?: string;
187
193
  }>;
194
+ /**
195
+ * For a setup that adds a whole file: earlier versions of that file, byte for byte, which an older sideby wrote.
196
+ * Setup replaces such a file after a preview, teardown removes it, and the Panel's copies into other Accounts
197
+ * replace it; a file with any other content is never touched.
198
+ */
199
+ earlierTexts?: readonly string[];
188
200
  }
189
201
  export interface Account {
190
202
  family: string;
@@ -206,15 +218,47 @@ export interface ReadContext {
206
218
  /** sideby's own environment (PATH lookups); never the Host's. */
207
219
  env: Env;
208
220
  }
221
+ /**
222
+ * How sideby asks for a Family's Quota when that costs a request (spec §3.26). sideby keeps each Account's last
223
+ * answer in its state directory and calls `fetch` at most every `everyMinutes`, or when the user refreshes; never
224
+ * from the status line or the handoff hook, for an API Account or a signed-out one, in CI, or when the user turned it
225
+ * off (config `quota.check.<family>: false`).
226
+ */
227
+ export interface QuotaFetch {
228
+ /** At most one `fetch` per Account in this many minutes, unless the user refreshes. */
229
+ everyMinutes: number;
230
+ /** What a fetch does, in one plain sentence, shown next to the setting that turns it off. */
231
+ summary: string;
232
+ /**
233
+ * The Account's Quota now. An error, or an `unavailable` result, keeps the last good answer on show. Stop when
234
+ * `ctx.signal` aborts (after 20 seconds).
235
+ */
236
+ fetch(account: Account, ctx: QuotaFetchContext): Promise<QuotaResult>;
237
+ }
238
+ export interface QuotaFetchContext extends ReadContext {
239
+ signal: AbortSignal;
240
+ /**
241
+ * The environment the Host gets at Launch, before the Secret File and Plugin hooks: sideby's own, Hijack Variables
242
+ * cleared, the Family's `selectVar` set for every Account but the Main Account. Run the Host with this one.
243
+ */
244
+ hostEnv: Env;
245
+ }
209
246
  export interface QuotaWindow {
210
247
  /** `5h`, `7d`, or `<n>m` for unknown window lengths. */
211
248
  label: string;
249
+ /**
250
+ * The group of models this limit covers, such as Cursor's "Other models", when it covers only some of them. Such a
251
+ * window is shown under this name but never counts toward Quota Pressure: the Account can still use other models.
252
+ */
253
+ name?: string;
212
254
  windowMinutes: number;
213
255
  usedPercent: number;
214
256
  /** ISO timestamp. */
215
257
  resetsAt: string;
216
258
  }
217
- export type QuotaUnavailableReason = 'not-enabled' | 'no-session' | 'unrecognized' | 'no-source' | 'api-account';
259
+ export type QuotaUnavailableReason = 'not-enabled' | 'no-session' | 'unrecognized' | 'no-source' | 'api-account'
260
+ /** A `fetchQuota` answer failed; `detail` says why and what to do. */
261
+ | 'fetch-failed';
218
262
  export type QuotaResult = {
219
263
  status: 'ok';
220
264
  windows: QuotaWindow[];
@@ -226,6 +270,10 @@ export type QuotaResult = {
226
270
  * Account signed in as the same login (spec §3.21). `observedAt` is that Account's time.
227
271
  */
228
272
  from?: string;
273
+ /** Set by sideby: this reading is the kept answer of the Family's `fetchQuota`, not of its local reader. */
274
+ fetched?: boolean;
275
+ /** Set by sideby: why the latest `fetchQuota` attempt failed, while this older reading stays on show. */
276
+ fetchError?: string;
229
277
  } | {
230
278
  status: 'unavailable';
231
279
  reason: QuotaUnavailableReason;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
5
5
  "license": "MIT",
6
6
  "author": "Adonis0123 (https://github.com/Adonis0123)",
@@ -56,6 +56,7 @@
56
56
  "scripts": {
57
57
  "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/src/cli/main.js && node scripts/write-build-id.ts",
58
58
  "local": "node scripts/local.ts",
59
+ "release": "node scripts/release.ts",
59
60
  "typecheck": "tsc --noEmit",
60
61
  "lint": "biome check .",
61
62
  "format": "biome check --write .",
@@ -79,5 +80,5 @@
79
80
  },
80
81
  "packageManager": "pnpm@10.15.0",
81
82
  "readmeFilename": "README.md",
82
- "readme": "<p align=\"center\">\n <img alt=\"sideby: work, personal and API-key accounts, one terminal each, one shared config\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg\" width=\"880\">\n</p>\n\n<p align=\"center\">\n <b>Run work, personal and API-key accounts at the same time, without their configs drifting apart.</b><br>\n Each account is its own directory and its own terminal. Skills, hooks and settings stay linked to your main account. Claude Code, Codex, Grok Build and pi.\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/sideby\"><img alt=\"npm\" src=\"https://img.shields.io/npm/v/sideby\"></a>\n <a href=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml/badge.svg\"></a>\n <img alt=\"Node 22.18+\" src=\"https://img.shields.io/badge/node-%E2%89%A522.18-339933\">\n <a href=\"LICENSE\"><img alt=\"MIT\" src=\"https://img.shields.io/badge/license-MIT-blue\"></a>\n</p>\n\n<p align=\"center\">English | <a href=\"README.zh-CN.md\">中文</a></p>\n\n## Quick start\n\nRequires 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.\n\n```sh\nnpm i -g sideby\nsideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks\nsideby login claude:work # sign in once\nsideby run work # Claude Code on the work account\nsideby quota setup claude --yes # Claude quota in the panel; undo: sideby quota teardown claude\nsideby ui # every account's quota at http://127.0.0.1:17420\n```\n\n`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`.\n\n## Set up with your AI agent\n\nPaste this into Claude Code, Codex or any coding agent:\n\n```text\nInstall the sideby CLI (npm package `sideby`) and its agent skill, then set up a second Claude Code account for me.\nFollow \"Set up sideby for a user\" in https://raw.githubusercontent.com/Adonis0123/sideby/main/llms.txt\n```\n\nThe agent checks Node and your Host CLIs, installs sideby and the skill, creates the account, tells you what it shares with your main one and makes its short command work. You sign in once in your browser; the agent gives you that one command. Once the skill is installed, short requests work too: \"add another Claude account, cc008\", \"what do my Codex accounts share?\", \"which account still has quota?\".\n\n## What it looks like\n\nThe 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.\n\nhttps://github.com/user-attachments/assets/3a470c03-9009-4f1d-b7f6-8b4625d74958\n\n<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\">\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\">\n <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\">\n</picture>\n\n## Why not just an alias?\n\n`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)\n\n| | At the same time | Skills, hooks, settings | Quota and usage | Setup |\n|---|---|---|---|---|\n| `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 |\n| [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 |\n| 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 |\n\ncc-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.\n\n## What else it does\n\n| You want to | Run | Guide |\n|---|---|---|\n| Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |\n| Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |\n| See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |\n| 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) |\n| 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) |\n| 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) |\n| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |\n| 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) |\n| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |\n| 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) |\n| 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) |\n| 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) |\n| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |\n| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |\n| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |\n\n## Supported hosts\n\n| Host | Account directories | Sign-in | Quota | Usage (7 days) |\n|---|---|---|---|---|\n| Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |\n| Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |\n| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | no public source | no public source |\n| pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |\n\nsideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).\n\n## Platforms\n\nmacOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 22 and 24. **Windows is untested.** `sideby app` only installs a launcher on macOS and Linux. Linked items are symlinks, `sideby shell-init` prints bash and zsh functions, and the Claude quota wrapper runs `/bin/sh`.\n\n## Roadmap\n\n**Gemini CLI.** Gemini can point at another config directory with `GEMINI_CONFIG_DIR`. Older docs use `GEMINI_CLI_HOME`; current builds treat that name as deprecated, and exit on startup if it is set together with an exact directory override. A sideby family still needs a tested Gemini version, a login check that does not read tokens, and a list of which files to link or copy. That is more than a small patch, so it is not in this version.\n\n## Safe by design\n\n- 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.\n- 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)).\n- `doctor --fix` never replaces a real file and never changes where a link points.\n\nDetails and the terms-of-service question are in the [FAQ](docs/guide/faq.md).\n\nUsage questions and ideas go in [Discussions](https://github.com/Adonis0123/sideby/discussions).\n\n## Docs\n\n- [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes\n- [Commands](docs/guide/commands.md): every command, exit codes and `--json`\n- [Configuration](docs/guide/configuration.md): `config.json`, aliases, shell functions\n- [Panel](docs/guide/panel.md): the panel, panel app and embedding\n- [Plugins](docs/guide/plugins.md): write your own Family or launch hook\n- [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall\n- [Changelog](https://github.com/Adonis0123/sideby/releases)\n\n## For AI agents\n\n- [`llms.txt`](llms.txt): setup steps, commands, JSON contract and plugin API in one index. `sideby families --json` states what each Host's accounts share.\n- [`skills/sideby/SKILL.md`](skills/sideby/SKILL.md): one Agent Skill for seeing, repairing and creating accounts and for moving on when one hits its limit. Install with `npx skills add Adonis0123/sideby -g`; `sideby doctor` warns when the installed copy is for another sideby version.\n- [`AGENTS.md`](AGENTS.md): rules for agents working on this repository.\n\n## License\n\n[MIT](LICENSE)\n"
83
+ "readme": "<p align=\"center\">\n <img alt=\"sideby: work, personal and API-key accounts, one terminal each, one shared config\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg\" width=\"880\">\n</p>\n\n<p align=\"center\">\n <b>Run work, personal and API-key accounts at the same time, without their configs drifting apart.</b><br>\n Each account is its own directory and its own terminal. Skills, hooks and settings stay linked to your main account. Claude Code, Codex, Grok Build and pi.\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/sideby\"><img alt=\"npm\" src=\"https://img.shields.io/npm/v/sideby\"></a>\n <a href=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml/badge.svg\"></a>\n <img alt=\"Node 22.18+\" src=\"https://img.shields.io/badge/node-%E2%89%A522.18-339933\">\n <a href=\"LICENSE\"><img alt=\"MIT\" src=\"https://img.shields.io/badge/license-MIT-blue\"></a>\n</p>\n\n<p align=\"center\">English | <a href=\"README.zh-CN.md\">中文</a></p>\n\n## Quick start\n\nRequires 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.\n\n```sh\nnpm i -g sideby\nsideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks\nsideby login claude:work # sign in once\nsideby run work # Claude Code on the work account\nsideby quota setup claude --yes # Claude quota in the panel; undo: sideby quota teardown claude\nsideby ui # every account's quota at http://127.0.0.1:17420\n```\n\n`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`.\n\n## Set up with your AI agent\n\nPaste this into Claude Code, Codex or any coding agent:\n\n```text\nInstall the sideby CLI (npm package `sideby`) and its agent skill, then set up a second Claude Code account for me.\nFollow \"Set up sideby for a user\" in https://raw.githubusercontent.com/Adonis0123/sideby/main/llms.txt\n```\n\nThe agent checks Node and your Host CLIs, installs sideby and the skill, creates the account, tells you what it shares with your main one and makes its short command work. You sign in once in your browser; the agent gives you that one command. Once the skill is installed, short requests work too: \"add another Claude account, cc008\", \"what do my Codex accounts share?\", \"which account still has quota?\".\n\n## What it looks like\n\nThe 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.\n\nhttps://github.com/user-attachments/assets/3a470c03-9009-4f1d-b7f6-8b4625d74958\n\n<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\">\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\">\n <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\">\n</picture>\n\n## Why not just an alias?\n\n`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)\n\n| | At the same time | Skills, hooks, settings | Quota and usage | Setup |\n|---|---|---|---|---|\n| `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 |\n| [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 |\n| 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 |\n\ncc-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.\n\n## What else it does\n\n| You want to | Run | Guide |\n|---|---|---|\n| Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |\n| Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |\n| See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |\n| 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) |\n| 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) |\n| 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) |\n| Pin the accounts you use most and put tools and accounts in your own order | `sideby pin claude:work`, `sideby order` (or the panel's Settings) | [Your own order](docs/guide/usage.md#your-own-order) |\n| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |\n| 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) |\n| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |\n| 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) |\n| 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) |\n| 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) |\n| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |\n| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |\n| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |\n\n## Supported hosts\n\n| Host | Account directories | Sign-in | Quota | Usage (7 days) |\n|---|---|---|---|---|\n| Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |\n| Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |\n| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | weekly, asked from the `grok` CLI | no public source |\n| pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |\n\nsideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).\n\n## Platforms\n\nmacOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 22 and 24. **Windows is untested.** `sideby app` only installs a launcher on macOS and Linux. Linked items are symlinks, `sideby shell-init` prints bash and zsh functions, and the Claude quota wrapper runs `/bin/sh`.\n\n## Roadmap\n\n**Gemini CLI.** Gemini can point at another config directory with `GEMINI_CONFIG_DIR`. Older docs use `GEMINI_CLI_HOME`; current builds treat that name as deprecated, and exit on startup if it is set together with an exact directory override. A sideby family still needs a tested Gemini version, a login check that does not read tokens, and a list of which files to link or copy. That is more than a small patch, so it is not in this version.\n\n## Safe by design\n\n- 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.\n- 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)).\n- `doctor --fix` never replaces a real file and never changes where a link points.\n\nDetails and the terms-of-service question are in the [FAQ](docs/guide/faq.md).\n\nUsage questions and ideas go in [Discussions](https://github.com/Adonis0123/sideby/discussions).\n\n## Docs\n\n- [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes\n- [Commands](docs/guide/commands.md): every command, exit codes and `--json`\n- [Configuration](docs/guide/configuration.md): `config.json`, aliases, shell functions\n- [Panel](docs/guide/panel.md): the panel, panel app and embedding\n- [Plugins](docs/guide/plugins.md): write your own Family or launch hook\n- [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall\n- [Changelog](https://github.com/Adonis0123/sideby/releases)\n\n## For AI agents\n\n- [`llms.txt`](llms.txt): setup steps, commands, JSON contract and plugin API in one index. `sideby families --json` states what each Host's accounts share.\n- [`skills/sideby/SKILL.md`](skills/sideby/SKILL.md): one Agent Skill for seeing, repairing and creating accounts and for moving on when one hits its limit. Install with `npx skills add Adonis0123/sideby -g`; `sideby doctor` warns when the installed copy is for another sideby version.\n- [`AGENTS.md`](AGENTS.md): rules for agents working on this repository.\n\n## License\n\n[MIT](LICENSE)\n"
83
84
  }
@@ -149,7 +149,7 @@
149
149
  },
150
150
  "command": {
151
151
  "type": "string",
152
- "description": "Updates the installed sideby skill (run it after the user agrees)."
152
+ "description": "`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes."
153
153
  },
154
154
  "message": {
155
155
  "type": "string"
@@ -121,7 +121,7 @@
121
121
  },
122
122
  "command": {
123
123
  "type": "string",
124
- "description": "Updates the installed sideby skill (run it after the user agrees)."
124
+ "description": "`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes."
125
125
  },
126
126
  "message": {
127
127
  "type": "string"
@@ -101,7 +101,7 @@
101
101
  },
102
102
  "command": {
103
103
  "type": "string",
104
- "description": "Updates the installed sideby skill (run it after the user agrees)."
104
+ "description": "`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes."
105
105
  },
106
106
  "message": {
107
107
  "type": "string"
@@ -94,7 +94,7 @@
94
94
  },
95
95
  "command": {
96
96
  "type": "string",
97
- "description": "Updates the installed sideby skill (run it after the user agrees)."
97
+ "description": "`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes."
98
98
  },
99
99
  "message": {
100
100
  "type": "string"
@@ -89,7 +89,7 @@
89
89
  },
90
90
  "command": {
91
91
  "type": "string",
92
- "description": "Updates the installed sideby skill (run it after the user agrees)."
92
+ "description": "`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes."
93
93
  },
94
94
  "message": {
95
95
  "type": "string"
@@ -146,7 +146,7 @@
146
146
  },
147
147
  "command": {
148
148
  "type": "string",
149
- "description": "Updates the installed sideby skill (run it after the user agrees)."
149
+ "description": "`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes."
150
150
  },
151
151
  "message": {
152
152
  "type": "string"