sideby 0.9.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +23 -16
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +9 -1
  4. package/dist/src/cli/commands.js +145 -5
  5. package/dist/src/cli/json-schemas.js +55 -1
  6. package/dist/src/cli/main.js +23 -7
  7. package/dist/src/core/app-profiles.d.ts +50 -0
  8. package/dist/src/core/app-profiles.js +206 -0
  9. package/dist/src/core/brand-mark.d.ts +29 -0
  10. package/dist/src/core/brand-mark.js +42 -0
  11. package/dist/src/core/claude-settings-file.d.ts +17 -0
  12. package/dist/src/core/claude-settings-file.js +53 -0
  13. package/dist/src/core/desktop-app.d.ts +18 -1
  14. package/dist/src/core/desktop-app.js +22 -12
  15. package/dist/src/core/handoff-chain.d.ts +16 -2
  16. package/dist/src/core/handoff-chain.js +19 -6
  17. package/dist/src/core/handoff-run.js +43 -18
  18. package/dist/src/core/handoff-select.d.ts +4 -0
  19. package/dist/src/core/handoff-select.js +3 -2
  20. package/dist/src/core/remote-control.d.ts +45 -0
  21. package/dist/src/core/remote-control.js +157 -0
  22. package/dist/src/core/tuning-apply.js +2 -49
  23. package/dist/src/desktop/icon.js +33 -33
  24. package/dist/src/families/claude/handoff.js +10 -0
  25. package/dist/src/families/desktop.d.ts +25 -0
  26. package/dist/src/families/desktop.js +8 -2
  27. package/dist/src/families/grok/bot.d.ts +8 -0
  28. package/dist/src/families/grok/bot.js +18 -0
  29. package/dist/src/handoff/hook.js +21 -4
  30. package/dist/src/handoff/messages.d.ts +4 -0
  31. package/dist/src/handoff/messages.js +12 -0
  32. package/dist/src/panel/handler.d.ts +9 -0
  33. package/dist/src/panel/handler.js +61 -2
  34. package/dist/src/panel/i18n.d.ts +46 -0
  35. package/dist/src/panel/i18n.js +92 -0
  36. package/dist/src/panel/page-script.js +205 -5
  37. package/dist/src/panel/page-styles.js +15 -0
  38. package/dist/src/panel/page.js +2 -1
  39. package/dist/src/runtime.d.ts +10 -1
  40. package/dist/src/runtime.js +17 -0
  41. package/dist/src/types.d.ts +5 -0
  42. package/package.json +2 -2
  43. package/schemas/desktop-remove.json +33 -0
  44. package/schemas/desktop.json +2 -1
  45. package/schemas/handoff-ready.json +4 -0
  46. package/schemas/list.json +47 -0
  47. package/schemas/remote-control.json +125 -0
  48. package/skills/sideby/SKILL.md +4 -1
  49. package/skills/sideby/references/handoff.md +1 -1
@@ -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 ----------
@@ -69,6 +69,7 @@ const MAIN = String.raw `
69
69
  for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
70
70
  if (raw.hideEmail === true) p.hideEmail = true
71
71
  if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
72
+ if (raw.rcHide === true) p.rcHide = true
72
73
  if (typeof raw.family === 'string') p.family = raw.family
73
74
  if (raw.collapsed && typeof raw.collapsed === 'object')
74
75
  for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
@@ -253,6 +254,7 @@ const MAIN = String.raw `
253
254
  check: 'M9 12l2 2 4-4M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
254
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',
255
256
  x: 'M6 6l12 12M18 6L6 18',
257
+ 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',
256
258
  grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
257
259
  list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
258
260
  clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
@@ -1067,6 +1069,147 @@ const MAIN = String.raw `
1067
1069
  const list = needing.map((a) => a.name + ': ' + whyOf(a, now)).join('\n')
1068
1070
  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))
1069
1071
  }
1072
+ // ---------- Grok Bot windows (ADR-0019) ----------
1073
+ // Windows of an app that signs in apart from every account, so they get a group of their own after the accounts.
1074
+ // A new window gets the next free three-digit name; removing one asks inside its row first.
1075
+ const APP_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
1076
+ // A plain bot glyph, not the grok mark: these windows are not grok (Grok Build) accounts.
1077
+ function appBadge() { return h('span', { class: 'badge badge-sm badge-logo', 'aria-hidden': 'true' }, icon('bot')) }
1078
+ function appProfiles() { return (S.state && S.state.appProfiles) || [] }
1079
+ function appId(ref) { return String(ref).slice(0, String(ref).indexOf(':')) }
1080
+ function nextAppName(list) {
1081
+ const taken = new Set(list.map((p) => p.name))
1082
+ for (let i = 1; i < 1000; i++) { const n = String(i).padStart(3, '0'); if (!taken.has(n)) return n }
1083
+ return ''
1084
+ }
1085
+ function appNameProblem(name) {
1086
+ if (!name) return t('app.nameEmpty')
1087
+ return APP_NAME.test(name) ? '' : t('app.nameBad')
1088
+ }
1089
+ function appRow(p) {
1090
+ const busy = ui.appBusy === p.ref
1091
+ const asking = ui.appRemove === p.ref
1092
+ const can = !BOOT.readOnly && p.installed
1093
+ const actions = !can ? null : asking
1094
+ ? h('div', { class: 'row-actions app-actions', role: 'group', 'aria-label': t('app.removeAsk', { ref: p.ref }) },
1095
+ h('span', { class: 'small' }, t('app.removeAsk', { ref: p.ref })),
1096
+ h('button', { class: 'btn btn-sm btn-danger', type: 'button', disabled: busy, onclick: () => removeApp(p) }, t(busy ? 'app.removing' : 'app.removeYes')),
1097
+ h('button', { class: 'btn btn-sm btn-ghost', type: 'button', disabled: busy, onclick: () => { ui.appRemove = null; renderAccounts() } }, t('btn.cancel')))
1098
+ : h('div', { class: 'row-actions app-actions' },
1099
+ 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) },
1100
+ busy ? t('desk.opening', { app: p.app }) : t(p.running ? 'app.show' : 'app.openBtn')),
1101
+ 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')))
1102
+ 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)]) },
1103
+ h('div', { class: 'ident' }, appBadge(), h('div', { class: 'ident-text' },
1104
+ h('div', { class: 'ident-name' },
1105
+ h('span', { class: 'name' }, p.isMain ? t('app.mainName') : p.name),
1106
+ p.running ? h('span', { class: 'tag tag-ok' }, h('span', { class: 'dot dot-ok', 'aria-hidden': 'true' }), t('app.isOpen')) : null,
1107
+ p.signedIn === true ? h('span', { class: 'tag', title: t('app.signedInHint') }, t('app.signedIn'))
1108
+ : p.signedIn === false ? h('span', { class: 'tag tag-warn', title: t('app.signedInHint') }, t('app.signedOut')) : null),
1109
+ h('div', { class: 'ident-sub' }, dots([h('span', { class: 'mono' }, p.ref), h('span', { class: 'muted' }, p.isMain ? t('app.mainData') : p.appData)])))),
1110
+ actions)
1111
+ }
1112
+ function appFoot(list) {
1113
+ if (BOOT.readOnly || !list[0].installed) return null
1114
+ const n = ui.appNew
1115
+ if (!n)
1116
+ return h('div', { class: 'app-foot', 'data-key': 'foot' },
1117
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(ui.appBusy), onclick: () => {
1118
+ ui.appNew = { name: nextAppName(list) }
1119
+ renderAccounts()
1120
+ const el = $('app-new-name')
1121
+ if (el) { el.focus(); el.select() }
1122
+ } }, icon('plus'), t('app.another', { app: list[0].app })),
1123
+ h('span', { class: 'muted small' }, t('app.anotherHint')))
1124
+ 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' })
1125
+ const err = h('span', { id: 'app-new-err', class: 'field-error', role: 'status' })
1126
+ 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'))
1127
+ const sync = () => {
1128
+ n.name = input.value.trim()
1129
+ const problem = appNameProblem(n.name)
1130
+ const exists = list.some((p) => p.name === n.name)
1131
+ err.textContent = problem && input.value ? problem : exists ? t('app.exists', { ref: appId(list[0].ref) + ':' + n.name }) : ''
1132
+ err.classList.toggle('field-note', !problem && exists)
1133
+ input.classList.toggle('invalid', Boolean(problem && input.value))
1134
+ go.disabled = Boolean(problem) || Boolean(ui.appBusy)
1135
+ }
1136
+ input.addEventListener('input', sync)
1137
+ input.addEventListener('keydown', (e) => {
1138
+ if (e.key === 'Enter' && !appNameProblem(n.name)) { e.preventDefault(); createApp(list) }
1139
+ if (e.key === 'Escape') { ui.appNew = null; renderAccounts() }
1140
+ })
1141
+ return h('div', { class: 'app-foot', 'data-key': 'foot-new' },
1142
+ h('label', { for: 'app-new-name', class: 'small' }, t('app.nameLabel')),
1143
+ 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'))),
1144
+ err,
1145
+ h('span', { class: 'muted small' }, t('app.newHint', { app: list[0].app })))
1146
+ }
1147
+ function appGroup(list) {
1148
+ const app = list[0].app
1149
+ const collapsed = prefs.collapsed['app:' + app] === true
1150
+ const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
1151
+ const head = h('div', { class: 'group-head', 'data-key': 'head' },
1152
+ 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: () => {
1153
+ if (collapsed) delete prefs.collapsed['app:' + app]; else prefs.collapsed['app:' + app] = true
1154
+ savePrefs()
1155
+ renderAccounts()
1156
+ } },
1157
+ icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
1158
+ h('span', { class: 'group-count' }, String(list.length))),
1159
+ h('div', { class: 'group-meta' },
1160
+ h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
1161
+ list[0].installed ? null : h('div', { class: 'group-meta' }, h('span', { class: 'tag tag-warn' }, t('acct.notInstalled'))))
1162
+ const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map(appRow))
1163
+ return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-patch': '' },
1164
+ head, body, collapsed ? null : appFoot(list))
1165
+ }
1166
+ // Shown with the account groups unless a family filter or the attention filter is on; the search matches names.
1167
+ function appGroups() {
1168
+ if (prefs.family || ui.attention) return []
1169
+ const q = ui.query.trim().toLowerCase()
1170
+ const out = []
1171
+ for (const app of new Set(appProfiles().map((p) => p.app))) {
1172
+ const all = appProfiles().filter((p) => p.app === app)
1173
+ const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
1174
+ if (list.length) out.push(appGroup(list))
1175
+ }
1176
+ return out
1177
+ }
1178
+ async function openApp(ref) {
1179
+ if (ui.appBusy) return
1180
+ ui.appBusy = ref
1181
+ renderAccounts()
1182
+ let ok = false
1183
+ try {
1184
+ const r = await api('/api/app-profile/open', { ref })
1185
+ 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 }) : ''
1186
+ if (text) toast(text)
1187
+ for (const m of r.notices || []) toast(m, { kind: 'info', duration: 12000 })
1188
+ ok = true
1189
+ } catch (e) { toast(t('app.failed', { ref, msg: e.message }), { kind: 'fail' }) }
1190
+ ui.appBusy = null
1191
+ if (ok) ui.appNew = null
1192
+ renderAccounts()
1193
+ load({ silent: true })
1194
+ }
1195
+ function createApp(list) {
1196
+ const n = ui.appNew
1197
+ if (!n || appNameProblem(n.name)) return
1198
+ openApp(appId(list[0].ref) + ':' + n.name)
1199
+ }
1200
+ async function removeApp(p) {
1201
+ if (ui.appBusy) return
1202
+ ui.appBusy = p.ref
1203
+ renderAccounts()
1204
+ try {
1205
+ await api('/api/app-profile/remove', { ref: p.ref })
1206
+ toast(t('app.removed', { ref: p.ref }))
1207
+ } catch (e) { toast(t('app.removeFailed', { ref: p.ref, msg: e.message }), { kind: 'fail' }) }
1208
+ ui.appBusy = null
1209
+ ui.appRemove = null
1210
+ renderAccounts()
1211
+ load({ silent: true })
1212
+ }
1070
1213
  function group(fam, list, total, now, cols) {
1071
1214
  const info = family(fam)
1072
1215
  const filtering = Boolean(ui.query.trim()) || ui.attention
@@ -1103,7 +1246,8 @@ const MAIN = String.raw `
1103
1246
  function emptyState() {
1104
1247
  const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
1105
1248
  return h('div', { class: 'empty' },
1106
- h('span', { class: 'logo', 'aria-hidden': 'true' }, h('i'), h('i')),
1249
+ // The header's mark, cloned (page.ts draws it from core/brand-mark.ts).
1250
+ h('span', { class: 'logo', 'aria-hidden': 'true' }, document.querySelector('.brand .logo svg')?.cloneNode(true) ?? null),
1107
1251
  h('h3', null, t('empty.title')),
1108
1252
  h('p', { class: 'muted' }, t('empty.body')),
1109
1253
  fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
@@ -1144,13 +1288,14 @@ const MAIN = String.raw `
1144
1288
  const all = S.state.accounts
1145
1289
  const now = Date.now()
1146
1290
  $('toolbar').hidden = !all.length
1147
- if (!all.length) { setKids(count, null); patch(box, emptyState()); return }
1291
+ if (!all.length) { setKids(count, null); patch(box, [emptyState()].concat(appGroups())); return }
1148
1292
  const shown = all.filter((a) => (!prefs.family || a.family === prefs.family) && matchesQuery(a, ui.query, famTitle(a)) && (!ui.attention || reasonsOf(a, now).length))
1149
1293
  const filtered = shown.length !== all.length
1150
1294
  patch(count, filtered
1151
1295
  ? [t('count.filtered', { n: shown.length, total: all.length }), ' ', h('button', { class: 'link-btn', type: 'button', onclick: clearFilters }, t('filter.clear'))]
1152
1296
  : tn('count.accounts', all.length))
1153
- if (!shown.length) { patch(box, noMatch()); return }
1297
+ const apps = appGroups()
1298
+ if (!shown.length && !apps.length) { patch(box, noMatch()); return }
1154
1299
  const cols = quotaColumns(shown)
1155
1300
  box.style.setProperty('--qn', String(cols.length))
1156
1301
  const ids = S.state.families.map((f) => f.id)
@@ -1160,7 +1305,7 @@ const MAIN = String.raw `
1160
1305
  const list = shown.filter((a) => a.family === fam)
1161
1306
  if (list.length) groups.push(group(fam, sortAccounts(list, prefs.sort, now), all.filter((a) => a.family === fam).length, now, cols))
1162
1307
  }
1163
- patch(box, groups)
1308
+ patch(box, groups.concat(apps))
1164
1309
  if (ui.fade && !REDUCED) {
1165
1310
  box.classList.add('view-in')
1166
1311
  setTimeout(() => box.classList.remove('view-in'), 400)
@@ -2380,6 +2525,59 @@ const MAIN = String.raw `
2380
2525
  setKids(box, kids)
2381
2526
  }
2382
2527
 
2528
+ // ---------- Remote Control (spec §3.22) ----------
2529
+ // Shown while some Claude settings file turns Remote Control on. "Turn all off" first lists each file, the change
2530
+ // and the accounts it reaches, in the banner itself; the toast after it carries the undo.
2531
+ const rc = { confirm: false, busy: false }
2532
+ const shortRef = (ref) => ref.replace(/^claude:/, '')
2533
+ function remoteControlBanner() {
2534
+ const groups = S.state.remoteControl || []
2535
+ // API Accounts never sign in to claude.ai, so Remote Control cannot reach their sessions.
2536
+ const api = new Set(S.state.accounts.filter((a) => a.kind === 'api').map((a) => a.ref))
2537
+ const refs = groups.filter((g) => g.state === 'on').flatMap((g) => g.accounts).filter((r) => !api.has(r)).map(shortRef)
2538
+ if (!refs.length || prefs.rcHide) return null
2539
+ const todo = groups.filter((g) => g.state === 'on' || g.state === 'default')
2540
+ const text = h('div', null, h('b', null, t('rc.title') + ' '), tn('rc.body', refs.length, { refs: refs.join(', ') }))
2541
+ if (BOOT.readOnly) return h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null, text, ' ', t('rc.readOnly')))
2542
+ if (!rc.confirm)
2543
+ return h('div', { class: 'banner banner-warn', role: 'status' }, text,
2544
+ h('div', { class: 'banner-actions' },
2545
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => { rc.confirm = true; renderBanners() } }, t('rc.turnOff')),
2546
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.rcHide = true; savePrefs(); renderBanners(); toast(t('rc.hidden')) } }, t('rc.keep'))))
2547
+ const lines = todo.map((g) => h('li', null, h('code', null, g.file), ' ',
2548
+ t('rc.change', { from: g.state === 'on' ? 'true' : t('rc.notSet') }), ' ',
2549
+ h('span', { class: 'muted' }, t('rc.usedBy', { refs: g.accounts.map(shortRef).join(', ') }))))
2550
+ return h('div', { class: 'banner banner-warn', role: 'status' },
2551
+ h('div', null, h('b', null, t('rc.confirmTitle')), h('ul', null, lines), h('div', { class: 'small' }, t('rc.confirmNote'))),
2552
+ h('div', { class: 'banner-actions' },
2553
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: rc.busy, onclick: remoteControlOff }, rc.busy ? spinner() : null, t('rc.confirm')),
2554
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: rc.busy, onclick: () => { rc.confirm = false; renderBanners() } }, t('btn.cancel'))))
2555
+ }
2556
+ async function remoteControlOff() {
2557
+ rc.busy = true
2558
+ renderBanners()
2559
+ try {
2560
+ const r = await api('/api/remote-control/off', {})
2561
+ if (r.ok) {
2562
+ // Hide the banner now; the reload that follows confirms it.
2563
+ S.state.remoteControl = (S.state.remoteControl || []).map((g) => g.state === 'on' || g.state === 'default' ? { ...g, state: 'off', undo: true } : g)
2564
+ toast(t('rc.done'), { action: { label: t('toast.undo'), run: remoteControlUndo }, duration: 12000 })
2565
+ }
2566
+ else toast(t('rc.failed', { msg: r.message }), { kind: 'fail' })
2567
+ } catch (e) { toast(t('rc.failed', { msg: e.message }), { kind: 'fail' }) }
2568
+ rc.busy = false
2569
+ rc.confirm = false
2570
+ renderBanners()
2571
+ await load({ silent: true })
2572
+ }
2573
+ async function remoteControlUndo() {
2574
+ try {
2575
+ const r = await api('/api/remote-control/undo', {})
2576
+ toast(r.ok ? t('rc.undone') : r.message, { kind: r.ok ? 'ok' : 'fail' })
2577
+ } catch (e) { toast(t('toast.undoFailed', { msg: e.message }), { kind: 'fail' }) }
2578
+ await load({ silent: true })
2579
+ }
2580
+
2383
2581
  // ---------- banners ----------
2384
2582
  function renderBanners() {
2385
2583
  const out = []
@@ -2394,6 +2592,8 @@ const MAIN = String.raw `
2394
2592
  h('div', { class: 'banner-actions' },
2395
2593
  h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => setPage('handoff') }, t('ah.setUp')),
2396
2594
  h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
2595
+ const rcBanner = remoteControlBanner()
2596
+ if (rcBanner) out.push(rcBanner)
2397
2597
  const errs = S.state.pluginErrors
2398
2598
  if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
2399
2599
  h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
@@ -263,6 +263,7 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
263
263
  .badge svg { display: block; width: 20px; height: 20px; fill: currentColor; }
264
264
  .badge-sm { width: 26px; height: 26px; border-radius: 7px; font-size: 10.5px; }
265
265
  .badge-sm svg { width: 16px; height: 16px; }
266
+ .badge svg.ic { fill: none; stroke: currentColor; }
266
267
  .badge-xs { width: 16px; height: 16px; border-radius: 4px; border: 0; background: none; }
267
268
  .badge-xs svg { width: 13px; height: 13px; }
268
269
  .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 +323,8 @@ a.btn:hover { text-decoration: none; }
322
323
  .btn svg { width: 15px; height: 15px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
323
324
  .btn:disabled { background: var(--surface-2); border-color: var(--border); color: var(--faint); cursor: not-allowed; }
324
325
  .btn-ghost:disabled { background: transparent; border-color: transparent; }
326
+ .btn-danger { border-color: var(--fail); color: var(--fail); }
327
+ .btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--fail) 10%, transparent); border-color: var(--fail); color: var(--fail); }
325
328
  /* A busy button is disabled but keeps its variant, so a running action does not look unavailable. */
326
329
  .btn[aria-busy=true]:disabled { cursor: progress; color: var(--muted); }
327
330
  .btn-primary[aria-busy=true]:disabled { background: var(--primary); border-color: var(--primary-border); color: var(--on-primary); opacity: .85; }
@@ -433,6 +436,16 @@ select:is(:hover, :focus) { --chevron: var(--text); }
433
436
  .field-hint { color: var(--muted); font-size: var(--small); }
434
437
  .field-hint:empty { display: none; }
435
438
  .field-error:empty { display: none; }
439
+ .field-error.field-note { color: var(--muted); }
440
+ /* Grok Bot windows (ADR-0019): a simple row of their own, since they have no quota, usage or health columns. */
441
+ .app-row { display: flex; align-items: center; gap: 12px; padding: 11px 10px 11px 16px; border-top: 1px solid var(--border); min-width: 0; }
442
+ .app-row:first-child { border-top: 0; }
443
+ .app-row > .ident { flex: 1; }
444
+ .app-actions { flex: none; justify-content: flex-end; }
445
+ .app-foot { display: flex; flex-direction: column; gap: 6px; padding: 10px 16px 12px; border-top: 1px solid var(--border); }
446
+ .app-foot > .btn { align-self: flex-start; }
447
+ .app-new { display: flex; gap: 8px; align-items: center; max-width: 420px; }
448
+ .app-new input { height: var(--ctl-h-sm); flex: 1; min-width: 0; }
436
449
  .fam-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
437
450
  .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
451
  .fam-card:hover { border-color: var(--btn-hover-border); }
@@ -524,6 +537,8 @@ select:is(:hover, :focus) { --chevron: var(--text); }
524
537
 
525
538
  @media (max-width: 1100px) {
526
539
  .col-head { display: none; }
540
+ .app-row { flex-wrap: wrap; }
541
+ .app-actions { width: 100%; justify-content: flex-start; }
527
542
  .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
543
  .row-folded { grid-template-areas: "id id more"; }
529
544
  .row > .quota { grid-template-columns: repeat(var(--qn), minmax(0, 1fr)); max-width: 560px; }
@@ -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';
@@ -57,7 +58,7 @@ export function renderPage(opts) {
57
58
  <header class="top">
58
59
  <div class="top-in">
59
60
  <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>
61
+ <span class="logo" aria-hidden="true">${MARK_SVG}</span>
61
62
  <div>
62
63
  <h1>sideby</h1>
63
64
  <p class="tagline" data-i18n="top.tagline">Every AI coding account, side by side.</p>
@@ -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 RemoteControlChange, type RemoteControlGroup } from './core/remote-control.ts';
12
13
  import { type Shell, type ShellInitWrite } from './core/shell-init.ts';
13
14
  import { type TuneEntry, type TuneGroup } from './core/tuning.ts';
14
15
  import { type TuneApplyPlan, type TuneChange, type TuneUndoRecord } from './core/tuning-apply.ts';
@@ -227,6 +228,12 @@ export interface Runtime {
227
228
  }>;
228
229
  /** What could be undone for the Account, or null. */
229
230
  tuneUndoInfo(account: Account): Promise<TuneUndoRecord | null>;
231
+ /** Whether each Claude settings file starts Remote Control by itself (spec §3.22, ADR-0017); grouped by file. */
232
+ remoteControl(): Promise<RemoteControlGroup[]>;
233
+ /** Sets `remoteControlAtStartup` to false wherever it is on or left to the default, remembering how to undo it. */
234
+ remoteControlOff(): Promise<RemoteControlChange>;
235
+ /** Puts back what `remoteControlOff` replaced, only where the key still holds false. */
236
+ remoteControlUndo(): Promise<RemoteControlChange>;
230
237
  /**
231
238
  * Which Account of a Family to move on to (spec §3.13). Throws a UserError with code `no-quota-source`
232
239
  * when the Family cannot tell, `no-accounts` when it has none.
@@ -239,11 +246,13 @@ export interface Runtime {
239
246
  /**
240
247
  * Where an Auto Handoff from `origin` would go now (spec §3.17 "选号"): every Account of every Family is a
241
248
  * candidate; `used` lists the chain's Accounts so far. `ahead` decides early what happens once the origin's fullest
242
- * 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.
243
251
  */
244
252
  handoffDecision(origin: Account, used: readonly string[], opts?: {
245
253
  ahead?: boolean;
246
254
  to?: string;
255
+ manual?: boolean;
247
256
  }): Promise<HandoffDecision>;
248
257
  readContext(): ReadContext;
249
258
  }
@@ -13,6 +13,7 @@ import { mergeDoctorHistory, readDoctorHistory, writeDoctorHistory, } from './co
13
13
  import { 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 { remoteControlGroups, turnRemoteControlOff, undoRemoteControlOff, } from './core/remote-control.js';
16
17
  import { findResumeRoute } from './core/resume.js';
17
18
  import { SAME_LOGIN_FAMILIES, sameLoginGroups, sharedQuota } from './core/same-login.js';
18
19
  import { shareMeaning } from './core/share-meaning.js';
@@ -574,6 +575,15 @@ export async function createRuntime(opts = {}) {
574
575
  onlyClaude(account);
575
576
  return undoTune(account, readContext());
576
577
  },
578
+ async remoteControl() {
579
+ return remoteControlGroups(await accounts(), readContext());
580
+ },
581
+ async remoteControlOff() {
582
+ return turnRemoteControlOff(await accounts(), readContext());
583
+ },
584
+ async remoteControlUndo() {
585
+ return undoRemoteControlOff(await accounts(), readContext());
586
+ },
577
587
  async tuneUndoInfo(account) {
578
588
  return account.family === 'claude' ? tuneUndoInfo(account, readContext()) : null;
579
589
  },
@@ -658,6 +668,12 @@ export async function createRuntime(opts = {}) {
658
668
  const self = candidates.find((c) => c.ref === origin.ref);
659
669
  if (!self)
660
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
+ }
661
677
  return selectNext({
662
678
  origin: self,
663
679
  candidates,
@@ -667,6 +683,7 @@ export async function createRuntime(opts = {}) {
667
683
  now: now(),
668
684
  ...(opts.ahead ? { ahead: true } : {}),
669
685
  ...(opts.to !== undefined ? { to: opts.to } : {}),
686
+ ...(opts.manual ? { manual: true } : {}),
670
687
  });
671
688
  },
672
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.9.3",
3
+ "version": "0.11.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| 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 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 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"
81
81
  }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "sideby/desktop-remove.json",
4
+ "type": "object",
5
+ "required": [
6
+ "schemaVersion",
7
+ "ref",
8
+ "app",
9
+ "appData",
10
+ "removed"
11
+ ],
12
+ "properties": {
13
+ "schemaVersion": {
14
+ "type": "number",
15
+ "const": 1
16
+ },
17
+ "ref": {
18
+ "type": "string"
19
+ },
20
+ "app": {
21
+ "type": "string"
22
+ },
23
+ "appData": {
24
+ "type": "string",
25
+ "description": "The folder that is (or would be) deleted."
26
+ },
27
+ "removed": {
28
+ "type": "boolean",
29
+ "description": "False when only the plan was shown; run again with --yes (exit 10)."
30
+ }
31
+ },
32
+ "title": "sideby desktop grok-bot:<name> --remove --json"
33
+ }
@@ -22,7 +22,8 @@
22
22
  "type": "string"
23
23
  },
24
24
  "family": {
25
- "type": "string"
25
+ "type": "string",
26
+ "description": "The family, or `grok-bot` for a Grok Bot window."
26
27
  },
27
28
  "app": {
28
29
  "type": "string",
@@ -20,6 +20,10 @@
20
20
  },
21
21
  "brief": {
22
22
  "type": "string"
23
+ },
24
+ "now": {
25
+ "type": "boolean",
26
+ "const": true
23
27
  }
24
28
  },
25
29
  "title": "sideby handoff ready --json"
package/schemas/list.json CHANGED
@@ -185,6 +185,53 @@
185
185
  ]
186
186
  }
187
187
  },
188
+ "appProfiles": {
189
+ "type": "array",
190
+ "items": {
191
+ "type": "object",
192
+ "required": [
193
+ "ref",
194
+ "app",
195
+ "name",
196
+ "isMain",
197
+ "installed",
198
+ "running"
199
+ ],
200
+ "properties": {
201
+ "ref": {
202
+ "type": "string",
203
+ "description": "`grok-bot:<name>`; `grok-bot:main` is the app's own default data."
204
+ },
205
+ "app": {
206
+ "type": "string",
207
+ "description": "The app, for example `Grok Bot`."
208
+ },
209
+ "name": {
210
+ "type": "string"
211
+ },
212
+ "isMain": {
213
+ "type": "boolean"
214
+ },
215
+ "appData": {
216
+ "type": "string",
217
+ "description": "This window's data folder; absent for the main one, which uses the app's default."
218
+ },
219
+ "installed": {
220
+ "type": "boolean"
221
+ },
222
+ "running": {
223
+ "type": "boolean",
224
+ "description": "This window's own instance runs now."
225
+ },
226
+ "signedIn": {
227
+ "type": "boolean",
228
+ "description": "What the app last wrote about its sign-in (it keeps the last value after the app quits); absent when unknown. Which account is signed in is not known."
229
+ }
230
+ },
231
+ "description": "A window of a desktop app that signs in apart from every account (Grok Bot, ADR-0019). Open or create one with `sideby desktop grok-bot:<name>`."
232
+ },
233
+ "description": "Grok Bot windows (macOS, when Grok Bot is installed or has windows), the main one first. Not accounts."
234
+ },
188
235
  "pluginErrors": {
189
236
  "type": "array",
190
237
  "items": {