sideby 0.4.3 → 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.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +5 -0
  4. package/dist/src/cli/commands.js +67 -0
  5. package/dist/src/cli/json-schemas.js +30 -0
  6. package/dist/src/cli/main.js +5 -0
  7. package/dist/src/core/config.d.ts +14 -0
  8. package/dist/src/core/config.js +9 -0
  9. package/dist/src/core/handoff-enable.d.ts +52 -0
  10. package/dist/src/core/handoff-enable.js +169 -0
  11. package/dist/src/core/handoff-overview.d.ts +26 -0
  12. package/dist/src/core/handoff-overview.js +63 -0
  13. package/dist/src/core/handoff-run.js +2 -1
  14. package/dist/src/core/handoff-select.d.ts +2 -0
  15. package/dist/src/core/handoff-select.js +11 -5
  16. package/dist/src/core/model-choice.d.ts +41 -0
  17. package/dist/src/core/model-choice.js +128 -0
  18. package/dist/src/families/claude/index.js +2 -0
  19. package/dist/src/families/claude/model.d.ts +2 -0
  20. package/dist/src/families/claude/model.js +42 -0
  21. package/dist/src/families/claude/quota-cache.d.ts +7 -0
  22. package/dist/src/families/claude/quota-cache.js +40 -0
  23. package/dist/src/families/claude/usage.d.ts +2 -0
  24. package/dist/src/families/claude/usage.js +13 -1
  25. package/dist/src/families/codex/index.js +2 -0
  26. package/dist/src/families/codex/model.d.ts +2 -0
  27. package/dist/src/families/codex/model.js +59 -0
  28. package/dist/src/families/codex/rollout.d.ts +5 -0
  29. package/dist/src/families/codex/rollout.js +45 -3
  30. package/dist/src/families/grok/handoff.js +1 -1
  31. package/dist/src/families/shared/model-args.d.ts +7 -0
  32. package/dist/src/families/shared/model-args.js +28 -0
  33. package/dist/src/handoff/messages.js +1 -1
  34. package/dist/src/panel/handler.d.ts +3 -0
  35. package/dist/src/panel/handler.js +69 -0
  36. package/dist/src/panel/i18n.d.ts +67 -0
  37. package/dist/src/panel/i18n.js +134 -0
  38. package/dist/src/panel/page-script.js +328 -13
  39. package/dist/src/panel/page-styles.js +14 -1
  40. package/dist/src/panel/page.js +1 -0
  41. package/dist/src/quota/statusline-tap.js +13 -2
  42. package/dist/src/runtime.d.ts +5 -2
  43. package/dist/src/runtime.js +2 -1
  44. package/dist/src/types.d.ts +29 -0
  45. package/package.json +3 -2
  46. package/schemas/model.json +169 -0
  47. package/schemas/quota.json +6 -0
  48. package/skills/sideby/SKILL.md +2 -1
  49. package/skills/sideby/references/handoff.md +2 -2
@@ -233,6 +233,73 @@ 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;
255
+ 'page.handoff': string;
256
+ 'hp.title': string;
257
+ 'hp.advanced': string;
258
+ 'hp.loading': string;
259
+ 'hp.intro': string;
260
+ 'hp.how1': string;
261
+ 'hp.how2': string;
262
+ 'hp.how3': string;
263
+ 'hp.noStarters': string;
264
+ 'hp.sameFamily': string;
265
+ 'hp.turnOn': string;
266
+ 'hp.turnOff': string;
267
+ 'hp.willDo': string;
268
+ 'hp.step.config': string;
269
+ 'hp.step.quota': string;
270
+ 'hp.step.hook': string;
271
+ 'hp.step.copy': string;
272
+ 'hp.showChange': string;
273
+ 'hp.sameFamilyOffNote': string;
274
+ 'hp.manual.codexTrust': string;
275
+ 'hp.blocked': string;
276
+ 'hp.confirmOn': string;
277
+ 'hp.applying': string;
278
+ 'hp.stepFailed': string;
279
+ 'hp.tryAgain': string;
280
+ 'hp.onNote': string;
281
+ 'hp.takeover': string;
282
+ 'hp.used': string;
283
+ 'hp.noQuota': string;
284
+ 'hp.goesTo': string;
285
+ 'hp.goesToLimit': string;
286
+ 'hp.waits': string;
287
+ 'hp.waitRule': string;
288
+ 'hp.planChanged': string;
289
+ 'hp.none': string;
290
+ 'hp.noneBack': string;
291
+ 'hp.noAccounts': string;
292
+ 'hp.recent': string;
293
+ 'hp.noneYet': string;
294
+ 'hp.why.threshold': string;
295
+ 'hp.why.limit': string;
296
+ 'hp.why.manual': string;
297
+ 'hp.missing': string;
298
+ 'hp.complete': string;
299
+ 'hp.onlySideby': string;
300
+ 'hp.sameConfirm': string;
301
+ 'hp.sameOn': string;
302
+ 'hp.sameOff': string;
236
303
  'page.accounts': string;
237
304
  'page.tuning': string;
238
305
  'tune.title': string;
@@ -235,6 +235,73 @@ 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.',
257
+ 'page.handoff': 'Auto Handoff',
258
+ 'hp.title': 'Auto Handoff',
259
+ 'hp.advanced': 'Advanced settings',
260
+ 'hp.loading': 'Checking…',
261
+ 'hp.intro': 'When an account is about to run out of quota, your work moves to the next account by itself. You do not have to do anything.',
262
+ 'hp.how1': 'You keep working in a session you started with sideby (a short command such as cc001, or sideby run).',
263
+ 'hp.how2': 'When that account reaches {p}% of its quota, sideby has the AI write a short note about the task and ends the session.',
264
+ 'hp.how3': 'After a {s}-second countdown (Enter cancels), the next account starts in the same terminal and carries on from the note.',
265
+ 'hp.noStarters': 'None of your accounts can start a hand-over yet. Add a Claude, Codex or Grok account first.',
266
+ 'hp.sameFamily': 'Also switch between accounts of the same tool (for example cc001 to cc002)',
267
+ 'hp.turnOn': 'Turn on',
268
+ 'hp.turnOff': 'Turn off',
269
+ 'hp.willDo': 'To turn it on, sideby will:',
270
+ 'hp.step.config': 'Switch Auto Handoff on in sideby’s settings',
271
+ 'hp.step.quota': 'Turn on {host} quota display (hand-over needs it to know how much is used)',
272
+ 'hp.step.hook': 'Add sideby’s hand-over hook to {host}',
273
+ 'hp.step.copy': 'Copy the hook file into your other {host} accounts ({accounts}); nothing else in them changes',
274
+ 'hp.showChange': 'Show the change',
275
+ 'hp.sameFamilyOffNote': 'Accounts of the same tool will not switch between each other. You can turn that on later on this page.',
276
+ 'hp.manual.codexTrust': 'Codex asks once per account to trust sideby’s hooks. Type /hooks in Codex the first time and trust them; sideby reminds you then.',
277
+ 'hp.blocked': 'It cannot be turned on yet: {why}',
278
+ 'hp.confirmOn': 'Confirm and turn on',
279
+ 'hp.applying': 'Turning on…',
280
+ 'hp.stepFailed': 'Stopped at: {step}. {why}',
281
+ 'hp.tryAgain': 'Nothing after it was changed. Try again, or ask for help.',
282
+ 'hp.onNote': 'When an account reaches {p}% of its quota, its session moves to the next account by itself.',
283
+ 'hp.takeover': 'Where work goes next',
284
+ 'hp.used': '{p}% used',
285
+ 'hp.noQuota': 'quota unknown',
286
+ 'hp.goesTo': 'at {p}% it moves to {to}',
287
+ 'hp.goesToLimit': 'when it hits its usage limit it moves to {to}',
288
+ 'hp.waits': 'at the limit it waits: quota is back at {at}',
289
+ 'hp.waitRule': 'If its quota comes back within {w} minutes at that point, it waits instead of switching.',
290
+ 'hp.planChanged': 'Something changed since you looked, so sideby has not changed anything yet. This is what it would do now; confirm again.',
291
+ 'hp.none': 'no account can take over right now',
292
+ 'hp.noneBack': 'no account can take over now; {ref} is back at {at}',
293
+ 'hp.noAccounts': 'No subscription account can start a hand-over.',
294
+ 'hp.recent': 'Recent hand-overs',
295
+ 'hp.noneYet': 'None yet. The next time an account runs low you will see it here.',
296
+ 'hp.why.threshold': 'quota reached the hand-over level',
297
+ 'hp.why.limit': 'it hit its usage limit',
298
+ 'hp.why.manual': 'handed over on request',
299
+ 'hp.missing': 'One step left',
300
+ 'hp.complete': 'Finish setting up',
301
+ 'hp.onlySideby': 'Only sessions started with sideby hand over (a short command such as cc001, or sideby run). Typing claude directly does not.',
302
+ 'hp.sameConfirm': 'Switch it on',
303
+ 'hp.sameOn': 'Same-tool hand-over is on.',
304
+ 'hp.sameOff': 'Same-tool hand-over is off.',
238
305
  'page.accounts': 'Accounts',
239
306
  'page.tuning': 'Context tuning',
240
307
  'tune.title': 'Context tuning',
@@ -617,6 +684,73 @@ const zh = {
617
684
  'menu.check': '检查这个账号',
618
685
  'menu.like': '照此新建账号',
619
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} 的新会话会用它。',
706
+ 'page.handoff': '自动交接',
707
+ 'hp.title': '自动交接',
708
+ 'hp.advanced': '高级设置',
709
+ 'hp.loading': '检查中…',
710
+ 'hp.intro': '账号额度快用完时,你的工作会自动换到下一个账号接着做,不用你操心。',
711
+ 'hp.how1': '你照常在用 sideby 启动的会话里工作(短命令如 cc001,或 sideby run)。',
712
+ 'hp.how2': '这个账号用到 {p}% 时,sideby 让 AI 写一份任务小结,然后结束这个会话。',
713
+ 'hp.how3': '倒计时 {s} 秒后(按回车可取消),下一个账号在同一个终端里按小结接着做。',
714
+ 'hp.noStarters': '你的账号里还没有能发起交接的。先添加一个 Claude、Codex 或 Grok 账号。',
715
+ 'hp.sameFamily': '同一个工具的账号之间也切换(例如 cc001 到 cc002)',
716
+ 'hp.turnOn': '一键开启',
717
+ 'hp.turnOff': '关闭',
718
+ 'hp.willDo': '开启时 sideby 会:',
719
+ 'hp.step.config': '在 sideby 的设置里打开自动交接',
720
+ 'hp.step.quota': '打开 {host} 的额度显示(交接要靠它知道用了多少)',
721
+ 'hp.step.hook': '给 {host} 装上 sideby 的交接钩子',
722
+ 'hp.step.copy': '把钩子文件复制到你的其他 {host} 账号({accounts}),它们的其他文件不动',
723
+ 'hp.showChange': '查看改动',
724
+ 'hp.sameFamilyOffNote': '同一个工具的账号之间不会互相切换,之后可以在本页打开。',
725
+ 'hp.manual.codexTrust': 'Codex 每个账号第一次要信任 sideby 的钩子:第一次启动时在 Codex 里输入 /hooks 并信任,到时 sideby 会提醒你。',
726
+ 'hp.blocked': '暂时不能开启:{why}',
727
+ 'hp.confirmOn': '确认开启',
728
+ 'hp.applying': '正在开启…',
729
+ 'hp.stepFailed': '停在这一步:{step}。{why}',
730
+ 'hp.tryAgain': '它之后的步骤没有执行。可以重试,或找人帮忙。',
731
+ 'hp.onNote': '任一账号用到 {p}% 时,它的会话会自动换到下一个账号。',
732
+ 'hp.takeover': '接下来会交给谁',
733
+ 'hp.used': '已用 {p}%',
734
+ 'hp.noQuota': '额度未知',
735
+ 'hp.goesTo': '到 {p}% 时交给 {to}',
736
+ 'hp.goesToLimit': '撞到用量上限时交给 {to}',
737
+ 'hp.waits': '到上限时会等待:{at} 额度恢复',
738
+ 'hp.waitRule': '如果那时离额度恢复不到 {w} 分钟,就原地等,不换号。',
739
+ 'hp.planChanged': '你查看之后设置有了变化,所以还没有改任何东西。下面是现在要做的改动,请再确认一次。',
740
+ 'hp.none': '现在没有能接手的账号',
741
+ 'hp.noneBack': '现在没有能接手的账号;{ref} 在 {at} 恢复',
742
+ 'hp.noAccounts': '没有能发起交接的订阅账号。',
743
+ 'hp.recent': '最近的交接',
744
+ 'hp.noneYet': '还没有。下次有账号额度快用完时,这里会出现记录。',
745
+ 'hp.why.threshold': '额度到了交接线',
746
+ 'hp.why.limit': '撞到了用量上限',
747
+ 'hp.why.manual': '按你的要求交接',
748
+ 'hp.missing': '还差一步',
749
+ 'hp.complete': '补全设置',
750
+ 'hp.onlySideby': '只有用 sideby 启动的会话才会交接(短命令如 cc001,或 sideby run)。直接敲 claude 启动的不会。',
751
+ 'hp.sameConfirm': '确认打开',
752
+ 'hp.sameOn': '已开启同工具交接。',
753
+ 'hp.sameOff': '已关闭同工具交接。',
620
754
  'page.accounts': '账号',
621
755
  'page.tuning': '上下文调优',
622
756
  'tune.title': '上下文调优',
@@ -227,6 +227,8 @@ const MAIN = String.raw `
227
227
  // tooltip, a focused field, or a text selection in the account list.
228
228
  function uiBusy() {
229
229
  if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
230
+ // A plan on the Auto Handoff page, and the change a person opened to read, stay as they are.
231
+ if (!$('handoff-page').hidden && (hp.preview || hp.askSame || document.querySelector('#handoff-page details[open]'))) return true
230
232
  const el = document.activeElement
231
233
  if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return true
232
234
  const sel = window.getSelection && window.getSelection()
@@ -575,6 +577,7 @@ const MAIN = String.raw `
575
577
  a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
576
578
  a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
577
579
  '-',
580
+ a.modelInfo && a.modelInfo.settable ? { icon: 'terminal', label: t('menu.model'), run: () => openModel(a) } : null,
578
581
  family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
579
582
  { icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
580
583
  BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
@@ -901,6 +904,7 @@ const MAIN = String.raw `
901
904
  return h('div', { class: 'ident-sub' }, dots([
902
905
  h('span', { class: 'mono' }, a.ref),
903
906
  a.kind === 'api' && a.model ? h('span', { class: 'mono', title: t('acct.model') }, a.model) : null,
907
+ a.kind !== 'api' ? modelChip(a) : null,
904
908
  a.kind !== 'api' && plan ? h('span', { class: 'plan' }, plan) : null,
905
909
  ]))
906
910
  }
@@ -1249,7 +1253,8 @@ const MAIN = String.raw `
1249
1253
  // What each card's size menu shows while the user is choosing, by group id; dropped once a change is made.
1250
1254
  const tuneSel = {}
1251
1255
  const tp = { data: null, loading: false, error: null, at: 0 }
1252
- let curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'
1256
+ function pageFromHash() { return location.hash === '#tuning' ? 'tuning' : location.hash === '#handoff' ? 'handoff' : 'accounts' }
1257
+ let curPage = pageFromHash()
1253
1258
  function tuneSize(n) { return n === null || n === undefined ? t('tune.hostDefault') : tokens(n) }
1254
1259
  function tuneRedraw() {
1255
1260
  if (dlg.kind === 'tune') renderTune()
@@ -1442,25 +1447,37 @@ const MAIN = String.raw `
1442
1447
  function tuningAvailable() { return Boolean(S.state && S.state.families.some((f) => f.tuning)) }
1443
1448
  function setPage(next) {
1444
1449
  curPage = next
1445
- try { history.replaceState(null, '', next === 'tuning' ? '#tuning' : location.pathname + location.search) } catch {}
1450
+ try { history.replaceState(null, '', next === 'accounts' ? location.pathname + location.search : '#' + next) } catch {}
1446
1451
  renderPageChrome()
1447
1452
  }
1448
1453
  // The page tabs, and which sections show; runs with every render, so a reload or a language switch keeps them.
1449
1454
  function renderPageChrome() {
1450
1455
  const nav = $('pages')
1451
- const can = tuningAvailable()
1452
- if (!can && curPage === 'tuning' && S.state) curPage = 'accounts'
1453
- nav.hidden = !can
1454
- const tabs = [['accounts', t('page.accounts')], ['tuning', t('page.tuning')]]
1456
+ const canTune = tuningAvailable()
1457
+ const canHandoff = Boolean(ahState())
1458
+ if (S.state && ((curPage === 'tuning' && !canTune) || (curPage === 'handoff' && !canHandoff))) curPage = 'accounts'
1459
+ const tabs = [['accounts', t('page.accounts')]]
1460
+ if (canTune) tabs.push(['tuning', t('page.tuning')])
1461
+ if (canHandoff) tabs.push(['handoff', t('page.handoff')])
1462
+ nav.hidden = tabs.length < 2
1455
1463
  setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
1456
1464
  h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
1457
- const on = can && curPage === 'tuning'
1458
- for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = on
1459
- $('tuning-page').hidden = !on
1460
- if (on) {
1465
+ const away = curPage !== 'accounts' && tabs.some(([id]) => id === curPage)
1466
+ for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away
1467
+ const onTune = away && curPage === 'tuning'
1468
+ const onHandoff = away && curPage === 'handoff'
1469
+ $('tuning-page').hidden = !onTune
1470
+ $('handoff-page').hidden = !onHandoff
1471
+ if (onTune) {
1461
1472
  if (!tp.data && !tp.loading && !tp.error) loadTuningPage()
1462
1473
  else renderTuningPage()
1463
1474
  }
1475
+ if (onHandoff) {
1476
+ // A change made elsewhere (Advanced settings, the CLI) makes what the page loaded out of date.
1477
+ const stale = hp.data && (hp.data.settings.auto !== ahState().settings.auto || hp.data.settings.sameFamily !== ahState().settings.sameFamily)
1478
+ if ((!hp.data || stale) && !hp.loading && !hp.error) loadHandoffPage()
1479
+ else renderHandoffPage()
1480
+ }
1464
1481
  }
1465
1482
  async function loadTuningPage() {
1466
1483
  tp.loading = true
@@ -1491,7 +1508,7 @@ const MAIN = String.raw `
1491
1508
  }
1492
1509
  setKids(box, kids)
1493
1510
  }
1494
- window.addEventListener('hashchange', () => { curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'; renderPageChrome() })
1511
+ window.addEventListener('hashchange', () => { curPage = pageFromHash(); renderPageChrome() })
1495
1512
 
1496
1513
  // New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
1497
1514
  let nf = null
@@ -1964,6 +1981,303 @@ const MAIN = String.raw `
1964
1981
  if (ah.loading) loadHandoffStatus()
1965
1982
  }
1966
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
+
2095
+ // ---------- the Auto Handoff page ----------
2096
+ // Off: one sentence and one button. On: where each account's work would go next, the last hand-overs and what is
2097
+ // still missing. Turning on is one plan shown first (hp.preview) and one confirmation; no commands to copy.
2098
+ const hp = { data: null, plan: null, loading: false, error: null, preview: null, previewing: false, applying: false, failed: null, changed: false, sameFamily: false, sameTouched: false, busyOff: false, askSame: false }
2099
+ async function loadHandoffPage() {
2100
+ hp.loading = true
2101
+ hp.error = null
2102
+ renderHandoffPage()
2103
+ try {
2104
+ // What is missing is only worth the check once it is on; while off the button shows the whole plan.
2105
+ const [data, plan] = await Promise.all([api('/api/handoff/overview', {}), ahState().settings.auto ? api('/api/handoff/enable', {}) : Promise.resolve(null)])
2106
+ hp.data = data
2107
+ hp.plan = plan
2108
+ } catch (e) { hp.error = e }
2109
+ hp.loading = false
2110
+ renderHandoffPage()
2111
+ }
2112
+ // Off, the box on this page decides; on, "finish setting up" keeps whatever is set.
2113
+ function hpBody() { return ahState().settings.auto ? {} : { sameFamily: hp.sameFamily } }
2114
+ async function hpPreview() {
2115
+ hp.previewing = true
2116
+ hp.failed = null
2117
+ hp.changed = false
2118
+ renderHandoffPage()
2119
+ try { hp.preview = await api('/api/handoff/enable', hpBody()) } catch (e) { hp.failed = { error: e } }
2120
+ hp.previewing = false
2121
+ renderHandoffPage()
2122
+ }
2123
+ async function hpApply() {
2124
+ hp.applying = true
2125
+ hp.failed = null
2126
+ renderHandoffPage()
2127
+ try {
2128
+ const r = await api('/api/handoff/enable', Object.assign(hpBody(), { confirm: true, expect: hp.preview && hp.preview.key }))
2129
+ const bad = r.results.find((x) => !x.ok)
2130
+ // A plan that changed since it was shown is shown again; one with nothing left to do means it is already on.
2131
+ hp.changed = Boolean(r.changed) && !r.plan.alreadyOn
2132
+ if (hp.changed || r.plan.blocked) hp.preview = r.plan
2133
+ else if (bad) hp.failed = { step: bad, plan: r.plan }
2134
+ else { hp.preview = null; toast(t('toast.ahOn')) }
2135
+ await load({ silent: true })
2136
+ await loadHandoffPage()
2137
+ } catch (e) { hp.failed = { error: e } }
2138
+ hp.applying = false
2139
+ renderHandoffPage()
2140
+ }
2141
+ async function hpChange(change, message) {
2142
+ hp.busyOff = true
2143
+ renderHandoffPage()
2144
+ try {
2145
+ await api('/api/handoff/configure', { change, confirm: true })
2146
+ hp.preview = null
2147
+ hp.askSame = false
2148
+ toast(message)
2149
+ await load({ silent: true })
2150
+ await loadHandoffPage()
2151
+ } catch (e) { hp.failed = { error: e } }
2152
+ hp.busyOff = false
2153
+ renderHandoffPage()
2154
+ }
2155
+ function hpStepTitle(step) {
2156
+ const host = step.family ? family(step.family).title : ''
2157
+ return t('hp.step.' + step.id, { host, accounts: (step.accounts || []).join(', ') })
2158
+ }
2159
+ function hpSteps(plan) {
2160
+ return h('ul', { class: 'hp-steps' }, plan.steps.map((step) => h('li', null, h('div', null,
2161
+ h('div', null, hpStepTitle(step)),
2162
+ step.diff ? h('details', { class: 'small' }, h('summary', null, t('hp.showChange')), h('p', { class: 'muted small' }, tx('ah.changes', { file: h('code', null, step.file) })), diffView(step.diff)) : null))))
2163
+ }
2164
+ function hpManual(plan) {
2165
+ return plan.manual.map((m) => h('p', { class: 'note' }, m.id === 'codex-trust' ? t('hp.manual.codexTrust') : (family(m.family).title + ': ' + (m.message || ''))))
2166
+ }
2167
+ function hpPreviewCard() {
2168
+ const p = hp.preview
2169
+ const kids = [h('h3', { class: 'small' }, t('hp.willDo'))]
2170
+ if (hp.changed) kids.push(h('p', { class: 'note note-warn', role: 'status' }, t('hp.planChanged')))
2171
+ if (p.blocked) kids.push(h('p', { class: 'note note-fail' }, t('hp.blocked', { why: p.blocked })), codeCopy('npm i -g sideby'))
2172
+ else {
2173
+ kids.push(hpSteps(p), ...hpManual(p))
2174
+ kids.push(p.sameFamily ? h('p', { class: 'note note-warn' }, t('ah.sameFamilyWarn')) : h('p', { class: 'muted small' }, t('hp.sameFamilyOffNote')))
2175
+ }
2176
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: hp.applying, onclick: () => { hp.preview = null; hp.failed = null; hp.changed = false; renderHandoffPage() } }, t('btn.cancel'))]
2177
+ if (!p.blocked) foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: hp.applying, 'aria-busy': hp.applying ? 'true' : null, onclick: hpApply }, hp.applying ? spinner() : null, hp.applying ? t('hp.applying') : t('hp.confirmOn')))
2178
+ kids.push(h('div', { class: 'row-actions' }, foot))
2179
+ return h('div', { class: 'ah-fam' }, kids)
2180
+ }
2181
+ function hpFailed() {
2182
+ const f = hp.failed
2183
+ if (!f) return null
2184
+ if (f.error) return errorNote(f.error, null)
2185
+ return h('p', { class: 'note note-fail', role: 'alert' }, t('hp.stepFailed', { step: hpStepTitle(f.step), why: f.step.message || '' }) + ' ' + t('hp.tryAgain'))
2186
+ }
2187
+ function hpDecision(d, starts) {
2188
+ const thr = ahState().settings.threshold
2189
+ if (d.kind === 'pick') return starts.includes('quota') ? t('hp.goesTo', { to: accountLabel(d.ref), p: thr }) : t('hp.goesToLimit', { to: accountLabel(d.ref) })
2190
+ if (d.kind === 'wait') return t('hp.waits', { at: clock(d.until) })
2191
+ return d.earliestReset ? t('hp.noneBack', { ref: accountLabel(d.earliestReset.ref), at: clock(d.earliestReset.at) }) : t('hp.none')
2192
+ }
2193
+ // The one case where reaching the threshold does not switch (spec §3.17 "选号" 1); none when waiting is off.
2194
+ function hpWaitRule(st) {
2195
+ const w = st.settings.waitIfResetWithinMinutes
2196
+ return w > 0 ? ' ' + t('hp.waitRule', { w }) : null
2197
+ }
2198
+ function hpRows(d) {
2199
+ if (!d.takeover.length) return h('p', { class: 'muted small' }, t('hp.noAccounts'))
2200
+ return h('ul', { class: 'hp-rows' }, d.takeover.map((r) => {
2201
+ const a = S.state.accounts.find((x) => x.ref === r.ref)
2202
+ const p = a ? quotaPressure(a, Date.now()) : -1
2203
+ return h('li', null,
2204
+ h('span', { class: 'title-logo' }, badge(r.family, 'sm'), h('b', { class: 'mono' }, accountLabel(r.ref))),
2205
+ h('span', { class: 'muted' }, p >= 0 ? t('hp.used', { p: Math.round(p) }) : t('hp.noQuota')),
2206
+ h('span', null, hpDecision(r.decision, r.starts)))
2207
+ }))
2208
+ }
2209
+ function hpRecent(d) {
2210
+ if (!d.recent.length) return h('p', { class: 'muted small' }, t('hp.noneYet'))
2211
+ return h('ul', { class: 'hp-rows' }, d.recent.map((r) => h('li', null,
2212
+ h('span', { class: 'mono' }, accountLabel(r.from) + ' → ' + accountLabel(r.to)),
2213
+ h('span', { class: 'muted' }, clock(r.at) + ' · ' + ago(r.at)),
2214
+ h('span', { class: 'muted' }, r.trigger ? t('hp.why.' + r.trigger) : ''))))
2215
+ }
2216
+ function hpSameFamilyBox(checked, onchange) {
2217
+ const input = h('input', { type: 'checkbox', disabled: BOOT.readOnly || hp.busyOff || hp.applying })
2218
+ input.checked = checked
2219
+ input.addEventListener('change', () => onchange(input.checked))
2220
+ return h('div', { class: 'check-row' }, h('label', { class: 'check' }, input, h('span', null, h('b', null, t('hp.sameFamily')), h('span', { class: 'muted small' }, t('ah.sameFamilyWarn')))))
2221
+ }
2222
+ function renderHandoffPage() {
2223
+ const box = $('handoff-page')
2224
+ if (box.hidden || !S.state) return
2225
+ const st = ahState()
2226
+ if (!st) return
2227
+ const kids = [h('div', { class: 'section-head' },
2228
+ h('h2', { id: 'h-handoff' }, t('hp.title')),
2229
+ h('div', { class: 'row-actions' },
2230
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: openHandoff }, t('hp.advanced')),
2231
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: hp.loading, onclick: loadHandoffPage }, hp.loading ? spinner() : icon('refresh'), t('top.refresh'))))]
2232
+ const on = st.settings.auto
2233
+ if (hp.loading && !hp.data) kids.push(h('p', { class: 'muted' }, spinner(), ' ', t('hp.loading')))
2234
+ if (hp.error) kids.push(errorNote(hp.error, loadHandoffPage))
2235
+ if (BOOT.readOnly) kids.push(h('p', { class: 'note' }, t('banner.readOnly')))
2236
+ if (!on) {
2237
+ kids.push(h('p', null, t('hp.intro')))
2238
+ kids.push(h('ol', { class: 'hp-how' }, h('li', null, t('hp.how1')), h('li', null, t('hp.how2', { p: st.settings.threshold }), hpWaitRule(st)), h('li', null, t('hp.how3', { s: st.settings.countdownSeconds }))))
2239
+ if (!S.state.accounts.some((a) => a.kind !== 'api' && st.starters.includes(a.family))) kids.push(h('p', { class: 'note' }, t('hp.noStarters')))
2240
+ else {
2241
+ if (!hp.preview) {
2242
+ // Until the person touches the box it shows what is set now, so turning on again keeps an earlier choice.
2243
+ if (!hp.sameTouched) hp.sameFamily = st.settings.sameFamily
2244
+ kids.push(hpSameFamilyBox(hp.sameFamily, (v) => { hp.sameFamily = v; hp.sameTouched = true }))
2245
+ kids.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', disabled: BOOT.readOnly || hp.previewing, 'aria-busy': hp.previewing ? 'true' : null, onclick: hpPreview }, hp.previewing ? spinner() : null, t('hp.turnOn'))))
2246
+ } else kids.push(hpPreviewCard())
2247
+ }
2248
+ } else {
2249
+ kids.push(h('p', null, h('span', { class: 'chip chip-ok' }, t('ah.on')), ' ', t('hp.onNote', { p: st.settings.threshold }), hpWaitRule(st)))
2250
+ const d = hp.data
2251
+ if (d) {
2252
+ kids.push(h('h3', { class: 'small' }, t('hp.takeover')), hpRows(d))
2253
+ kids.push(h('h3', { class: 'small' }, t('hp.recent')), hpRecent(d))
2254
+ }
2255
+ const plan = hp.plan
2256
+ if (plan && (plan.blocked || plan.steps.length)) {
2257
+ kids.push(h('h3', { class: 'small' }, t('hp.missing')))
2258
+ if (!hp.preview) {
2259
+ if (plan.blocked) kids.push(h('p', { class: 'note note-fail' }, t('hp.blocked', { why: plan.blocked })), codeCopy('npm i -g sideby'))
2260
+ else {
2261
+ kids.push(hpSteps(plan))
2262
+ kids.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', disabled: BOOT.readOnly || hp.previewing, onclick: hpPreview }, hp.previewing ? spinner() : null, t('hp.complete'))))
2263
+ }
2264
+ } else kids.push(hpPreviewCard())
2265
+ } else if (plan) kids.push(...hpManual(plan))
2266
+ kids.push(h('p', { class: 'muted small' }, t('hp.onlySideby')))
2267
+ kids.push(hpSameFamilyBox(st.settings.sameFamily, (v) => {
2268
+ if (v) { hp.askSame = true; renderHandoffPage() } else { hp.askSame = false; hpChange({ sameFamily: false }, t('hp.sameOff')) }
2269
+ }))
2270
+ if (hp.askSame && !st.settings.sameFamily) kids.push(h('div', { class: 'ah-fam' },
2271
+ h('p', { class: 'note note-warn' }, t('ah.sameFamilyWarn')),
2272
+ h('div', { class: 'row-actions' },
2273
+ h('button', { class: 'btn', type: 'button', disabled: hp.busyOff, onclick: () => { hp.askSame = false; renderHandoffPage() } }, t('btn.cancel')),
2274
+ h('button', { class: 'btn btn-primary', type: 'button', disabled: BOOT.readOnly || hp.busyOff, onclick: () => { hp.askSame = false; hpChange({ sameFamily: true }, t('hp.sameOn')) } }, t('hp.sameConfirm')))))
2275
+ kids.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn', type: 'button', disabled: BOOT.readOnly || hp.busyOff, onclick: () => hpChange({ auto: false }, t('toast.ahOff')) }, hp.busyOff ? spinner() : null, t('hp.turnOff'))))
2276
+ }
2277
+ kids.push(hpFailed())
2278
+ setKids(box, kids)
2279
+ }
2280
+
1967
2281
  // ---------- banners ----------
1968
2282
  function renderBanners() {
1969
2283
  const out = []
@@ -1976,7 +2290,7 @@ const MAIN = String.raw `
1976
2290
  if (hint) out.push(h('div', { class: 'banner', role: 'status' },
1977
2291
  h('div', null, t('ah.hint', { ref: hint.ref, p: hint.pressure })),
1978
2292
  h('div', { class: 'banner-actions' },
1979
- h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openHandoff }, t('ah.setUp')),
2293
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => setPage('handoff') }, t('ah.setUp')),
1980
2294
  h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
1981
2295
  const errs = S.state.pluginErrors
1982
2296
  if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
@@ -2061,7 +2375,7 @@ const MAIN = String.raw `
2061
2375
  // ---------- start ----------
2062
2376
  $('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
2063
2377
  $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
2064
- $('handoff').addEventListener('click', openHandoff)
2378
+ $('handoff').addEventListener('click', () => setPage('handoff'))
2065
2379
  $('lang').addEventListener('click', () => {
2066
2380
  if (LANG_FIXED) return
2067
2381
  lang = lang === 'zh' ? 'en' : 'zh'
@@ -2073,6 +2387,7 @@ const MAIN = String.raw `
2073
2387
  if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
2074
2388
  else if (dlg.kind === 'setup' && qs) renderSetup()
2075
2389
  else if (dlg.kind === 'tune' && tuneState) renderTune()
2390
+ else if (dlg.kind === 'model' && md) { md.form = null; renderModel() }
2076
2391
  })
2077
2392
  $('theme').addEventListener('click', () => {
2078
2393
  const order = CHOICES.theme