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
@@ -53,7 +53,7 @@ const MAIN = String.raw `
53
53
  silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
54
54
  }
55
55
  // View state that is not saved: the search text, the attention filter, the Health check filter.
56
- const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, highlight: null, fade: true }
56
+ const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true }
57
57
  const $ = (id) => document.getElementById(id)
58
58
 
59
59
  // ---------- preferences ----------
@@ -254,6 +254,8 @@ const MAIN = String.raw `
254
254
  check: 'M9 12l2 2 4-4M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
255
255
  userPlus: 'M15 19a6 6 0 0 0-12 0M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM19 8v6M16 11h6',
256
256
  x: 'M6 6l12 12M18 6L6 18',
257
+ arrowUp: 'M12 19V5M5 12l7-7 7 7',
258
+ bot: 'M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1',
257
259
  grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
258
260
  list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
259
261
  clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
@@ -632,6 +634,9 @@ const MAIN = String.raw `
632
634
  function renderTop() {
633
635
  renderUpdated()
634
636
  renderHandoffButton()
637
+ renderUpdateButton()
638
+ const u = updState()
639
+ $('foot-text').textContent = t(u && !u.checksOff ? 'foot.textChecks' : 'foot.text')
635
640
  // A background refresh keeps the button still; only one the viewer asked for spins it.
636
641
  const busy = S.loading && !S.silent
637
642
  const refresh = $('refresh')
@@ -1068,6 +1073,147 @@ const MAIN = String.raw `
1068
1073
  const list = needing.map((a) => a.name + ': ' + whyOf(a, now)).join('\n')
1069
1074
  return h('button', { class: 'tag tag-warn tag-btn', type: 'button', title: list, 'aria-pressed': String(ui.attention), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } }, tn('group.attention', needing.length))
1070
1075
  }
1076
+ // ---------- Grok Bot windows (ADR-0019) ----------
1077
+ // Windows of an app that signs in apart from every account, so they get a group of their own after the accounts.
1078
+ // A new window gets the next free three-digit name; removing one asks inside its row first.
1079
+ const APP_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
1080
+ // A plain bot glyph, not the grok mark: these windows are not grok (Grok Build) accounts.
1081
+ function appBadge() { return h('span', { class: 'badge badge-sm badge-logo', 'aria-hidden': 'true' }, icon('bot')) }
1082
+ function appProfiles() { return (S.state && S.state.appProfiles) || [] }
1083
+ function appId(ref) { return String(ref).slice(0, String(ref).indexOf(':')) }
1084
+ function nextAppName(list) {
1085
+ const taken = new Set(list.map((p) => p.name))
1086
+ for (let i = 1; i < 1000; i++) { const n = String(i).padStart(3, '0'); if (!taken.has(n)) return n }
1087
+ return ''
1088
+ }
1089
+ function appNameProblem(name) {
1090
+ if (!name) return t('app.nameEmpty')
1091
+ return APP_NAME.test(name) ? '' : t('app.nameBad')
1092
+ }
1093
+ function appRow(p) {
1094
+ const busy = ui.appBusy === p.ref
1095
+ const asking = ui.appRemove === p.ref
1096
+ const can = !BOOT.readOnly && p.installed
1097
+ const actions = !can ? null : asking
1098
+ ? h('div', { class: 'row-actions app-actions', role: 'group', 'aria-label': t('app.removeAsk', { ref: p.ref }) },
1099
+ h('span', { class: 'small' }, t('app.removeAsk', { ref: p.ref })),
1100
+ h('button', { class: 'btn btn-sm btn-danger', type: 'button', disabled: busy, onclick: () => removeApp(p) }, t(busy ? 'app.removing' : 'app.removeYes')),
1101
+ h('button', { class: 'btn btn-sm btn-ghost', type: 'button', disabled: busy, onclick: () => { ui.appRemove = null; renderAccounts() } }, t('btn.cancel')))
1102
+ : h('div', { class: 'row-actions app-actions' },
1103
+ h('button', { class: 'btn btn-sm' + (p.running ? '' : ' btn-primary'), type: 'button', disabled: Boolean(ui.appBusy), title: t(p.running ? 'app.showHint' : 'app.openHint', { app: p.app, ref: p.ref }), 'aria-label': t(p.running ? 'app.show' : 'app.openBtn') + ' (' + p.ref + ')', onclick: () => openApp(p.ref) },
1104
+ busy ? t('desk.opening', { app: p.app }) : t(p.running ? 'app.show' : 'app.openBtn')),
1105
+ p.isMain ? null : h('button', { class: 'btn btn-sm btn-ghost', type: 'button', disabled: p.running || Boolean(ui.appBusy), title: t(p.running ? 'app.removeRunning' : 'app.removeHint', { app: p.app }), 'aria-label': t('app.remove') + ' (' + p.ref + ')', onclick: () => { ui.appRemove = p.ref; renderAccounts() } }, t('app.remove')))
1106
+ return h('div', { class: 'app-row', role: 'listitem', 'data-ref': p.ref, 'data-key': p.ref, 'data-sig': sig([p, busy, asking, Boolean(ui.appBusy)]) },
1107
+ h('div', { class: 'ident' }, appBadge(), h('div', { class: 'ident-text' },
1108
+ h('div', { class: 'ident-name' },
1109
+ h('span', { class: 'name' }, p.isMain ? t('app.mainName') : p.name),
1110
+ p.running ? h('span', { class: 'tag tag-ok' }, h('span', { class: 'dot dot-ok', 'aria-hidden': 'true' }), t('app.isOpen')) : null,
1111
+ p.signedIn === true ? h('span', { class: 'tag', title: t('app.signedInHint') }, t('app.signedIn'))
1112
+ : p.signedIn === false ? h('span', { class: 'tag tag-warn', title: t('app.signedInHint') }, t('app.signedOut')) : null),
1113
+ h('div', { class: 'ident-sub' }, dots([h('span', { class: 'mono' }, p.ref), h('span', { class: 'muted' }, p.isMain ? t('app.mainData') : p.appData)])))),
1114
+ actions)
1115
+ }
1116
+ function appFoot(list) {
1117
+ if (BOOT.readOnly || !list[0].installed) return null
1118
+ const n = ui.appNew
1119
+ if (!n)
1120
+ return h('div', { class: 'app-foot', 'data-key': 'foot' },
1121
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(ui.appBusy), onclick: () => {
1122
+ ui.appNew = { name: nextAppName(list) }
1123
+ renderAccounts()
1124
+ const el = $('app-new-name')
1125
+ if (el) { el.focus(); el.select() }
1126
+ } }, icon('plus'), t('app.another', { app: list[0].app })),
1127
+ h('span', { class: 'muted small' }, t('app.anotherHint')))
1128
+ const input = h('input', { id: 'app-new-name', type: 'text', class: 'mono', value: n.name, maxlength: '32', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', 'aria-label': t('app.nameLabel'), 'aria-describedby': 'app-new-err' })
1129
+ const err = h('span', { id: 'app-new-err', class: 'field-error', role: 'status' })
1130
+ const go = h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: Boolean(appNameProblem(n.name)) || Boolean(ui.appBusy), onclick: () => createApp(list) }, t('app.create'))
1131
+ const sync = () => {
1132
+ n.name = input.value.trim()
1133
+ const problem = appNameProblem(n.name)
1134
+ const exists = list.some((p) => p.name === n.name)
1135
+ err.textContent = problem && input.value ? problem : exists ? t('app.exists', { ref: appId(list[0].ref) + ':' + n.name }) : ''
1136
+ err.classList.toggle('field-note', !problem && exists)
1137
+ input.classList.toggle('invalid', Boolean(problem && input.value))
1138
+ go.disabled = Boolean(problem) || Boolean(ui.appBusy)
1139
+ }
1140
+ input.addEventListener('input', sync)
1141
+ input.addEventListener('keydown', (e) => {
1142
+ if (e.key === 'Enter' && !appNameProblem(n.name)) { e.preventDefault(); createApp(list) }
1143
+ if (e.key === 'Escape') { ui.appNew = null; renderAccounts() }
1144
+ })
1145
+ return h('div', { class: 'app-foot', 'data-key': 'foot-new' },
1146
+ h('label', { for: 'app-new-name', class: 'small' }, t('app.nameLabel')),
1147
+ h('div', { class: 'app-new' }, input, go, h('button', { class: 'btn btn-sm btn-ghost', type: 'button', onclick: () => { ui.appNew = null; renderAccounts() } }, t('btn.cancel'))),
1148
+ err,
1149
+ h('span', { class: 'muted small' }, t('app.newHint', { app: list[0].app })))
1150
+ }
1151
+ function appGroup(list) {
1152
+ const app = list[0].app
1153
+ const collapsed = prefs.collapsed['app:' + app] === true
1154
+ const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
1155
+ const head = h('div', { class: 'group-head', 'data-key': 'head' },
1156
+ h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: app, count: String(list.length), action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => {
1157
+ if (collapsed) delete prefs.collapsed['app:' + app]; else prefs.collapsed['app:' + app] = true
1158
+ savePrefs()
1159
+ renderAccounts()
1160
+ } },
1161
+ icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
1162
+ h('span', { class: 'group-count' }, String(list.length))),
1163
+ h('div', { class: 'group-meta' },
1164
+ h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
1165
+ list[0].installed ? null : h('div', { class: 'group-meta' }, h('span', { class: 'tag tag-warn' }, t('acct.notInstalled'))))
1166
+ const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map(appRow))
1167
+ return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-patch': '' },
1168
+ head, body, collapsed ? null : appFoot(list))
1169
+ }
1170
+ // Shown with the account groups unless a family filter or the attention filter is on; the search matches names.
1171
+ function appGroups() {
1172
+ if (prefs.family || ui.attention) return []
1173
+ const q = ui.query.trim().toLowerCase()
1174
+ const out = []
1175
+ for (const app of new Set(appProfiles().map((p) => p.app))) {
1176
+ const all = appProfiles().filter((p) => p.app === app)
1177
+ const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
1178
+ if (list.length) out.push(appGroup(list))
1179
+ }
1180
+ return out
1181
+ }
1182
+ async function openApp(ref) {
1183
+ if (ui.appBusy) return
1184
+ ui.appBusy = ref
1185
+ renderAccounts()
1186
+ let ok = false
1187
+ try {
1188
+ const r = await api('/api/app-profile/open', { ref })
1189
+ const text = r.action === 'started' ? t('app.started', { app: r.app, ref: r.ref }) : r.action === 'focused' ? t('desk.focused', { app: r.app, ref: r.ref }) : ''
1190
+ if (text) toast(text)
1191
+ for (const m of r.notices || []) toast(m, { kind: 'info', duration: 12000 })
1192
+ ok = true
1193
+ } catch (e) { toast(t('app.failed', { ref, msg: e.message }), { kind: 'fail' }) }
1194
+ ui.appBusy = null
1195
+ if (ok) ui.appNew = null
1196
+ renderAccounts()
1197
+ load({ silent: true })
1198
+ }
1199
+ function createApp(list) {
1200
+ const n = ui.appNew
1201
+ if (!n || appNameProblem(n.name)) return
1202
+ openApp(appId(list[0].ref) + ':' + n.name)
1203
+ }
1204
+ async function removeApp(p) {
1205
+ if (ui.appBusy) return
1206
+ ui.appBusy = p.ref
1207
+ renderAccounts()
1208
+ try {
1209
+ await api('/api/app-profile/remove', { ref: p.ref })
1210
+ toast(t('app.removed', { ref: p.ref }))
1211
+ } catch (e) { toast(t('app.removeFailed', { ref: p.ref, msg: e.message }), { kind: 'fail' }) }
1212
+ ui.appBusy = null
1213
+ ui.appRemove = null
1214
+ renderAccounts()
1215
+ load({ silent: true })
1216
+ }
1071
1217
  function group(fam, list, total, now, cols) {
1072
1218
  const info = family(fam)
1073
1219
  const filtering = Boolean(ui.query.trim()) || ui.attention
@@ -1104,7 +1250,8 @@ const MAIN = String.raw `
1104
1250
  function emptyState() {
1105
1251
  const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
1106
1252
  return h('div', { class: 'empty' },
1107
- h('span', { class: 'logo', 'aria-hidden': 'true' }, h('i'), h('i')),
1253
+ // The header's mark, cloned (page.ts draws it from core/brand-mark.ts).
1254
+ h('span', { class: 'logo', 'aria-hidden': 'true' }, document.querySelector('.brand .logo svg')?.cloneNode(true) ?? null),
1108
1255
  h('h3', null, t('empty.title')),
1109
1256
  h('p', { class: 'muted' }, t('empty.body')),
1110
1257
  fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
@@ -1145,13 +1292,14 @@ const MAIN = String.raw `
1145
1292
  const all = S.state.accounts
1146
1293
  const now = Date.now()
1147
1294
  $('toolbar').hidden = !all.length
1148
- if (!all.length) { setKids(count, null); patch(box, emptyState()); return }
1295
+ if (!all.length) { setKids(count, null); patch(box, [emptyState()].concat(appGroups())); return }
1149
1296
  const shown = all.filter((a) => (!prefs.family || a.family === prefs.family) && matchesQuery(a, ui.query, famTitle(a)) && (!ui.attention || reasonsOf(a, now).length))
1150
1297
  const filtered = shown.length !== all.length
1151
1298
  patch(count, filtered
1152
1299
  ? [t('count.filtered', { n: shown.length, total: all.length }), ' ', h('button', { class: 'link-btn', type: 'button', onclick: clearFilters }, t('filter.clear'))]
1153
1300
  : tn('count.accounts', all.length))
1154
- if (!shown.length) { patch(box, noMatch()); return }
1301
+ const apps = appGroups()
1302
+ if (!shown.length && !apps.length) { patch(box, noMatch()); return }
1155
1303
  const cols = quotaColumns(shown)
1156
1304
  box.style.setProperty('--qn', String(cols.length))
1157
1305
  const ids = S.state.families.map((f) => f.id)
@@ -1161,7 +1309,7 @@ const MAIN = String.raw `
1161
1309
  const list = shown.filter((a) => a.family === fam)
1162
1310
  if (list.length) groups.push(group(fam, sortAccounts(list, prefs.sort, now), all.filter((a) => a.family === fam).length, now, cols))
1163
1311
  }
1164
- patch(box, groups)
1312
+ patch(box, groups.concat(apps))
1165
1313
  if (ui.fade && !REDUCED) {
1166
1314
  box.classList.add('view-in')
1167
1315
  setTimeout(() => box.classList.remove('view-in'), 400)
@@ -2530,8 +2678,135 @@ const MAIN = String.raw `
2530
2678
  if (Date.now() - S.lastTry > 3000) load({ silent: true })
2531
2679
  }
2532
2680
 
2681
+ // ---------- updates (spec §3.23, ADR-0021) ----------
2682
+ // A label in the header only when a newer sideby exists; the footer version opens the same dialog any time, with
2683
+ // the Auto Update switch. Updating is one plan shown first and one click; afterwards the page waits for the new
2684
+ // build and reloads (a background Panel restarts itself, a Portal loads the new code by itself).
2685
+ let up = null
2686
+ function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
2687
+ function renderUpdateButton() {
2688
+ const b = $('update')
2689
+ const u = updState()
2690
+ b.hidden = !(u && u.available)
2691
+ if (!b.hidden) {
2692
+ setKids(b, [icon('arrowUp'), h('span', null, t('upd.label', { v: u.latest }))])
2693
+ b.setAttribute('aria-label', t('upd.labelLong', { v: u.latest, cur: u.current }))
2694
+ b.title = t('upd.labelLong', { v: u.latest, cur: u.current })
2695
+ }
2696
+ if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
2697
+ }
2698
+ function openUpdate() {
2699
+ if (!BOOT.updates) return
2700
+ up = { busy: false, error: null, done: null, changed: false, autoPlan: null, autoBusy: false, waitUntil: 0 }
2701
+ showDialog('update')
2702
+ renderUpdate()
2703
+ }
2704
+ async function updRun() {
2705
+ const u = updState()
2706
+ if (!u) return
2707
+ up.busy = true
2708
+ dlg.busy = true
2709
+ up.error = null
2710
+ renderUpdate()
2711
+ try {
2712
+ const r = await api('/api/update', { confirm: true, version: u.latest })
2713
+ if (!r.updated) {
2714
+ // The registry names another version than the dialog showed: show the new plan and let the person look again.
2715
+ Object.assign(S.state.update, { latest: r.latest, available: r.available, command: r.command, canRun: r.canRun, releaseNotes: r.releaseNotes, method: r.method })
2716
+ up.changed = true
2717
+ } else {
2718
+ up.done = r
2719
+ up.waitUntil = Date.now() + 20000
2720
+ updWait()
2721
+ }
2722
+ } catch (e) { up.error = e }
2723
+ up.busy = false
2724
+ dlg.busy = false
2725
+ renderUpdate()
2726
+ }
2727
+ // After an Update the server (or the Portal's worker) comes back with a new build; load() then reloads the page.
2728
+ function updWait() {
2729
+ if (!up || !up.done || S.newBuild) { if (S.newBuild) location.reload(); return }
2730
+ if (Date.now() > up.waitUntil) { renderUpdate(); return }
2731
+ setTimeout(() => { void load({ silent: true }).then(updWait) }, 1500)
2732
+ }
2733
+ async function updAuto(on) {
2734
+ up.autoBusy = true
2735
+ up.error = null
2736
+ renderUpdate()
2737
+ try {
2738
+ const r = await api('/api/update/settings', { auto: on })
2739
+ up.autoPlan = r.changed ? { auto: on, file: r.file } : null
2740
+ } catch (e) { up.error = e }
2741
+ up.autoBusy = false
2742
+ renderUpdate()
2743
+ }
2744
+ async function updAutoSave() {
2745
+ if (!up.autoPlan) return
2746
+ up.autoBusy = true
2747
+ renderUpdate()
2748
+ try {
2749
+ await api('/api/update/settings', { auto: up.autoPlan.auto, confirm: true })
2750
+ toast(t(up.autoPlan.auto ? 'upd.autoOnSaved' : 'upd.autoOffSaved'))
2751
+ up.autoPlan = null
2752
+ await load({ silent: true })
2753
+ } catch (e) { up.error = e }
2754
+ up.autoBusy = false
2755
+ renderUpdate()
2756
+ }
2757
+ function renderUpdate() {
2758
+ if (!up || dlg.kind !== 'update') return
2759
+ const u = updState()
2760
+ const body = []
2761
+ if (!u) {
2762
+ body.push(h('p', { class: 'muted' }, spinner(), ' ', t('upd.loading')))
2763
+ setKids(dlg.el, dialogFrame(t('upd.title'), null, body, null))
2764
+ return
2765
+ }
2766
+ body.push(h('p', null, t('upd.current', { v: u.current })))
2767
+ if (up.done) {
2768
+ body.push(h('p', { class: 'note' }, t('upd.done', { v: up.done.latest })))
2769
+ body.push(h('p', { class: 'muted small' }, Date.now() > up.waitUntil ? t('upd.restartHint') : [spinner(), ' ', t('upd.reloading')]))
2770
+ body.push(h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill))
2771
+ } else if (u.available) {
2772
+ if (up.changed) body.push(h('p', { class: 'note note-warn' }, t('upd.changed', { v: u.latest })))
2773
+ body.push(h('p', null, h('b', null, t('upd.available', { v: u.latest })), ' ',
2774
+ h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
2775
+ if (u.canRun) {
2776
+ body.push(h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command))
2777
+ body.push(h('p', { class: 'muted small' }, t('upd.after')))
2778
+ } else {
2779
+ body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: u.method.reason || u.method.kind })))
2780
+ body.push(codeCopy(u.command))
2781
+ }
2782
+ body.push(h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill))
2783
+ } else if (u.checksOff) {
2784
+ body.push(h('p', { class: 'muted small' }, t('upd.checksOff', { why: u.checksOff })))
2785
+ } else if (u.latest) {
2786
+ body.push(h('p', { class: 'muted small' }, t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')))
2787
+ } else body.push(h('p', { class: 'muted small' }, t('upd.notYet')))
2788
+ if (!BOOT.readOnly && !up.done) {
2789
+ const box = h('input', { type: 'checkbox', disabled: up.autoBusy || !u.method.canUpdate })
2790
+ box.checked = up.autoPlan ? up.autoPlan.auto : u.auto
2791
+ box.addEventListener('change', () => updAuto(box.checked))
2792
+ body.push(h('div', { class: 'check-row' }, h('label', { class: 'check' }, box,
2793
+ h('span', null, h('b', null, t('upd.auto')), h('span', { class: 'muted small' }, u.method.canUpdate ? t('upd.autoHint') : t('upd.autoNo'))))))
2794
+ if (up.autoPlan) body.push(h('p', { class: 'small' }, t('upd.autoChange', { file: up.autoPlan.file, value: String(up.autoPlan.auto) }), ' ',
2795
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: up.autoBusy, onclick: updAutoSave }, t('upd.save'))))
2796
+ }
2797
+ if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
2798
+ if (up.error) body.push(errorNote(up.error, null))
2799
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
2800
+ if (u.available && u.canRun && !BOOT.readOnly && !up.done)
2801
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: up.busy, 'aria-busy': up.busy ? 'true' : null, onclick: updRun }, up.busy ? spinner() : null, up.busy ? t('upd.updating') : t('upd.now', { v: u.latest })))
2802
+ setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
2803
+ }
2804
+
2533
2805
  // ---------- start ----------
2534
- $('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
2806
+ $('ver').textContent = BOOT.version ? 'sideby v' + BOOT.version : 'sideby'
2807
+ $('ver').disabled = !BOOT.updates
2808
+ $('ver').addEventListener('click', openUpdate)
2809
+ $('update').addEventListener('click', openUpdate)
2535
2810
  $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
2536
2811
  $('handoff').addEventListener('click', () => setPage('handoff'))
2537
2812
  $('lang').addEventListener('click', () => {
@@ -2546,6 +2821,7 @@ const MAIN = String.raw `
2546
2821
  else if (dlg.kind === 'setup' && qs) renderSetup()
2547
2822
  else if (dlg.kind === 'tune' && tuneState) renderTune()
2548
2823
  else if (dlg.kind === 'model' && md) { md.form = null; renderModel() }
2824
+ else if (dlg.kind === 'update' && up) renderUpdate()
2549
2825
  })
2550
2826
  $('theme').addEventListener('click', () => {
2551
2827
  const order = CHOICES.theme
@@ -1,7 +1,8 @@
1
1
  // The Panel page's styles: design tokens (a host theme layers its values over them, see theme.ts) and the rules
2
2
  // that read them. Every color, radius and size below comes from a token, so a host theme reaches every component.
3
3
  import { resolveTheme } from './theme.js';
4
- // Colors defined from other tokens; repeated in both schemes so they follow that scheme's base colors.
4
+ // Colors defined from other tokens; repeated in both schemes so they follow that scheme's base colors. Dark mode keeps
5
+ // the logo tile light: the mark's ink outline (core/brand-mark.ts) needs a light ground.
5
6
  const DERIVED_COLORS = `
6
7
  --btn-text: var(--text); --btn-hover-text: var(--accent); --check: var(--focus);
7
8
  --logo-bg: var(--accent-soft); --logo-fg: var(--focus); --logo-line: var(--border);
@@ -34,7 +35,8 @@ const DARK_TOKENS = `
34
35
  --ok: #4ade80; --ok-bar: #22c55e; --ok-soft: #13291c; --warn: #fbbf24; --warn-bar: #f59e0b; --warn-soft: #2d2410;
35
36
  --fail: #f87171; --fail-bar: #ef4444; --fail-soft: #331717; --track: #1f2c3a;
36
37
  --spark: oklch(from var(--accent) clamp(0.7, l, 0.78) max(c, 0.14) h); --spark-soft: oklch(from var(--accent) 0.45 calc(max(c, 0.14) * 0.6) h);
37
- --shadow: 0 1px 2px rgba(0,0,0,.3); --float-shadow: 0 12px 32px rgba(0,0,0,.5), 0 2px 6px rgba(0,0,0,.3);${DERIVED_COLORS}`;
38
+ --shadow: 0 1px 2px rgba(0,0,0,.3); --float-shadow: 0 12px 32px rgba(0,0,0,.5), 0 2px 6px rgba(0,0,0,.3);${DERIVED_COLORS}
39
+ --logo-bg: #dcebf7; --logo-line: transparent;`;
38
40
  /**
39
41
  * The page's style block: default tokens, the host's tokens over them, then the rules that read them. With
40
42
  * `colorScheme: 'auto'` the page follows the system unless the viewer picked light or dark in the Panel, which sets
@@ -221,6 +223,8 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
221
223
  .link-btn { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; font-weight: var(--strong-weight); cursor: pointer; }
222
224
  .link-btn:hover:not(:disabled) { text-decoration: underline; }
223
225
  .link-btn:disabled { color: var(--faint); cursor: not-allowed; }
226
+ .foot .link-btn { color: inherit; font-weight: inherit; }
227
+ .update-btn { border-color: var(--accent); color: var(--accent); }
224
228
  .link-btn:focus-visible { outline: var(--focus-outline); outline-offset: 2px; border-radius: 3px; }
225
229
 
226
230
  /* Usage: total over cache hits, with the 7 day bars beside both lines. In the wide list the bars sit at the column's
@@ -263,6 +267,7 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
263
267
  .badge svg { display: block; width: 20px; height: 20px; fill: currentColor; }
264
268
  .badge-sm { width: 26px; height: 26px; border-radius: 7px; font-size: 10.5px; }
265
269
  .badge-sm svg { width: 16px; height: 16px; }
270
+ .badge svg.ic { fill: none; stroke: currentColor; }
266
271
  .badge-xs { width: 16px; height: 16px; border-radius: 4px; border: 0; background: none; }
267
272
  .badge-xs svg { width: 13px; height: 13px; }
268
273
  .chip { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: var(--chip-radius); background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); font: inherit; font-size: 12px; line-height: 20px; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
@@ -322,6 +327,8 @@ a.btn:hover { text-decoration: none; }
322
327
  .btn svg { width: 15px; height: 15px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
323
328
  .btn:disabled { background: var(--surface-2); border-color: var(--border); color: var(--faint); cursor: not-allowed; }
324
329
  .btn-ghost:disabled { background: transparent; border-color: transparent; }
330
+ .btn-danger { border-color: var(--fail); color: var(--fail); }
331
+ .btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--fail) 10%, transparent); border-color: var(--fail); color: var(--fail); }
325
332
  /* A busy button is disabled but keeps its variant, so a running action does not look unavailable. */
326
333
  .btn[aria-busy=true]:disabled { cursor: progress; color: var(--muted); }
327
334
  .btn-primary[aria-busy=true]:disabled { background: var(--primary); border-color: var(--primary-border); color: var(--on-primary); opacity: .85; }
@@ -433,6 +440,16 @@ select:is(:hover, :focus) { --chevron: var(--text); }
433
440
  .field-hint { color: var(--muted); font-size: var(--small); }
434
441
  .field-hint:empty { display: none; }
435
442
  .field-error:empty { display: none; }
443
+ .field-error.field-note { color: var(--muted); }
444
+ /* Grok Bot windows (ADR-0019): a simple row of their own, since they have no quota, usage or health columns. */
445
+ .app-row { display: flex; align-items: center; gap: 12px; padding: 11px 10px 11px 16px; border-top: 1px solid var(--border); min-width: 0; }
446
+ .app-row:first-child { border-top: 0; }
447
+ .app-row > .ident { flex: 1; }
448
+ .app-actions { flex: none; justify-content: flex-end; }
449
+ .app-foot { display: flex; flex-direction: column; gap: 6px; padding: 10px 16px 12px; border-top: 1px solid var(--border); }
450
+ .app-foot > .btn { align-self: flex-start; }
451
+ .app-new { display: flex; gap: 8px; align-items: center; max-width: 420px; }
452
+ .app-new input { height: var(--ctl-h-sm); flex: 1; min-width: 0; }
436
453
  .fam-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
437
454
  .fam-card { position: relative; display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid var(--border); border-radius: var(--ctl-radius); cursor: pointer; min-width: 0; transition: border-color .12s, background-color .12s; }
438
455
  .fam-card:hover { border-color: var(--btn-hover-border); }
@@ -524,6 +541,8 @@ select:is(:hover, :focus) { --chevron: var(--text); }
524
541
 
525
542
  @media (max-width: 1100px) {
526
543
  .col-head { display: none; }
544
+ .app-row { flex-wrap: wrap; }
545
+ .app-actions { width: 100%; justify-content: flex-start; }
527
546
  .row { grid-template-columns: minmax(0, 1fr) auto var(--m-w); grid-template-areas: "id health more" "quota quota quota" "usage usage last" "notes notes notes"; row-gap: 10px; }
528
547
  .row-folded { grid-template-areas: "id id more"; }
529
548
  .row > .quota { grid-template-columns: repeat(var(--qn), minmax(0, 1fr)); max-width: 560px; }
@@ -14,6 +14,8 @@ export interface PageOptions {
14
14
  theme?: ResolvedTheme;
15
15
  /** Host language from createPanelHandler's `lang`; `auto` (the default) lets the viewer choose. */
16
16
  lang?: 'auto' | 'en' | 'zh';
17
+ /** createPanelHandler's `updates`: false hides everything about sideby updates. */
18
+ updates?: boolean;
17
19
  }
18
20
  /** JSON that is safe inside a `<script>` element: no `</script>`, `<!--` or line separators survive. */
19
21
  export declare function jsonForScript(value: unknown): string;
@@ -1,6 +1,7 @@
1
1
  // The Panel page: one self-contained HTML document with inline CSS and JS, no external resources. The parts live in
2
2
  // sibling modules: page-styles.ts (tokens and CSS), page-script.ts (behaviour, with page-logic.ts helpers) and
3
3
  // i18n.ts (English and Chinese text); this module assembles them under the CSP nonce.
4
+ import { MARK_SVG } from '../core/brand-mark.js';
4
5
  import { ALIAS_NAME, RESERVED_ALIASES } from '../core/config.js';
5
6
  import { QUOTA_FAIL_PERCENT, QUOTA_WARN_PERCENT, USAGE_DAYS } from '../core/quota-levels.js';
6
7
  import { MESSAGES } from './i18n.js';
@@ -33,6 +34,7 @@ export function renderPage(opts) {
33
34
  version: opts.version ?? '',
34
35
  build: opts.build ?? '',
35
36
  readOnly: Boolean(opts.readOnly),
37
+ updates: opts.updates !== false,
36
38
  colorScheme: theme.colorScheme,
37
39
  lang,
38
40
  warn: QUOTA_WARN_PERCENT,
@@ -50,14 +52,14 @@ export function renderPage(opts) {
50
52
  <meta name="color-scheme" content="${scheme}">
51
53
  <meta name="referrer" content="no-referrer">
52
54
  <title>sideby</title>
53
- <link rel="icon" href="data:,">
55
+ <link rel="icon" type="image/svg+xml" href="${attr(opts.basePath)}/favicon.svg">
54
56
  <style${nonce}>${pageStyles(theme)}</style>
55
57
  </head>
56
58
  <body${theme.header === 'bar' ? ' class="header-bar"' : ''}>
57
59
  <header class="top">
58
60
  <div class="top-in">
59
61
  <div class="brand">
60
- <span class="logo" aria-hidden="true"><svg viewBox="0 0 40 40"><path d="M18.5 8A10.5 10.5 0 0 0 18.5 29Z"/><path d="M21.5 11A10.5 10.5 0 0 1 21.5 32Z" fill-opacity=".55"/></svg></span>
62
+ <span class="logo" aria-hidden="true">${MARK_SVG}</span>
61
63
  <div>
62
64
  <h1>sideby</h1>
63
65
  <p class="tagline" data-i18n="top.tagline">Every AI coding account, side by side.</p>
@@ -67,6 +69,7 @@ export function renderPage(opts) {
67
69
  <span id="updated" class="muted small">Loading…</span>
68
70
  <button id="lang" class="icon-btn lang-btn" type="button"${lang === 'auto' ? '' : ' hidden'}>${lang === 'zh' ? 'EN' : '中文'}</button>
69
71
  <button id="theme" class="icon-btn" type="button" hidden></button>
72
+ <button id="update" class="btn update-btn" type="button" hidden></button>
70
73
  <button id="handoff" class="btn" type="button" hidden></button>
71
74
  <button id="refresh" class="btn" type="button" aria-label="Refresh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg><span class="hide-sm" data-i18n="top.refresh">Refresh</span></button>
72
75
  </div>
@@ -87,7 +90,7 @@ export function renderPage(opts) {
87
90
  <div class="section-head"><h2 id="h-checkup" data-i18n="health.title">Health check</h2></div>
88
91
  <span class="sk sk-line w70"></span><span class="sk sk-line w50"></span>
89
92
  </section>
90
- <footer class="foot muted small">sideby <span id="ver"></span> · <span data-i18n="foot.text">runs on this machine; nothing leaves it.</span></footer>
93
+ <footer class="foot muted small"><button id="ver" class="link-btn" type="button">sideby</button> · <span id="foot-text" data-i18n="foot.text">runs on this machine; nothing leaves it.</span></footer>
91
94
  </main>
92
95
  <div id="conn" class="conn" role="status" hidden></div>
93
96
  <div id="toasts" class="toasts" aria-live="polite" popover="manual"></div>
@@ -246,11 +246,13 @@ export interface Runtime {
246
246
  /**
247
247
  * Where an Auto Handoff from `origin` would go now (spec §3.17 "选号"): every Account of every Family is a
248
248
  * candidate; `used` lists the chain's Accounts so far. `ahead` decides early what happens once the origin's fullest
249
- * window reaches the threshold, as plan.json and the Panel need. `to` names the only candidate (`handoff ready --to`).
249
+ * window reaches the threshold, as plan.json and the Panel need. `to` names the only candidate (`handoff ready --to`);
250
+ * `manual` (`handoff ready`) never waits for a reset.
250
251
  */
251
252
  handoffDecision(origin: Account, used: readonly string[], opts?: {
252
253
  ahead?: boolean;
253
254
  to?: string;
255
+ manual?: boolean;
254
256
  }): Promise<HandoffDecision>;
255
257
  readContext(): ReadContext;
256
258
  }
@@ -668,6 +668,12 @@ export async function createRuntime(opts = {}) {
668
668
  const self = candidates.find((c) => c.ref === origin.ref);
669
669
  if (!self)
670
670
  throw new UserError(`${origin.ref} is not an account sideby can find; run \`sideby list\``);
671
+ try {
672
+ self.resumesAtReset = (await familyOf(origin.family).handoff?.resumesAtReset?.(origin)) === true;
673
+ }
674
+ catch {
675
+ // cannot tell: do not wait
676
+ }
671
677
  return selectNext({
672
678
  origin: self,
673
679
  candidates,
@@ -677,6 +683,7 @@ export async function createRuntime(opts = {}) {
677
683
  now: now(),
678
684
  ...(opts.ahead ? { ahead: true } : {}),
679
685
  ...(opts.to !== undefined ? { to: opts.to } : {}),
686
+ ...(opts.manual ? { manual: true } : {}),
680
687
  });
681
688
  },
682
689
  readContext,
@@ -150,6 +150,11 @@ export interface FamilyHandoff {
150
150
  }): Promise<string | undefined>;
151
151
  /** One-time hook installation for a Host without a launch option (Grok); the same contract as `quotaSetup`. */
152
152
  hookSetup?: QuotaSetup;
153
+ /**
154
+ * Whether a session of this Account stopped at the usage limit continues by itself once the limit resets (Claude's
155
+ * `autoContinueAtUsageLimit`). Auto Handoff waits for a reset only when this says true; left out, it never waits.
156
+ */
157
+ resumesAtReset?(account: Account): Promise<boolean>;
153
158
  }
154
159
  /** Identity fields of a signed-in Account. */
155
160
  export interface AccountIdentity {
@@ -19,6 +19,8 @@ export async function withFakeHome(fn) {
19
19
  XDG_STATE_HOME: join(home, '.local', 'state'),
20
20
  PATH: `${bin}:/usr/bin:/bin`,
21
21
  NO_COLOR: '1',
22
+ // No Update Check from tests (spec §3.23): it is sideby's only network request. Delete it to test checks.
23
+ SIDEBY_NO_UPDATE_NOTIFIER: '1',
22
24
  };
23
25
  const h = {
24
26
  home,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.10.0",
3
+ "version": "0.12.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)",
@@ -77,5 +77,5 @@
77
77
  },
78
78
  "packageManager": "pnpm@10.15.0",
79
79
  "readmeFilename": "README.md",
80
- "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## 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## 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 ui # every account's quota at http://127.0.0.1:17420\n```\n\n`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\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## 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| Stop Claude sessions from connecting to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` (or the panel's banner) | [Remote Control](docs/guide/usage.md#remote-control) |\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| 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 leaves your machine: sideby calls no remote service.\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"
80
+ "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| Stop Claude sessions from connecting to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` (or the panel's banner) | [Remote Control](docs/guide/usage.md#remote-control) |\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"
81
81
  }