sideby 0.5.0 → 0.6.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 +2 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +5 -0
- package/dist/src/cli/commands.js +67 -0
- package/dist/src/cli/json-schemas.js +30 -0
- package/dist/src/cli/main.js +5 -0
- package/dist/src/core/config.d.ts +14 -0
- package/dist/src/core/config.js +9 -0
- package/dist/src/core/model-choice.d.ts +41 -0
- package/dist/src/core/model-choice.js +128 -0
- package/dist/src/families/claude/index.js +2 -0
- package/dist/src/families/claude/model.d.ts +2 -0
- package/dist/src/families/claude/model.js +42 -0
- package/dist/src/families/claude/usage.d.ts +2 -0
- package/dist/src/families/claude/usage.js +13 -1
- package/dist/src/families/codex/index.js +2 -0
- package/dist/src/families/codex/model.d.ts +2 -0
- package/dist/src/families/codex/model.js +59 -0
- package/dist/src/families/codex/rollout.d.ts +5 -0
- package/dist/src/families/codex/rollout.js +45 -3
- package/dist/src/families/shared/model-args.d.ts +7 -0
- package/dist/src/families/shared/model-args.js +28 -0
- package/dist/src/panel/handler.d.ts +3 -0
- package/dist/src/panel/handler.js +39 -0
- package/dist/src/panel/i18n.d.ts +19 -0
- package/dist/src/panel/i18n.js +38 -0
- package/dist/src/panel/page-script.js +114 -0
- package/dist/src/panel/page-styles.js +5 -1
- package/dist/src/types.d.ts +29 -0
- package/package.json +3 -2
- package/schemas/model.json +169 -0
- package/schemas/quota.json +6 -0
- package/skills/sideby/SKILL.md +2 -1
|
@@ -174,6 +174,42 @@ export function fileTokens(events) {
|
|
|
174
174
|
return null;
|
|
175
175
|
return fileDeltas(events).reduce(plus, ZERO);
|
|
176
176
|
}
|
|
177
|
+
/** The newest `turn_context` of a rollout: the model and effort its last turn ran with. */
|
|
178
|
+
async function lastContext(file, read) {
|
|
179
|
+
const lines = createInterface({ input: createReadStream(file), crlfDelay: Number.POSITIVE_INFINITY });
|
|
180
|
+
let best = null;
|
|
181
|
+
try {
|
|
182
|
+
for await (const line of lines) {
|
|
183
|
+
if (!line.includes('turn_context'))
|
|
184
|
+
continue;
|
|
185
|
+
let o;
|
|
186
|
+
try {
|
|
187
|
+
o = JSON.parse(line);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!isObj(o) || o.type !== 'turn_context' || !isObj(o.payload))
|
|
193
|
+
continue;
|
|
194
|
+
const at = typeof o.timestamp === 'string' ? Date.parse(o.timestamp) : Number.NaN;
|
|
195
|
+
if (!Number.isFinite(at) || (best && at < best.at))
|
|
196
|
+
continue;
|
|
197
|
+
const { model, effort } = o.payload;
|
|
198
|
+
best = {
|
|
199
|
+
at,
|
|
200
|
+
...(typeof model === 'string' && model ? { model } : {}),
|
|
201
|
+
...(typeof effort === 'string' && effort ? { effort } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
read.failed = true;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
lines.close();
|
|
210
|
+
}
|
|
211
|
+
return best;
|
|
212
|
+
}
|
|
177
213
|
async function fileUsage(file) {
|
|
178
214
|
const read = { failed: false };
|
|
179
215
|
const times = [];
|
|
@@ -184,10 +220,11 @@ async function fileUsage(file) {
|
|
|
184
220
|
if (t)
|
|
185
221
|
events.push({ at, ...t });
|
|
186
222
|
}
|
|
187
|
-
|
|
223
|
+
const context = await lastContext(file, read);
|
|
224
|
+
return { times, events, deltas: fileDeltas(events), context, failed: read.failed };
|
|
188
225
|
}
|
|
189
226
|
/** Parsed token totals per Account directory; a rollout is read again only after it changed. */
|
|
190
|
-
export const codexUsageMemo = new FileMemo({ name: 'codex-usage', version:
|
|
227
|
+
export const codexUsageMemo = new FileMemo({ name: 'codex-usage', version: 2 });
|
|
191
228
|
/**
|
|
192
229
|
* Token totals for events in the last `days` x 24 h, from rollout files modified in that window (mtime only
|
|
193
230
|
* picks which files to read). Each event counts its fileDeltas increment, so a long session resumed today
|
|
@@ -207,13 +244,16 @@ export async function readCodexUsage(dir, now, days = USAGE_DAYS) {
|
|
|
207
244
|
let sawTokenCount = false;
|
|
208
245
|
let sawTotals = false;
|
|
209
246
|
let last = Number.NEGATIVE_INFINITY;
|
|
247
|
+
let lastContextSeen = null;
|
|
210
248
|
const daily = dailyBuckets(now, days);
|
|
211
249
|
const sum = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
212
250
|
const memo = codexUsageMemo.pass(dir);
|
|
213
251
|
for (const f of files) {
|
|
214
|
-
const { times, events, deltas } = await memo.get(f.path, f.stats, () => fileUsage(f.path), (v) => !v.failed);
|
|
252
|
+
const { times, events, deltas, context } = await memo.get(f.path, f.stats, () => fileUsage(f.path), (v) => !v.failed);
|
|
215
253
|
if (times.length)
|
|
216
254
|
sawTokenCount = true;
|
|
255
|
+
if (context?.model && context.at <= end && (!lastContextSeen || context.at >= lastContextSeen.at))
|
|
256
|
+
lastContextSeen = context;
|
|
217
257
|
for (const at of times)
|
|
218
258
|
if (at <= end && at > last)
|
|
219
259
|
last = at;
|
|
@@ -250,5 +290,7 @@ export async function readCodexUsage(dir, now, days = USAGE_DAYS) {
|
|
|
250
290
|
totalTokens: sum.inputTokens + sum.outputTokens + sum.cacheReadTokens + sum.cacheWriteTokens,
|
|
251
291
|
daily: daily.daily,
|
|
252
292
|
...(Number.isFinite(last) ? { lastActivityAt: new Date(last).toISOString() } : {}),
|
|
293
|
+
...(lastContextSeen?.model ? { lastModel: lastContextSeen.model } : {}),
|
|
294
|
+
...(lastContextSeen?.effort ? { lastEffort: lastContextSeen.effort } : {}),
|
|
253
295
|
};
|
|
254
296
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Takes every occurrence of the flags in `names` out of `args`; the last value wins. */
|
|
2
|
+
export declare function takeFlag(args: readonly string[], names: readonly string[]): {
|
|
3
|
+
value?: string;
|
|
4
|
+
rest: string[];
|
|
5
|
+
};
|
|
6
|
+
/** A model name sideby passes on: letters, digits and `._:/[]-` (for example `claude-opus-5-5[1m]`). */
|
|
7
|
+
export declare const MODEL_NAME: RegExp;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Reading and writing a model or effort choice as Host arguments (spec §3.19): the value of `--name v`, `--name=v`
|
|
2
|
+
// or `-x v`, wherever it is. Built-in Families use it for `modelChoice`.
|
|
3
|
+
/** Takes every occurrence of the flags in `names` out of `args`; the last value wins. */
|
|
4
|
+
export function takeFlag(args, names) {
|
|
5
|
+
const rest = [];
|
|
6
|
+
let value;
|
|
7
|
+
for (let i = 0; i < args.length; i++) {
|
|
8
|
+
const a = args[i];
|
|
9
|
+
// Past `--` everything is the Host's prompt text, not an option.
|
|
10
|
+
if (a === '--') {
|
|
11
|
+
rest.push(...args.slice(i));
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
const eq = names.find((n) => n.startsWith('--') && a.startsWith(`${n}=`));
|
|
15
|
+
if (eq) {
|
|
16
|
+
value = a.slice(eq.length + 1);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (names.includes(a) && i + 1 < args.length) {
|
|
20
|
+
value = args[++i];
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
rest.push(a);
|
|
24
|
+
}
|
|
25
|
+
return value === undefined ? { rest } : { value, rest };
|
|
26
|
+
}
|
|
27
|
+
/** A model name sideby passes on: letters, digits and `._:/[]-` (for example `claude-opus-5-5[1m]`). */
|
|
28
|
+
export const MODEL_NAME = /^[A-Za-z0-9._:/[\]-]{1,100}$/;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type HandoffSettings } from '../core/config.ts';
|
|
3
|
+
import { type AccountModel } from '../core/model-choice.ts';
|
|
3
4
|
import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
|
|
4
5
|
import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
|
|
5
6
|
import { type PanelTheme } from './theme.ts';
|
|
@@ -47,6 +48,8 @@ export type PanelAccount = (AccountStatus | (Account & Partial<AccountStatus>))
|
|
|
47
48
|
aliases?: string[];
|
|
48
49
|
/** Host arguments of those aliases that add any (alias → arguments); absent when none do. */
|
|
49
50
|
aliasArgs?: Record<string, string[]>;
|
|
51
|
+
/** The model and effort it starts with and where each comes from (spec §3.19); absent for Families without a choice. */
|
|
52
|
+
modelInfo?: Omit<AccountModel, 'ref' | 'family'>;
|
|
50
53
|
};
|
|
51
54
|
export type { DoctorHistory };
|
|
52
55
|
/** Counts of failing and warning Findings per checked Account, from the Doctor history. */
|
|
@@ -7,6 +7,7 @@ import { planHandoff } from '../core/handoff.js';
|
|
|
7
7
|
import { applyHandoffEnable, planHandoffEnable } from '../core/handoff-enable.js';
|
|
8
8
|
import { handoffOverview } from '../core/handoff-overview.js';
|
|
9
9
|
import { configureHandoff, handoffStatus } from '../core/handoff-status.js';
|
|
10
|
+
import { accountModel, setAccountModel } from '../core/model-choice.js';
|
|
10
11
|
import { tildify } from '../core/paths.js';
|
|
11
12
|
import { MIN_CALLS } from '../core/tuning.js';
|
|
12
13
|
import { buildId, packageVersion } from '../core/version.js';
|
|
@@ -170,8 +171,23 @@ async function accountState(rt, account, families) {
|
|
|
170
171
|
login: 'unknown',
|
|
171
172
|
};
|
|
172
173
|
errors.push(...(status?.problems ?? []));
|
|
174
|
+
let modelInfo;
|
|
175
|
+
if (rt.families.get(account.family)?.modelChoice) {
|
|
176
|
+
const last = usage?.status === 'ok'
|
|
177
|
+
? {
|
|
178
|
+
...(usage.lastModel ? { model: usage.lastModel } : {}),
|
|
179
|
+
...(usage.lastEffort ? { effort: usage.lastEffort } : {}),
|
|
180
|
+
}
|
|
181
|
+
: {};
|
|
182
|
+
const info = await guard(accountModel(rt, account, last));
|
|
183
|
+
if (info) {
|
|
184
|
+
const { ref: _r, family: _f, ...rest } = info;
|
|
185
|
+
modelInfo = rest;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
173
188
|
return {
|
|
174
189
|
...base,
|
|
190
|
+
...(modelInfo ? { modelInfo } : {}),
|
|
175
191
|
...(quota ? { quota } : {}),
|
|
176
192
|
...(usage ? { usage } : {}),
|
|
177
193
|
...(errors.length ? { error: errors.join('; ') } : {}),
|
|
@@ -395,6 +411,29 @@ export function createPanelHandler(opts) {
|
|
|
395
411
|
return { ...r, file: tildify(r.file, rt.paths.home) };
|
|
396
412
|
},
|
|
397
413
|
},
|
|
414
|
+
// Model and effort per Account (spec §3.19, ADR-0014): without `confirm` only the config diff.
|
|
415
|
+
// `model` / `effort`: left out keeps it, null goes back to the Host's default, a string sets it.
|
|
416
|
+
'/api/account/model': {
|
|
417
|
+
writes: (body) => body.confirm === true,
|
|
418
|
+
async run(body) {
|
|
419
|
+
const account = optString(body, 'account');
|
|
420
|
+
if (!account)
|
|
421
|
+
throw new HttpError(400, '"account" is required');
|
|
422
|
+
const change = {};
|
|
423
|
+
for (const k of ['model', 'effort']) {
|
|
424
|
+
const v = body[k];
|
|
425
|
+
if (v === undefined)
|
|
426
|
+
continue;
|
|
427
|
+
if (v !== null && typeof v !== 'string')
|
|
428
|
+
throw new HttpError(400, `"${k}" must be a string, or null for the default`);
|
|
429
|
+
change[k] = v;
|
|
430
|
+
}
|
|
431
|
+
const rt = await getRuntime();
|
|
432
|
+
const found = await rt.resolve(account);
|
|
433
|
+
const r = await setAccountModel(rt, found, change, { apply: optBool(body, 'confirm') });
|
|
434
|
+
return { ref: found.ref, ...r, file: tildify(r.file, rt.paths.home) };
|
|
435
|
+
},
|
|
436
|
+
},
|
|
398
437
|
// The Auto Handoff page (spec §3.17): where work would go next and the last hand-overs. Reads only.
|
|
399
438
|
'/api/handoff/overview': {
|
|
400
439
|
writes: () => false,
|
package/dist/src/panel/i18n.d.ts
CHANGED
|
@@ -233,6 +233,25 @@ declare const en: {
|
|
|
233
233
|
'menu.check': string;
|
|
234
234
|
'menu.like': string;
|
|
235
235
|
'menu.tune': string;
|
|
236
|
+
'menu.model': string;
|
|
237
|
+
'md.title': string;
|
|
238
|
+
'md.intro': string;
|
|
239
|
+
'md.model': string;
|
|
240
|
+
'md.effort': string;
|
|
241
|
+
'md.default': string;
|
|
242
|
+
'md.decides': string;
|
|
243
|
+
'md.from.sideby': string;
|
|
244
|
+
'md.from.host': string;
|
|
245
|
+
'md.from.last': string;
|
|
246
|
+
'md.none': string;
|
|
247
|
+
'md.click': string;
|
|
248
|
+
'md.envNote': string;
|
|
249
|
+
'md.reset': string;
|
|
250
|
+
'md.review': string;
|
|
251
|
+
'md.apply': string;
|
|
252
|
+
'md.nothing': string;
|
|
253
|
+
'md.changes': string;
|
|
254
|
+
'md.saved': string;
|
|
236
255
|
'page.handoff': string;
|
|
237
256
|
'hp.title': string;
|
|
238
257
|
'hp.advanced': string;
|
package/dist/src/panel/i18n.js
CHANGED
|
@@ -235,6 +235,25 @@ const en = {
|
|
|
235
235
|
'menu.check': 'Check this account',
|
|
236
236
|
'menu.like': 'New account like this',
|
|
237
237
|
'menu.tune': 'Context tuning',
|
|
238
|
+
'menu.model': 'Model and effort…',
|
|
239
|
+
'md.title': 'Model and effort',
|
|
240
|
+
'md.intro': 'Applies to new sessions of {ref} that you start with sideby (a short command or sideby run). It is saved in sideby’s config; {host}’s own settings stay as they are.',
|
|
241
|
+
'md.model': 'Model',
|
|
242
|
+
'md.effort': 'Effort',
|
|
243
|
+
'md.default': 'Default',
|
|
244
|
+
'md.decides': '{host} decides',
|
|
245
|
+
'md.from.sideby': 'set by sideby for this account',
|
|
246
|
+
'md.from.host': 'from {host}’s settings',
|
|
247
|
+
'md.from.last': 'used in the last session',
|
|
248
|
+
'md.none': 'not known yet',
|
|
249
|
+
'md.click': 'Click to change.',
|
|
250
|
+
'md.envNote': 'ANTHROPIC_MODEL or CLAUDE_CODE_EFFORT_LEVEL in your shell, and settings from your organization, override this; sideby cannot see them.',
|
|
251
|
+
'md.reset': 'Back to default',
|
|
252
|
+
'md.review': 'Review change',
|
|
253
|
+
'md.apply': 'Apply',
|
|
254
|
+
'md.nothing': 'Nothing to change.',
|
|
255
|
+
'md.changes': 'This changes sideby’s config:',
|
|
256
|
+
'md.saved': 'Saved. New sessions of {ref} use it.',
|
|
238
257
|
'page.handoff': 'Auto Handoff',
|
|
239
258
|
'hp.title': 'Auto Handoff',
|
|
240
259
|
'hp.advanced': 'Advanced settings',
|
|
@@ -665,6 +684,25 @@ const zh = {
|
|
|
665
684
|
'menu.check': '检查这个账号',
|
|
666
685
|
'menu.like': '照此新建账号',
|
|
667
686
|
'menu.tune': '上下文调优',
|
|
687
|
+
'menu.model': '模型和 effort…',
|
|
688
|
+
'md.title': '模型和 effort',
|
|
689
|
+
'md.intro': '只影响之后用 sideby 启动的 {ref} 新会话(短命令或 sideby run)。保存在 sideby 的配置里,不改 {host} 自己的设置。',
|
|
690
|
+
'md.model': '模型',
|
|
691
|
+
'md.effort': 'Effort',
|
|
692
|
+
'md.default': '默认',
|
|
693
|
+
'md.decides': '由 {host} 决定',
|
|
694
|
+
'md.from.sideby': 'sideby 为这个账号设置',
|
|
695
|
+
'md.from.host': '来自 {host} 的设置',
|
|
696
|
+
'md.from.last': '最近一次会话用的',
|
|
697
|
+
'md.none': '暂时不知道',
|
|
698
|
+
'md.click': '点击修改。',
|
|
699
|
+
'md.envNote': 'shell 里的 ANTHROPIC_MODEL、CLAUDE_CODE_EFFORT_LEVEL 和组织下发的设置会覆盖这里,sideby 看不到它们。',
|
|
700
|
+
'md.reset': '恢复默认',
|
|
701
|
+
'md.review': '查看改动',
|
|
702
|
+
'md.apply': '应用',
|
|
703
|
+
'md.nothing': '没有要改的。',
|
|
704
|
+
'md.changes': '将修改 sideby 的配置:',
|
|
705
|
+
'md.saved': '已保存,{ref} 的新会话会用它。',
|
|
668
706
|
'page.handoff': '自动交接',
|
|
669
707
|
'hp.title': '自动交接',
|
|
670
708
|
'hp.advanced': '高级设置',
|
|
@@ -577,6 +577,7 @@ const MAIN = String.raw `
|
|
|
577
577
|
a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
|
|
578
578
|
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
579
579
|
'-',
|
|
580
|
+
a.modelInfo && a.modelInfo.settable ? { icon: 'terminal', label: t('menu.model'), run: () => openModel(a) } : null,
|
|
580
581
|
family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
|
|
581
582
|
{ icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
|
|
582
583
|
BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
|
|
@@ -903,6 +904,7 @@ const MAIN = String.raw `
|
|
|
903
904
|
return h('div', { class: 'ident-sub' }, dots([
|
|
904
905
|
h('span', { class: 'mono' }, a.ref),
|
|
905
906
|
a.kind === 'api' && a.model ? h('span', { class: 'mono', title: t('acct.model') }, a.model) : null,
|
|
907
|
+
a.kind !== 'api' ? modelChip(a) : null,
|
|
906
908
|
a.kind !== 'api' && plan ? h('span', { class: 'plan' }, plan) : null,
|
|
907
909
|
]))
|
|
908
910
|
}
|
|
@@ -1979,6 +1981,117 @@ const MAIN = String.raw `
|
|
|
1979
1981
|
if (ah.loading) loadHandoffStatus()
|
|
1980
1982
|
}
|
|
1981
1983
|
|
|
1984
|
+
// ---------- model and effort (spec §3.19) ----------
|
|
1985
|
+
// Shown under the account name; the dialog changes what sideby starts this account with, never the Host's files.
|
|
1986
|
+
let md = null
|
|
1987
|
+
function modelFrom(a, from) { return t('md.from.' + from, { host: family(a.family).title }) }
|
|
1988
|
+
function modelChip(a) {
|
|
1989
|
+
const mi = a.modelInfo
|
|
1990
|
+
if (!mi || (!mi.model && !mi.effort)) return null
|
|
1991
|
+
const text = [mi.model && mi.model.value, mi.effort && mi.effort.value].filter(Boolean).join(' · ')
|
|
1992
|
+
const title = [
|
|
1993
|
+
mi.model ? t('md.model') + ': ' + mi.model.value + ' (' + modelFrom(a, mi.model.from) + ')' : null,
|
|
1994
|
+
mi.effort ? t('md.effort') + ': ' + mi.effort.value + ' (' + modelFrom(a, mi.effort.from) + ')' : null,
|
|
1995
|
+
mi.settable ? t('md.click') : null,
|
|
1996
|
+
].filter(Boolean).join('\n')
|
|
1997
|
+
if (!mi.settable) return h('span', { class: 'mono', title }, text)
|
|
1998
|
+
return h('button', { class: 'mono model-btn' + (mi.model && mi.model.from === 'sideby' ? ' model-set' : ''), type: 'button', title, 'aria-label': t('menu.model') + ' ' + a.ref + ': ' + text, onclick: (e) => { e.stopPropagation(); openModel(a) } }, text)
|
|
1999
|
+
}
|
|
2000
|
+
function mdAccount() { return S.state && S.state.accounts.find((x) => x.ref === md.ref) }
|
|
2001
|
+
function openModel(a) {
|
|
2002
|
+
md = { ref: a.ref, family: a.family, preview: null, busy: false, error: null, form: null }
|
|
2003
|
+
showDialog('model')
|
|
2004
|
+
renderModel()
|
|
2005
|
+
}
|
|
2006
|
+
// The inputs are built once per opening, so typing keeps focus; everything else re-renders around them.
|
|
2007
|
+
function mdForm(a) {
|
|
2008
|
+
if (md.form) return md.form
|
|
2009
|
+
const mi = a.modelInfo
|
|
2010
|
+
const host = family(a.family).title
|
|
2011
|
+
const list = h('datalist', { id: 'md-models' }, mi.models.map((m) => h('option', { value: m })))
|
|
2012
|
+
const model = h('input', { type: 'text', list: 'md-models', autocomplete: 'off', spellcheck: 'false', placeholder: t('md.default') + ' (' + t('md.decides', { host }) + ')', disabled: BOOT.readOnly })
|
|
2013
|
+
model.value = mi.sideby.model || ''
|
|
2014
|
+
const effort = h('select', { disabled: BOOT.readOnly }, h('option', { value: '' }, t('md.default') + ' (' + t('md.decides', { host }) + ')'), mi.efforts.map((e) => h('option', { value: e }, e)))
|
|
2015
|
+
effort.value = mi.sideby.effort || ''
|
|
2016
|
+
const changed = () => { md.preview = null; md.error = null; renderModel() }
|
|
2017
|
+
model.addEventListener('input', () => { if (md.preview) changed() })
|
|
2018
|
+
effort.addEventListener('change', changed)
|
|
2019
|
+
md.form = { model, effort, list }
|
|
2020
|
+
return md.form
|
|
2021
|
+
}
|
|
2022
|
+
function mdChange(a, reset) {
|
|
2023
|
+
const mi = a.modelInfo
|
|
2024
|
+
if (reset) return { model: mi.sideby.model ? null : undefined, effort: mi.sideby.effort ? null : undefined }
|
|
2025
|
+
const f = md.form
|
|
2026
|
+
const m = f.model.value.trim()
|
|
2027
|
+
const ch = {}
|
|
2028
|
+
if (m !== (mi.sideby.model || '')) ch.model = m || null
|
|
2029
|
+
if (f.effort.value !== (mi.sideby.effort || '')) ch.effort = f.effort.value || null
|
|
2030
|
+
return ch
|
|
2031
|
+
}
|
|
2032
|
+
async function mdReview(reset) {
|
|
2033
|
+
const a = mdAccount()
|
|
2034
|
+
if (!a) return
|
|
2035
|
+
const change = mdChange(a, reset)
|
|
2036
|
+
for (const k of Object.keys(change)) if (change[k] === undefined) delete change[k]
|
|
2037
|
+
md.error = null
|
|
2038
|
+
if (!Object.keys(change).length) { md.preview = { nothing: true }; renderModel(); return }
|
|
2039
|
+
md.busy = true
|
|
2040
|
+
renderModel()
|
|
2041
|
+
try {
|
|
2042
|
+
const r = await api('/api/account/model', Object.assign({ account: a.ref }, change))
|
|
2043
|
+
md.preview = r.changed ? { change, diff: r.diff } : { nothing: true }
|
|
2044
|
+
} catch (e) { md.error = e }
|
|
2045
|
+
md.busy = false
|
|
2046
|
+
renderModel()
|
|
2047
|
+
}
|
|
2048
|
+
async function mdApply() {
|
|
2049
|
+
const a = mdAccount()
|
|
2050
|
+
if (!a || !md.preview || !md.preview.change) return
|
|
2051
|
+
md.busy = true
|
|
2052
|
+
dlg.busy = true
|
|
2053
|
+
renderModel()
|
|
2054
|
+
try {
|
|
2055
|
+
await api('/api/account/model', Object.assign({ account: a.ref, confirm: true }, md.preview.change))
|
|
2056
|
+
dlg.busy = false
|
|
2057
|
+
toast(t('md.saved', { ref: a.ref }))
|
|
2058
|
+
dlg.el.close()
|
|
2059
|
+
await load({ silent: true })
|
|
2060
|
+
return
|
|
2061
|
+
} catch (e) { md.error = e }
|
|
2062
|
+
md.busy = false
|
|
2063
|
+
dlg.busy = false
|
|
2064
|
+
renderModel()
|
|
2065
|
+
}
|
|
2066
|
+
function renderModel() {
|
|
2067
|
+
if (!md || dlg.kind !== 'model') return
|
|
2068
|
+
const a = mdAccount()
|
|
2069
|
+
if (!a || !a.modelInfo) { dlg.el.close(); return }
|
|
2070
|
+
const mi = a.modelInfo
|
|
2071
|
+
const host = family(a.family).title
|
|
2072
|
+
const f = mdForm(a)
|
|
2073
|
+
const now = (key) => mi[key] ? h('span', null, h('b', { class: 'mono' }, mi[key].value), ' ', h('span', { class: 'muted small' }, '(' + modelFrom(a, mi[key].from) + ')')) : h('span', { class: 'muted' }, t('md.none'))
|
|
2074
|
+
const body = [h('p', { class: 'muted small' }, t('md.intro', { ref: a.ref, host }))]
|
|
2075
|
+
if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
|
|
2076
|
+
body.push(h('div', { class: 'md-now' },
|
|
2077
|
+
h('div', null, h('span', { class: 'muted' }, t('md.model') + ': '), now('model')),
|
|
2078
|
+
h('div', null, h('span', { class: 'muted' }, t('md.effort') + ': '), now('effort'))))
|
|
2079
|
+
body.push(h('div', { class: 'ah-grid' },
|
|
2080
|
+
h('label', { class: 'field' }, h('span', null, t('md.model')), f.model, f.list),
|
|
2081
|
+
h('label', { class: 'field' }, h('span', null, t('md.effort')), f.effort)))
|
|
2082
|
+
if (a.family === 'claude') body.push(h('p', { class: 'muted small' }, t('md.envNote')))
|
|
2083
|
+
if (md.error) body.push(errorNote(md.error, null))
|
|
2084
|
+
if (md.preview && md.preview.nothing) body.push(h('p', { class: 'muted small' }, t('md.nothing')))
|
|
2085
|
+
if (md.preview && md.preview.diff) body.push(h('p', { class: 'small' }, t('md.changes')), diffView(md.preview.diff))
|
|
2086
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: md.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
2087
|
+
if (!BOOT.readOnly) {
|
|
2088
|
+
if (mi.sideby.model || mi.sideby.effort) foot.push(h('button', { class: 'btn', type: 'button', disabled: md.busy, onclick: () => mdReview(true) }, t('md.reset')))
|
|
2089
|
+
if (md.preview && md.preview.change) foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: md.busy, 'aria-busy': md.busy ? 'true' : null, onclick: mdApply }, md.busy ? spinner() : null, t('md.apply')))
|
|
2090
|
+
else foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: md.busy, onclick: () => mdReview(false) }, md.busy ? spinner() : null, t('md.review')))
|
|
2091
|
+
}
|
|
2092
|
+
setKids(dlg.el, dialogFrame(t('md.title') + ' · ' + a.ref, a.family, body, foot))
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1982
2095
|
// ---------- the Auto Handoff page ----------
|
|
1983
2096
|
// Off: one sentence and one button. On: where each account's work would go next, the last hand-overs and what is
|
|
1984
2097
|
// still missing. Turning on is one plan shown first (hp.preview) and one confirmation; no commands to copy.
|
|
@@ -2274,6 +2387,7 @@ const MAIN = String.raw `
|
|
|
2274
2387
|
if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
|
|
2275
2388
|
else if (dlg.kind === 'setup' && qs) renderSetup()
|
|
2276
2389
|
else if (dlg.kind === 'tune' && tuneState) renderTune()
|
|
2390
|
+
else if (dlg.kind === 'model' && md) { md.form = null; renderModel() }
|
|
2277
2391
|
})
|
|
2278
2392
|
$('theme').addEventListener('click', () => {
|
|
2279
2393
|
const order = CHOICES.theme
|
|
@@ -167,7 +167,11 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
167
167
|
.ident-name { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 3px; }
|
|
168
168
|
.ident-name .name { font-weight: var(--strong-weight); font-size: var(--font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
|
169
169
|
.ident-email { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
|
170
|
-
.email-btn { all: unset; cursor: pointer; border-radius: 4px; }
|
|
170
|
+
.email-btn, .model-btn { all: unset; cursor: pointer; border-radius: 4px; }
|
|
171
|
+
.model-btn:hover { color: var(--text); text-decoration: underline dotted; }
|
|
172
|
+
.model-btn:focus-visible { outline: var(--focus-outline); outline-offset: 2px; }
|
|
173
|
+
.model-set { color: var(--accent); }
|
|
174
|
+
.md-now { display: flex; flex-direction: column; gap: 4px; }
|
|
171
175
|
.email-btn:hover { color: var(--text); text-decoration: underline dotted; }
|
|
172
176
|
.email-btn:focus-visible { outline: var(--focus-outline); outline-offset: 2px; }
|
|
173
177
|
.ident-email + .ident-sub { color: var(--faint); margin-top: 0; }
|
package/dist/src/types.d.ts
CHANGED
|
@@ -77,6 +77,8 @@ export interface FamilyDef {
|
|
|
77
77
|
};
|
|
78
78
|
loginState?(account: Account): Promise<LoginState>;
|
|
79
79
|
model?(account: Account): Promise<string | undefined>;
|
|
80
|
+
/** Choosing the model and effort an Account starts with, as Host arguments (spec §3.19, ADR-0014). */
|
|
81
|
+
modelChoice?: FamilyModelChoice;
|
|
80
82
|
/**
|
|
81
83
|
* Who the Account is signed in as, from the Host's own login file: identity fields such as the email and the
|
|
82
84
|
* organization only, never a token or any other credential value (ADR-0003). Return undefined when unknown; an
|
|
@@ -224,6 +226,30 @@ export interface TokenTotals {
|
|
|
224
226
|
cacheWriteTokens: number;
|
|
225
227
|
totalTokens: number;
|
|
226
228
|
}
|
|
229
|
+
/** A model and an effort level; a field left out means the Host's own default. */
|
|
230
|
+
export interface ModelChoice {
|
|
231
|
+
model?: string;
|
|
232
|
+
effort?: string;
|
|
233
|
+
}
|
|
234
|
+
export interface FamilyModelChoice {
|
|
235
|
+
/** Effort levels the Host accepts at launch, lowest first. */
|
|
236
|
+
efforts: readonly string[];
|
|
237
|
+
/** Model names to offer; any other name may be typed. */
|
|
238
|
+
models: readonly string[];
|
|
239
|
+
/**
|
|
240
|
+
* What the Host's own config sets for new sessions of the Account. Reads only the model and effort keys. A
|
|
241
|
+
* per-model effort is looked up for `model` (the model sideby starts it with), else the config's own model, else
|
|
242
|
+
* `lastModel` (the model of the newest session).
|
|
243
|
+
*/
|
|
244
|
+
configured(account: Account, model?: string, lastModel?: string): Promise<ModelChoice>;
|
|
245
|
+
/** Host arguments that start a session with `choice`. */
|
|
246
|
+
toArgs(choice: ModelChoice): string[];
|
|
247
|
+
/** The choice in `args`, and the other arguments in their order. */
|
|
248
|
+
fromArgs(args: readonly string[]): {
|
|
249
|
+
choice: ModelChoice;
|
|
250
|
+
rest: string[];
|
|
251
|
+
};
|
|
252
|
+
}
|
|
227
253
|
export type UsageResult = ({
|
|
228
254
|
status: 'ok';
|
|
229
255
|
days: number;
|
|
@@ -239,6 +265,9 @@ export type UsageResult = ({
|
|
|
239
265
|
}[];
|
|
240
266
|
/** ISO time of the newest record the reader saw, not later than now. */
|
|
241
267
|
lastActivityAt?: string;
|
|
268
|
+
/** Model and effort of the newest turn the reader saw, when the logs record them (spec §3.19). */
|
|
269
|
+
lastModel?: string;
|
|
270
|
+
lastEffort?: string;
|
|
242
271
|
} & TokenTotals) | {
|
|
243
272
|
status: 'unavailable';
|
|
244
273
|
reason: 'no-source' | 'no-session' | 'unrecognized';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sideby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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)",
|
|
@@ -72,9 +72,10 @@
|
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@biomejs/biome": "2.5.15",
|
|
74
74
|
"@types/node": "^22.18.0",
|
|
75
|
+
"motion": "13.4.3",
|
|
75
76
|
"typescript": "^7.0.2"
|
|
76
77
|
},
|
|
77
78
|
"packageManager": "pnpm@10.15.0",
|
|
78
79
|
"readmeFilename": "README.md",
|
|
79
|
-
"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, then the local quota panel\" 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| 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 the panel from the Dock or app menu | `sideby app install` | [Desktop app](docs/guide/panel.md#desktop-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, desktop 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## 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 the panel from the Dock or app menu | `sideby app install` | [Desktop app](docs/guide/panel.md#desktop-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, desktop 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
81
|
}
|