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.
@@ -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
- return { times, events, deltas: fileDeltas(events), failed: read.failed };
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: 1 });
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,
@@ -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;
@@ -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; }
@@ -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.5.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
  }