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.
- package/README.md +23 -16
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +9 -1
- package/dist/src/cli/commands.js +145 -5
- package/dist/src/cli/json-schemas.js +55 -1
- package/dist/src/cli/main.js +23 -7
- package/dist/src/core/app-profiles.d.ts +50 -0
- package/dist/src/core/app-profiles.js +206 -0
- package/dist/src/core/brand-mark.d.ts +29 -0
- package/dist/src/core/brand-mark.js +42 -0
- package/dist/src/core/claude-settings-file.d.ts +17 -0
- package/dist/src/core/claude-settings-file.js +53 -0
- package/dist/src/core/desktop-app.d.ts +18 -1
- package/dist/src/core/desktop-app.js +22 -12
- package/dist/src/core/handoff-chain.d.ts +16 -2
- package/dist/src/core/handoff-chain.js +19 -6
- package/dist/src/core/handoff-run.js +43 -18
- package/dist/src/core/handoff-select.d.ts +4 -0
- package/dist/src/core/handoff-select.js +3 -2
- package/dist/src/core/remote-control.d.ts +45 -0
- package/dist/src/core/remote-control.js +157 -0
- package/dist/src/core/tuning-apply.js +2 -49
- package/dist/src/desktop/icon.js +33 -33
- package/dist/src/families/claude/handoff.js +10 -0
- package/dist/src/families/desktop.d.ts +25 -0
- package/dist/src/families/desktop.js +8 -2
- package/dist/src/families/grok/bot.d.ts +8 -0
- package/dist/src/families/grok/bot.js +18 -0
- package/dist/src/handoff/hook.js +21 -4
- package/dist/src/handoff/messages.d.ts +4 -0
- package/dist/src/handoff/messages.js +12 -0
- package/dist/src/panel/handler.d.ts +9 -0
- package/dist/src/panel/handler.js +61 -2
- package/dist/src/panel/i18n.d.ts +46 -0
- package/dist/src/panel/i18n.js +92 -0
- package/dist/src/panel/page-script.js +205 -5
- package/dist/src/panel/page-styles.js +15 -0
- package/dist/src/panel/page.js +2 -1
- package/dist/src/runtime.d.ts +10 -1
- package/dist/src/runtime.js +17 -0
- package/dist/src/types.d.ts +5 -0
- package/package.json +2 -2
- package/schemas/desktop-remove.json +33 -0
- package/schemas/desktop.json +2 -1
- package/schemas/handoff-ready.json +4 -0
- package/schemas/list.json +47 -0
- package/schemas/remote-control.json +125 -0
- package/skills/sideby/SKILL.md +4 -1
- 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
|
-
|
|
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
|
-
|
|
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; }
|
package/dist/src/panel/page.js
CHANGED
|
@@ -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"
|
|
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>
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -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,
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/schemas/desktop.json
CHANGED
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": {
|