sideby 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -16
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +4 -1
- package/dist/src/cli/commands.js +50 -5
- package/dist/src/cli/json-schemas.js +31 -1
- package/dist/src/cli/main.js +15 -5
- package/dist/src/core/app-profiles.d.ts +50 -0
- package/dist/src/core/app-profiles.js +206 -0
- package/dist/src/core/brand-mark.d.ts +29 -0
- package/dist/src/core/brand-mark.js +42 -0
- package/dist/src/core/desktop-app.d.ts +18 -1
- package/dist/src/core/desktop-app.js +22 -12
- package/dist/src/core/handoff-chain.d.ts +16 -2
- package/dist/src/core/handoff-chain.js +19 -6
- package/dist/src/core/handoff-run.js +43 -18
- package/dist/src/core/handoff-select.d.ts +4 -0
- package/dist/src/core/handoff-select.js +3 -2
- package/dist/src/desktop/icon.js +33 -33
- package/dist/src/families/claude/handoff.js +10 -0
- package/dist/src/families/desktop.d.ts +25 -0
- package/dist/src/families/desktop.js +8 -2
- package/dist/src/families/grok/bot.d.ts +8 -0
- package/dist/src/families/grok/bot.js +18 -0
- package/dist/src/handoff/hook.js +21 -4
- package/dist/src/handoff/messages.d.ts +4 -0
- package/dist/src/handoff/messages.js +12 -0
- package/dist/src/panel/handler.d.ts +3 -0
- package/dist/src/panel/handler.js +37 -1
- package/dist/src/panel/i18n.d.ts +30 -0
- package/dist/src/panel/i18n.js +60 -0
- package/dist/src/panel/page-script.js +149 -5
- package/dist/src/panel/page-styles.js +15 -0
- package/dist/src/panel/page.js +2 -1
- package/dist/src/runtime.d.ts +3 -1
- package/dist/src/runtime.js +7 -0
- package/dist/src/types.d.ts +5 -0
- package/package.json +2 -2
- package/schemas/desktop-remove.json +33 -0
- package/schemas/desktop.json +2 -1
- package/schemas/handoff-ready.json +4 -0
- package/schemas/list.json +47 -0
- package/skills/sideby/SKILL.md +3 -1
- package/skills/sideby/references/handoff.md +1 -1
|
@@ -12,4 +12,8 @@ declare const WHY: {
|
|
|
12
12
|
};
|
|
13
13
|
/** The next session's first prompt: the Brief's path only, never its text (it would show in a process list). */
|
|
14
14
|
export declare function firstPrompt(fromRef: string, brief: string, trigger: keyof typeof WHY): string;
|
|
15
|
+
/** For the user, not the model (ADR-0018): this session will wait for the reset, and how to move on now instead. */
|
|
16
|
+
export declare function waitNotice(ref: string, until: string, pressure?: number): string;
|
|
17
|
+
/** A desktop notification through the Host (OSC 9 and OSC 777), the only output `StopFailure` keeps. */
|
|
18
|
+
export declare function notifySequence(text: string): string;
|
|
15
19
|
export {};
|
|
@@ -34,3 +34,15 @@ const WHY = {
|
|
|
34
34
|
export function firstPrompt(fromRef, brief, trigger) {
|
|
35
35
|
return `This task was handed over from ${fromRef} by sideby because ${WHY[trigger]}. Read ${brief} first, then continue the task it describes. If the user asks to move the task to another account (back to ${fromRef}, say), run \`sideby handoff ready --to <account>\` and end your turn.`;
|
|
36
36
|
}
|
|
37
|
+
const clock = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
38
|
+
/** For the user, not the model (ADR-0018): this session will wait for the reset, and how to move on now instead. */
|
|
39
|
+
export function waitNotice(ref, until, pressure) {
|
|
40
|
+
const now = pressure === undefined ? 'hit its limit' : `is at ${Math.round(pressure)}%`;
|
|
41
|
+
const then = pressure === undefined ? 'this session waits' : 'when it runs out this session waits';
|
|
42
|
+
return `sideby: ${ref} ${now}; its quota is back at ${clock(until)}, so ${then} and goes on by itself instead of moving to another account. To move on now, type: ! sideby handoff ready`;
|
|
43
|
+
}
|
|
44
|
+
/** A desktop notification through the Host (OSC 9 and OSC 777), the only output `StopFailure` keeps. */
|
|
45
|
+
export function notifySequence(text) {
|
|
46
|
+
const safe = [...text].map((ch) => (ch < ' ' || ch === '\u007f' ? ' ' : ch)).join('');
|
|
47
|
+
return `\u001b]9;${safe}\u0007\u001b]777;notify;sideby;${safe}\u0007`;
|
|
48
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type AppProfileStatus } from '../core/app-profiles.ts';
|
|
2
3
|
import { type HandoffSettings } from '../core/config.ts';
|
|
3
4
|
import { type AccountModel } from '../core/model-choice.ts';
|
|
4
5
|
import type { RemoteControlGroup } from '../core/remote-control.ts';
|
|
@@ -97,6 +98,8 @@ export interface PanelState {
|
|
|
97
98
|
};
|
|
98
99
|
/** Claude settings files grouped as `sideby remote-control` shows them (spec §3.22); absent without Claude Accounts. */
|
|
99
100
|
remoteControl?: RemoteControlView[];
|
|
101
|
+
/** Grok Bot windows (ADR-0019), data folders shown as `~/…`; absent when there are none to show. */
|
|
102
|
+
appProfiles?: AppProfileStatus[];
|
|
100
103
|
}
|
|
101
104
|
/** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
|
|
102
105
|
export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// HTTP handler for the local Panel. Mountable under any basePath; standalone and embedded use the same rules.
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { aliasesByAccount } from '../core/accounts.js';
|
|
4
|
+
import { listAppProfiles, openAppProfile, removeAppProfile, } from '../core/app-profiles.js';
|
|
4
5
|
import { aliasArgs, aliasRefs } from '../core/config.js';
|
|
5
6
|
import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
|
|
6
7
|
import { UserError } from '../core/errors.js';
|
|
@@ -212,7 +213,10 @@ async function buildState(rt, opts) {
|
|
|
212
213
|
const health = healthByAccount(history);
|
|
213
214
|
const aliases = aliasesByAccount(aliasRefs(rt.config.aliases), accounts);
|
|
214
215
|
// Desktop Apps (spec §3.20): one process listing for every Account, run alongside the per-Account reads.
|
|
215
|
-
const states = await
|
|
216
|
+
const [states, appProfiles] = await Promise.all([
|
|
217
|
+
withDesktopApps(rt, accounts, Promise.all(accounts.map((a) => accountState(rt, a, families)))),
|
|
218
|
+
listAppProfiles(rt),
|
|
219
|
+
]);
|
|
216
220
|
const list = states.map((st) => {
|
|
217
221
|
const h = health.get(st.ref);
|
|
218
222
|
const names = aliases.get(st.ref);
|
|
@@ -244,6 +248,11 @@ async function buildState(rt, opts) {
|
|
|
244
248
|
...(rt.config.aliases ? { configAliases: aliasRefs(rt.config.aliases) } : {}),
|
|
245
249
|
...(Object.keys(handoff).length ? { handoff } : {}),
|
|
246
250
|
...(remoteControl.length ? { remoteControl: rcView(remoteControl, ctx.home) } : {}),
|
|
251
|
+
...(appProfiles.length
|
|
252
|
+
? {
|
|
253
|
+
appProfiles: appProfiles.map((p) => p.appData ? { ...p, appData: tildify(p.appData, rt.paths.home) } : p),
|
|
254
|
+
}
|
|
255
|
+
: {}),
|
|
247
256
|
autoHandoff: {
|
|
248
257
|
settings: rt.handoffSettings(),
|
|
249
258
|
starters: [...rt.families.values()]
|
|
@@ -479,6 +488,33 @@ export function createPanelHandler(opts) {
|
|
|
479
488
|
};
|
|
480
489
|
},
|
|
481
490
|
},
|
|
491
|
+
// Opens a Grok Bot window, creating it when the name is new, or brings it to the front (ADR-0019).
|
|
492
|
+
'/api/app-profile/open': {
|
|
493
|
+
async run(body) {
|
|
494
|
+
const ref = optString(body, 'ref');
|
|
495
|
+
if (!ref)
|
|
496
|
+
throw new HttpError(400, '"ref" is required');
|
|
497
|
+
const rt = await getRuntime();
|
|
498
|
+
const r = await openAppProfile(rt, ref);
|
|
499
|
+
const home = rt.paths.home;
|
|
500
|
+
return {
|
|
501
|
+
...r,
|
|
502
|
+
appPath: tildify(r.appPath, home),
|
|
503
|
+
...(r.appData ? { appData: tildify(r.appData, home) } : {}),
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
// Removes a Grok Bot window's data folder (ADR-0019). The page asks first, so this call is the confirmation.
|
|
508
|
+
'/api/app-profile/remove': {
|
|
509
|
+
async run(body) {
|
|
510
|
+
const ref = optString(body, 'ref');
|
|
511
|
+
if (!ref)
|
|
512
|
+
throw new HttpError(400, '"ref" is required');
|
|
513
|
+
const rt = await getRuntime();
|
|
514
|
+
const r = await removeAppProfile(rt, ref, { yes: true });
|
|
515
|
+
return { ...r, appData: tildify(r.appData, rt.paths.home) };
|
|
516
|
+
},
|
|
517
|
+
},
|
|
482
518
|
// The Auto Handoff page (spec §3.17): where work would go next and the last hand-overs. Reads only.
|
|
483
519
|
'/api/handoff/overview': {
|
|
484
520
|
writes: () => false,
|
package/dist/src/panel/i18n.d.ts
CHANGED
|
@@ -247,6 +247,36 @@ declare const en: {
|
|
|
247
247
|
'desk.started': string;
|
|
248
248
|
'desk.focused': string;
|
|
249
249
|
'desk.failed': string;
|
|
250
|
+
'app.apart': string;
|
|
251
|
+
'app.apartTip': string;
|
|
252
|
+
'app.mainName': string;
|
|
253
|
+
'app.mainData': string;
|
|
254
|
+
'app.isOpen': string;
|
|
255
|
+
'app.signedIn': string;
|
|
256
|
+
'app.signedOut': string;
|
|
257
|
+
'app.signedInHint': string;
|
|
258
|
+
'app.openBtn': string;
|
|
259
|
+
'app.show': string;
|
|
260
|
+
'app.openHint': string;
|
|
261
|
+
'app.showHint': string;
|
|
262
|
+
'app.started': string;
|
|
263
|
+
'app.failed': string;
|
|
264
|
+
'app.remove': string;
|
|
265
|
+
'app.removeHint': string;
|
|
266
|
+
'app.removeRunning': string;
|
|
267
|
+
'app.removeAsk': string;
|
|
268
|
+
'app.removeYes': string;
|
|
269
|
+
'app.removing': string;
|
|
270
|
+
'app.removed': string;
|
|
271
|
+
'app.removeFailed': string;
|
|
272
|
+
'app.another': string;
|
|
273
|
+
'app.anotherHint': string;
|
|
274
|
+
'app.nameLabel': string;
|
|
275
|
+
'app.create': string;
|
|
276
|
+
'app.newHint': string;
|
|
277
|
+
'app.exists': string;
|
|
278
|
+
'app.nameEmpty': string;
|
|
279
|
+
'app.nameBad': string;
|
|
250
280
|
'md.title': string;
|
|
251
281
|
'md.intro': string;
|
|
252
282
|
'md.model': string;
|
package/dist/src/panel/i18n.js
CHANGED
|
@@ -249,6 +249,36 @@ const en = {
|
|
|
249
249
|
'desk.started': 'Opened {app} as {ref}.',
|
|
250
250
|
'desk.focused': '{app} for {ref} is in front.',
|
|
251
251
|
'desk.failed': 'Could not open the desktop app for {ref}: {msg}',
|
|
252
|
+
'app.apart': 'Signs in on its own',
|
|
253
|
+
'app.apartTip': '{app} signs in with a Cursor account, not with any account above. Each window here keeps its own sign-in, so several can be open at once.',
|
|
254
|
+
'app.mainName': 'main',
|
|
255
|
+
'app.mainData': 'the app’s own data',
|
|
256
|
+
'app.isOpen': 'Open',
|
|
257
|
+
'app.signedIn': 'Signed in',
|
|
258
|
+
'app.signedOut': 'Not signed in',
|
|
259
|
+
'app.signedInHint': 'What the app last reported. Which account is signed in is not shown: sideby does not read sign-in files.',
|
|
260
|
+
'app.openBtn': 'Open',
|
|
261
|
+
'app.show': 'Switch to it',
|
|
262
|
+
'app.openHint': 'Open {app} as {ref}, in its own window next to the others',
|
|
263
|
+
'app.showHint': '{app} is open as {ref}; click to bring it to the front',
|
|
264
|
+
'app.started': 'Opened {app} as {ref}.',
|
|
265
|
+
'app.failed': 'Could not open {ref}: {msg}',
|
|
266
|
+
'app.remove': 'Remove',
|
|
267
|
+
'app.removeHint': 'Remove this {app} window: signs it out and deletes its local data on this Mac',
|
|
268
|
+
'app.removeRunning': 'Quit this {app} window first, then remove it',
|
|
269
|
+
'app.removeAsk': 'Remove {ref}? This signs that window out and deletes its local data on this Mac.',
|
|
270
|
+
'app.removeYes': 'Remove',
|
|
271
|
+
'app.removing': 'Removing…',
|
|
272
|
+
'app.removed': 'Removed {ref}.',
|
|
273
|
+
'app.removeFailed': 'Could not remove {ref}: {msg}',
|
|
274
|
+
'app.another': 'Open another {app}',
|
|
275
|
+
'app.anotherHint': 'A new window with its own sign-in.',
|
|
276
|
+
'app.nameLabel': 'Name for the new window',
|
|
277
|
+
'app.create': 'Create and open',
|
|
278
|
+
'app.newHint': 'Sign in inside the new window with the account you want for it. Close the other {app} windows while you sign in.',
|
|
279
|
+
'app.exists': '{ref} already exists; this opens it.',
|
|
280
|
+
'app.nameEmpty': 'Enter a name.',
|
|
281
|
+
'app.nameBad': 'Use lowercase letters, digits and dashes, starting with a letter or digit.',
|
|
252
282
|
'md.title': 'Model and effort',
|
|
253
283
|
'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.',
|
|
254
284
|
'md.model': 'Model',
|
|
@@ -728,6 +758,36 @@ const zh = {
|
|
|
728
758
|
'desk.started': '已以 {ref} 打开 {app}。',
|
|
729
759
|
'desk.focused': '{ref} 的 {app} 已切到前台。',
|
|
730
760
|
'desk.failed': '没能打开 {ref} 的桌面应用:{msg}',
|
|
761
|
+
'app.apart': '单独登录',
|
|
762
|
+
'app.apartTip': '{app} 用 Cursor 账号登录,和上面的账号都无关。这里每个窗口各自保存登录,可以同时开几个。',
|
|
763
|
+
'app.mainName': 'main',
|
|
764
|
+
'app.mainData': '应用自己的数据',
|
|
765
|
+
'app.isOpen': '已打开',
|
|
766
|
+
'app.signedIn': '已登录',
|
|
767
|
+
'app.signedOut': '未登录',
|
|
768
|
+
'app.signedInHint': '应用最后一次报告的状态。不显示登录的是哪个账号:sideby 不读登录文件。',
|
|
769
|
+
'app.openBtn': '打开',
|
|
770
|
+
'app.show': '切到窗口',
|
|
771
|
+
'app.openHint': '以 {ref} 打开 {app},和其他窗口同时开着',
|
|
772
|
+
'app.showHint': '{app} 已经以 {ref} 打开,点一下切到前台',
|
|
773
|
+
'app.started': '已以 {ref} 打开 {app}。',
|
|
774
|
+
'app.failed': '没能打开 {ref}:{msg}',
|
|
775
|
+
'app.remove': '删除',
|
|
776
|
+
'app.removeHint': '删除这个 {app} 窗口:退出它的登录,删掉它在这台 Mac 上的数据',
|
|
777
|
+
'app.removeRunning': '先退出这个 {app} 窗口,再删除',
|
|
778
|
+
'app.removeAsk': '删除 {ref}?会退出这个窗口的登录,删掉它在这台 Mac 上的数据。',
|
|
779
|
+
'app.removeYes': '删除',
|
|
780
|
+
'app.removing': '正在删除…',
|
|
781
|
+
'app.removed': '已删除 {ref}。',
|
|
782
|
+
'app.removeFailed': '没能删除 {ref}:{msg}',
|
|
783
|
+
'app.another': '再开一个 {app}',
|
|
784
|
+
'app.anotherHint': '新窗口单独登录。',
|
|
785
|
+
'app.nameLabel': '新窗口的名字',
|
|
786
|
+
'app.create': '新建并打开',
|
|
787
|
+
'app.newHint': '在新窗口里用你想给它的账号登录。登录时先关掉其他 {app} 窗口。',
|
|
788
|
+
'app.exists': '{ref} 已经有了,点了会直接打开它。',
|
|
789
|
+
'app.nameEmpty': '请填一个名字。',
|
|
790
|
+
'app.nameBad': '只能用小写字母、数字和横线,以字母或数字开头。',
|
|
731
791
|
'md.title': '模型和 effort',
|
|
732
792
|
'md.intro': '只影响之后用 sideby 启动的 {ref} 新会话(短命令或 sideby run)。保存在 sideby 的配置里,不改 {host} 自己的设置。',
|
|
733
793
|
'md.model': '模型',
|
|
@@ -53,7 +53,7 @@ const MAIN = String.raw `
|
|
|
53
53
|
silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
|
|
54
54
|
}
|
|
55
55
|
// View state that is not saved: the search text, the attention filter, the Health check filter.
|
|
56
|
-
const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, highlight: null, fade: true }
|
|
56
|
+
const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true }
|
|
57
57
|
const $ = (id) => document.getElementById(id)
|
|
58
58
|
|
|
59
59
|
// ---------- preferences ----------
|
|
@@ -254,6 +254,7 @@ const MAIN = String.raw `
|
|
|
254
254
|
check: 'M9 12l2 2 4-4M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
|
|
255
255
|
userPlus: 'M15 19a6 6 0 0 0-12 0M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM19 8v6M16 11h6',
|
|
256
256
|
x: 'M6 6l12 12M18 6L6 18',
|
|
257
|
+
bot: 'M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1',
|
|
257
258
|
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
|
|
258
259
|
list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
|
|
259
260
|
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
@@ -1068,6 +1069,147 @@ const MAIN = String.raw `
|
|
|
1068
1069
|
const list = needing.map((a) => a.name + ': ' + whyOf(a, now)).join('\n')
|
|
1069
1070
|
return h('button', { class: 'tag tag-warn tag-btn', type: 'button', title: list, 'aria-pressed': String(ui.attention), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } }, tn('group.attention', needing.length))
|
|
1070
1071
|
}
|
|
1072
|
+
// ---------- Grok Bot windows (ADR-0019) ----------
|
|
1073
|
+
// Windows of an app that signs in apart from every account, so they get a group of their own after the accounts.
|
|
1074
|
+
// A new window gets the next free three-digit name; removing one asks inside its row first.
|
|
1075
|
+
const APP_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
1076
|
+
// A plain bot glyph, not the grok mark: these windows are not grok (Grok Build) accounts.
|
|
1077
|
+
function appBadge() { return h('span', { class: 'badge badge-sm badge-logo', 'aria-hidden': 'true' }, icon('bot')) }
|
|
1078
|
+
function appProfiles() { return (S.state && S.state.appProfiles) || [] }
|
|
1079
|
+
function appId(ref) { return String(ref).slice(0, String(ref).indexOf(':')) }
|
|
1080
|
+
function nextAppName(list) {
|
|
1081
|
+
const taken = new Set(list.map((p) => p.name))
|
|
1082
|
+
for (let i = 1; i < 1000; i++) { const n = String(i).padStart(3, '0'); if (!taken.has(n)) return n }
|
|
1083
|
+
return ''
|
|
1084
|
+
}
|
|
1085
|
+
function appNameProblem(name) {
|
|
1086
|
+
if (!name) return t('app.nameEmpty')
|
|
1087
|
+
return APP_NAME.test(name) ? '' : t('app.nameBad')
|
|
1088
|
+
}
|
|
1089
|
+
function appRow(p) {
|
|
1090
|
+
const busy = ui.appBusy === p.ref
|
|
1091
|
+
const asking = ui.appRemove === p.ref
|
|
1092
|
+
const can = !BOOT.readOnly && p.installed
|
|
1093
|
+
const actions = !can ? null : asking
|
|
1094
|
+
? h('div', { class: 'row-actions app-actions', role: 'group', 'aria-label': t('app.removeAsk', { ref: p.ref }) },
|
|
1095
|
+
h('span', { class: 'small' }, t('app.removeAsk', { ref: p.ref })),
|
|
1096
|
+
h('button', { class: 'btn btn-sm btn-danger', type: 'button', disabled: busy, onclick: () => removeApp(p) }, t(busy ? 'app.removing' : 'app.removeYes')),
|
|
1097
|
+
h('button', { class: 'btn btn-sm btn-ghost', type: 'button', disabled: busy, onclick: () => { ui.appRemove = null; renderAccounts() } }, t('btn.cancel')))
|
|
1098
|
+
: h('div', { class: 'row-actions app-actions' },
|
|
1099
|
+
h('button', { class: 'btn btn-sm' + (p.running ? '' : ' btn-primary'), type: 'button', disabled: Boolean(ui.appBusy), title: t(p.running ? 'app.showHint' : 'app.openHint', { app: p.app, ref: p.ref }), 'aria-label': t(p.running ? 'app.show' : 'app.openBtn') + ' (' + p.ref + ')', onclick: () => openApp(p.ref) },
|
|
1100
|
+
busy ? t('desk.opening', { app: p.app }) : t(p.running ? 'app.show' : 'app.openBtn')),
|
|
1101
|
+
p.isMain ? null : h('button', { class: 'btn btn-sm btn-ghost', type: 'button', disabled: p.running || Boolean(ui.appBusy), title: t(p.running ? 'app.removeRunning' : 'app.removeHint', { app: p.app }), 'aria-label': t('app.remove') + ' (' + p.ref + ')', onclick: () => { ui.appRemove = p.ref; renderAccounts() } }, t('app.remove')))
|
|
1102
|
+
return h('div', { class: 'app-row', role: 'listitem', 'data-ref': p.ref, 'data-key': p.ref, 'data-sig': sig([p, busy, asking, Boolean(ui.appBusy)]) },
|
|
1103
|
+
h('div', { class: 'ident' }, appBadge(), h('div', { class: 'ident-text' },
|
|
1104
|
+
h('div', { class: 'ident-name' },
|
|
1105
|
+
h('span', { class: 'name' }, p.isMain ? t('app.mainName') : p.name),
|
|
1106
|
+
p.running ? h('span', { class: 'tag tag-ok' }, h('span', { class: 'dot dot-ok', 'aria-hidden': 'true' }), t('app.isOpen')) : null,
|
|
1107
|
+
p.signedIn === true ? h('span', { class: 'tag', title: t('app.signedInHint') }, t('app.signedIn'))
|
|
1108
|
+
: p.signedIn === false ? h('span', { class: 'tag tag-warn', title: t('app.signedInHint') }, t('app.signedOut')) : null),
|
|
1109
|
+
h('div', { class: 'ident-sub' }, dots([h('span', { class: 'mono' }, p.ref), h('span', { class: 'muted' }, p.isMain ? t('app.mainData') : p.appData)])))),
|
|
1110
|
+
actions)
|
|
1111
|
+
}
|
|
1112
|
+
function appFoot(list) {
|
|
1113
|
+
if (BOOT.readOnly || !list[0].installed) return null
|
|
1114
|
+
const n = ui.appNew
|
|
1115
|
+
if (!n)
|
|
1116
|
+
return h('div', { class: 'app-foot', 'data-key': 'foot' },
|
|
1117
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(ui.appBusy), onclick: () => {
|
|
1118
|
+
ui.appNew = { name: nextAppName(list) }
|
|
1119
|
+
renderAccounts()
|
|
1120
|
+
const el = $('app-new-name')
|
|
1121
|
+
if (el) { el.focus(); el.select() }
|
|
1122
|
+
} }, icon('plus'), t('app.another', { app: list[0].app })),
|
|
1123
|
+
h('span', { class: 'muted small' }, t('app.anotherHint')))
|
|
1124
|
+
const input = h('input', { id: 'app-new-name', type: 'text', class: 'mono', value: n.name, maxlength: '32', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', 'aria-label': t('app.nameLabel'), 'aria-describedby': 'app-new-err' })
|
|
1125
|
+
const err = h('span', { id: 'app-new-err', class: 'field-error', role: 'status' })
|
|
1126
|
+
const go = h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: Boolean(appNameProblem(n.name)) || Boolean(ui.appBusy), onclick: () => createApp(list) }, t('app.create'))
|
|
1127
|
+
const sync = () => {
|
|
1128
|
+
n.name = input.value.trim()
|
|
1129
|
+
const problem = appNameProblem(n.name)
|
|
1130
|
+
const exists = list.some((p) => p.name === n.name)
|
|
1131
|
+
err.textContent = problem && input.value ? problem : exists ? t('app.exists', { ref: appId(list[0].ref) + ':' + n.name }) : ''
|
|
1132
|
+
err.classList.toggle('field-note', !problem && exists)
|
|
1133
|
+
input.classList.toggle('invalid', Boolean(problem && input.value))
|
|
1134
|
+
go.disabled = Boolean(problem) || Boolean(ui.appBusy)
|
|
1135
|
+
}
|
|
1136
|
+
input.addEventListener('input', sync)
|
|
1137
|
+
input.addEventListener('keydown', (e) => {
|
|
1138
|
+
if (e.key === 'Enter' && !appNameProblem(n.name)) { e.preventDefault(); createApp(list) }
|
|
1139
|
+
if (e.key === 'Escape') { ui.appNew = null; renderAccounts() }
|
|
1140
|
+
})
|
|
1141
|
+
return h('div', { class: 'app-foot', 'data-key': 'foot-new' },
|
|
1142
|
+
h('label', { for: 'app-new-name', class: 'small' }, t('app.nameLabel')),
|
|
1143
|
+
h('div', { class: 'app-new' }, input, go, h('button', { class: 'btn btn-sm btn-ghost', type: 'button', onclick: () => { ui.appNew = null; renderAccounts() } }, t('btn.cancel'))),
|
|
1144
|
+
err,
|
|
1145
|
+
h('span', { class: 'muted small' }, t('app.newHint', { app: list[0].app })))
|
|
1146
|
+
}
|
|
1147
|
+
function appGroup(list) {
|
|
1148
|
+
const app = list[0].app
|
|
1149
|
+
const collapsed = prefs.collapsed['app:' + app] === true
|
|
1150
|
+
const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1151
|
+
const head = h('div', { class: 'group-head', 'data-key': 'head' },
|
|
1152
|
+
h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: app, count: String(list.length), action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => {
|
|
1153
|
+
if (collapsed) delete prefs.collapsed['app:' + app]; else prefs.collapsed['app:' + app] = true
|
|
1154
|
+
savePrefs()
|
|
1155
|
+
renderAccounts()
|
|
1156
|
+
} },
|
|
1157
|
+
icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
|
|
1158
|
+
h('span', { class: 'group-count' }, String(list.length))),
|
|
1159
|
+
h('div', { class: 'group-meta' },
|
|
1160
|
+
h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
|
|
1161
|
+
list[0].installed ? null : h('div', { class: 'group-meta' }, h('span', { class: 'tag tag-warn' }, t('acct.notInstalled'))))
|
|
1162
|
+
const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map(appRow))
|
|
1163
|
+
return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-patch': '' },
|
|
1164
|
+
head, body, collapsed ? null : appFoot(list))
|
|
1165
|
+
}
|
|
1166
|
+
// Shown with the account groups unless a family filter or the attention filter is on; the search matches names.
|
|
1167
|
+
function appGroups() {
|
|
1168
|
+
if (prefs.family || ui.attention) return []
|
|
1169
|
+
const q = ui.query.trim().toLowerCase()
|
|
1170
|
+
const out = []
|
|
1171
|
+
for (const app of new Set(appProfiles().map((p) => p.app))) {
|
|
1172
|
+
const all = appProfiles().filter((p) => p.app === app)
|
|
1173
|
+
const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
|
|
1174
|
+
if (list.length) out.push(appGroup(list))
|
|
1175
|
+
}
|
|
1176
|
+
return out
|
|
1177
|
+
}
|
|
1178
|
+
async function openApp(ref) {
|
|
1179
|
+
if (ui.appBusy) return
|
|
1180
|
+
ui.appBusy = ref
|
|
1181
|
+
renderAccounts()
|
|
1182
|
+
let ok = false
|
|
1183
|
+
try {
|
|
1184
|
+
const r = await api('/api/app-profile/open', { ref })
|
|
1185
|
+
const text = r.action === 'started' ? t('app.started', { app: r.app, ref: r.ref }) : r.action === 'focused' ? t('desk.focused', { app: r.app, ref: r.ref }) : ''
|
|
1186
|
+
if (text) toast(text)
|
|
1187
|
+
for (const m of r.notices || []) toast(m, { kind: 'info', duration: 12000 })
|
|
1188
|
+
ok = true
|
|
1189
|
+
} catch (e) { toast(t('app.failed', { ref, msg: e.message }), { kind: 'fail' }) }
|
|
1190
|
+
ui.appBusy = null
|
|
1191
|
+
if (ok) ui.appNew = null
|
|
1192
|
+
renderAccounts()
|
|
1193
|
+
load({ silent: true })
|
|
1194
|
+
}
|
|
1195
|
+
function createApp(list) {
|
|
1196
|
+
const n = ui.appNew
|
|
1197
|
+
if (!n || appNameProblem(n.name)) return
|
|
1198
|
+
openApp(appId(list[0].ref) + ':' + n.name)
|
|
1199
|
+
}
|
|
1200
|
+
async function removeApp(p) {
|
|
1201
|
+
if (ui.appBusy) return
|
|
1202
|
+
ui.appBusy = p.ref
|
|
1203
|
+
renderAccounts()
|
|
1204
|
+
try {
|
|
1205
|
+
await api('/api/app-profile/remove', { ref: p.ref })
|
|
1206
|
+
toast(t('app.removed', { ref: p.ref }))
|
|
1207
|
+
} catch (e) { toast(t('app.removeFailed', { ref: p.ref, msg: e.message }), { kind: 'fail' }) }
|
|
1208
|
+
ui.appBusy = null
|
|
1209
|
+
ui.appRemove = null
|
|
1210
|
+
renderAccounts()
|
|
1211
|
+
load({ silent: true })
|
|
1212
|
+
}
|
|
1071
1213
|
function group(fam, list, total, now, cols) {
|
|
1072
1214
|
const info = family(fam)
|
|
1073
1215
|
const filtering = Boolean(ui.query.trim()) || ui.attention
|
|
@@ -1104,7 +1246,8 @@ const MAIN = String.raw `
|
|
|
1104
1246
|
function emptyState() {
|
|
1105
1247
|
const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
|
|
1106
1248
|
return h('div', { class: 'empty' },
|
|
1107
|
-
|
|
1249
|
+
// The header's mark, cloned (page.ts draws it from core/brand-mark.ts).
|
|
1250
|
+
h('span', { class: 'logo', 'aria-hidden': 'true' }, document.querySelector('.brand .logo svg')?.cloneNode(true) ?? null),
|
|
1108
1251
|
h('h3', null, t('empty.title')),
|
|
1109
1252
|
h('p', { class: 'muted' }, t('empty.body')),
|
|
1110
1253
|
fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
|
|
@@ -1145,13 +1288,14 @@ const MAIN = String.raw `
|
|
|
1145
1288
|
const all = S.state.accounts
|
|
1146
1289
|
const now = Date.now()
|
|
1147
1290
|
$('toolbar').hidden = !all.length
|
|
1148
|
-
if (!all.length) { setKids(count, null); patch(box, emptyState()); return }
|
|
1291
|
+
if (!all.length) { setKids(count, null); patch(box, [emptyState()].concat(appGroups())); return }
|
|
1149
1292
|
const shown = all.filter((a) => (!prefs.family || a.family === prefs.family) && matchesQuery(a, ui.query, famTitle(a)) && (!ui.attention || reasonsOf(a, now).length))
|
|
1150
1293
|
const filtered = shown.length !== all.length
|
|
1151
1294
|
patch(count, filtered
|
|
1152
1295
|
? [t('count.filtered', { n: shown.length, total: all.length }), ' ', h('button', { class: 'link-btn', type: 'button', onclick: clearFilters }, t('filter.clear'))]
|
|
1153
1296
|
: tn('count.accounts', all.length))
|
|
1154
|
-
|
|
1297
|
+
const apps = appGroups()
|
|
1298
|
+
if (!shown.length && !apps.length) { patch(box, noMatch()); return }
|
|
1155
1299
|
const cols = quotaColumns(shown)
|
|
1156
1300
|
box.style.setProperty('--qn', String(cols.length))
|
|
1157
1301
|
const ids = S.state.families.map((f) => f.id)
|
|
@@ -1161,7 +1305,7 @@ const MAIN = String.raw `
|
|
|
1161
1305
|
const list = shown.filter((a) => a.family === fam)
|
|
1162
1306
|
if (list.length) groups.push(group(fam, sortAccounts(list, prefs.sort, now), all.filter((a) => a.family === fam).length, now, cols))
|
|
1163
1307
|
}
|
|
1164
|
-
patch(box, groups)
|
|
1308
|
+
patch(box, groups.concat(apps))
|
|
1165
1309
|
if (ui.fade && !REDUCED) {
|
|
1166
1310
|
box.classList.add('view-in')
|
|
1167
1311
|
setTimeout(() => box.classList.remove('view-in'), 400)
|
|
@@ -263,6 +263,7 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
263
263
|
.badge svg { display: block; width: 20px; height: 20px; fill: currentColor; }
|
|
264
264
|
.badge-sm { width: 26px; height: 26px; border-radius: 7px; font-size: 10.5px; }
|
|
265
265
|
.badge-sm svg { width: 16px; height: 16px; }
|
|
266
|
+
.badge svg.ic { fill: none; stroke: currentColor; }
|
|
266
267
|
.badge-xs { width: 16px; height: 16px; border-radius: 4px; border: 0; background: none; }
|
|
267
268
|
.badge-xs svg { width: 13px; height: 13px; }
|
|
268
269
|
.chip { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: var(--chip-radius); background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); font: inherit; font-size: 12px; line-height: 20px; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -322,6 +323,8 @@ a.btn:hover { text-decoration: none; }
|
|
|
322
323
|
.btn svg { width: 15px; height: 15px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
323
324
|
.btn:disabled { background: var(--surface-2); border-color: var(--border); color: var(--faint); cursor: not-allowed; }
|
|
324
325
|
.btn-ghost:disabled { background: transparent; border-color: transparent; }
|
|
326
|
+
.btn-danger { border-color: var(--fail); color: var(--fail); }
|
|
327
|
+
.btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--fail) 10%, transparent); border-color: var(--fail); color: var(--fail); }
|
|
325
328
|
/* A busy button is disabled but keeps its variant, so a running action does not look unavailable. */
|
|
326
329
|
.btn[aria-busy=true]:disabled { cursor: progress; color: var(--muted); }
|
|
327
330
|
.btn-primary[aria-busy=true]:disabled { background: var(--primary); border-color: var(--primary-border); color: var(--on-primary); opacity: .85; }
|
|
@@ -433,6 +436,16 @@ select:is(:hover, :focus) { --chevron: var(--text); }
|
|
|
433
436
|
.field-hint { color: var(--muted); font-size: var(--small); }
|
|
434
437
|
.field-hint:empty { display: none; }
|
|
435
438
|
.field-error:empty { display: none; }
|
|
439
|
+
.field-error.field-note { color: var(--muted); }
|
|
440
|
+
/* Grok Bot windows (ADR-0019): a simple row of their own, since they have no quota, usage or health columns. */
|
|
441
|
+
.app-row { display: flex; align-items: center; gap: 12px; padding: 11px 10px 11px 16px; border-top: 1px solid var(--border); min-width: 0; }
|
|
442
|
+
.app-row:first-child { border-top: 0; }
|
|
443
|
+
.app-row > .ident { flex: 1; }
|
|
444
|
+
.app-actions { flex: none; justify-content: flex-end; }
|
|
445
|
+
.app-foot { display: flex; flex-direction: column; gap: 6px; padding: 10px 16px 12px; border-top: 1px solid var(--border); }
|
|
446
|
+
.app-foot > .btn { align-self: flex-start; }
|
|
447
|
+
.app-new { display: flex; gap: 8px; align-items: center; max-width: 420px; }
|
|
448
|
+
.app-new input { height: var(--ctl-h-sm); flex: 1; min-width: 0; }
|
|
436
449
|
.fam-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
|
437
450
|
.fam-card { position: relative; display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid var(--border); border-radius: var(--ctl-radius); cursor: pointer; min-width: 0; transition: border-color .12s, background-color .12s; }
|
|
438
451
|
.fam-card:hover { border-color: var(--btn-hover-border); }
|
|
@@ -524,6 +537,8 @@ select:is(:hover, :focus) { --chevron: var(--text); }
|
|
|
524
537
|
|
|
525
538
|
@media (max-width: 1100px) {
|
|
526
539
|
.col-head { display: none; }
|
|
540
|
+
.app-row { flex-wrap: wrap; }
|
|
541
|
+
.app-actions { width: 100%; justify-content: flex-start; }
|
|
527
542
|
.row { grid-template-columns: minmax(0, 1fr) auto var(--m-w); grid-template-areas: "id health more" "quota quota quota" "usage usage last" "notes notes notes"; row-gap: 10px; }
|
|
528
543
|
.row-folded { grid-template-areas: "id id more"; }
|
|
529
544
|
.row > .quota { grid-template-columns: repeat(var(--qn), minmax(0, 1fr)); max-width: 560px; }
|
package/dist/src/panel/page.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The Panel page: one self-contained HTML document with inline CSS and JS, no external resources. The parts live in
|
|
2
2
|
// sibling modules: page-styles.ts (tokens and CSS), page-script.ts (behaviour, with page-logic.ts helpers) and
|
|
3
3
|
// i18n.ts (English and Chinese text); this module assembles them under the CSP nonce.
|
|
4
|
+
import { MARK_SVG } from '../core/brand-mark.js';
|
|
4
5
|
import { ALIAS_NAME, RESERVED_ALIASES } from '../core/config.js';
|
|
5
6
|
import { QUOTA_FAIL_PERCENT, QUOTA_WARN_PERCENT, USAGE_DAYS } from '../core/quota-levels.js';
|
|
6
7
|
import { MESSAGES } from './i18n.js';
|
|
@@ -57,7 +58,7 @@ export function renderPage(opts) {
|
|
|
57
58
|
<header class="top">
|
|
58
59
|
<div class="top-in">
|
|
59
60
|
<div class="brand">
|
|
60
|
-
<span class="logo" aria-hidden="true"
|
|
61
|
+
<span class="logo" aria-hidden="true">${MARK_SVG}</span>
|
|
61
62
|
<div>
|
|
62
63
|
<h1>sideby</h1>
|
|
63
64
|
<p class="tagline" data-i18n="top.tagline">Every AI coding account, side by side.</p>
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -246,11 +246,13 @@ export interface Runtime {
|
|
|
246
246
|
/**
|
|
247
247
|
* Where an Auto Handoff from `origin` would go now (spec §3.17 "选号"): every Account of every Family is a
|
|
248
248
|
* candidate; `used` lists the chain's Accounts so far. `ahead` decides early what happens once the origin's fullest
|
|
249
|
-
* window reaches the threshold, as plan.json and the Panel need. `to` names the only candidate (`handoff ready --to`)
|
|
249
|
+
* window reaches the threshold, as plan.json and the Panel need. `to` names the only candidate (`handoff ready --to`);
|
|
250
|
+
* `manual` (`handoff ready`) never waits for a reset.
|
|
250
251
|
*/
|
|
251
252
|
handoffDecision(origin: Account, used: readonly string[], opts?: {
|
|
252
253
|
ahead?: boolean;
|
|
253
254
|
to?: string;
|
|
255
|
+
manual?: boolean;
|
|
254
256
|
}): Promise<HandoffDecision>;
|
|
255
257
|
readContext(): ReadContext;
|
|
256
258
|
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -668,6 +668,12 @@ export async function createRuntime(opts = {}) {
|
|
|
668
668
|
const self = candidates.find((c) => c.ref === origin.ref);
|
|
669
669
|
if (!self)
|
|
670
670
|
throw new UserError(`${origin.ref} is not an account sideby can find; run \`sideby list\``);
|
|
671
|
+
try {
|
|
672
|
+
self.resumesAtReset = (await familyOf(origin.family).handoff?.resumesAtReset?.(origin)) === true;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
// cannot tell: do not wait
|
|
676
|
+
}
|
|
671
677
|
return selectNext({
|
|
672
678
|
origin: self,
|
|
673
679
|
candidates,
|
|
@@ -677,6 +683,7 @@ export async function createRuntime(opts = {}) {
|
|
|
677
683
|
now: now(),
|
|
678
684
|
...(opts.ahead ? { ahead: true } : {}),
|
|
679
685
|
...(opts.to !== undefined ? { to: opts.to } : {}),
|
|
686
|
+
...(opts.manual ? { manual: true } : {}),
|
|
680
687
|
});
|
|
681
688
|
},
|
|
682
689
|
readContext,
|
package/dist/src/types.d.ts
CHANGED
|
@@ -150,6 +150,11 @@ export interface FamilyHandoff {
|
|
|
150
150
|
}): Promise<string | undefined>;
|
|
151
151
|
/** One-time hook installation for a Host without a launch option (Grok); the same contract as `quotaSetup`. */
|
|
152
152
|
hookSetup?: QuotaSetup;
|
|
153
|
+
/**
|
|
154
|
+
* Whether a session of this Account stopped at the usage limit continues by itself once the limit resets (Claude's
|
|
155
|
+
* `autoContinueAtUsageLimit`). Auto Handoff waits for a reset only when this says true; left out, it never waits.
|
|
156
|
+
*/
|
|
157
|
+
resumesAtReset?(account: Account): Promise<boolean>;
|
|
153
158
|
}
|
|
154
159
|
/** Identity fields of a signed-in Account. */
|
|
155
160
|
export interface AccountIdentity {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sideby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Adonis0123 (https://github.com/Adonis0123)",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
},
|
|
78
78
|
"packageManager": "pnpm@10.15.0",
|
|
79
79
|
"readmeFilename": "README.md",
|
|
80
|
-
"readme": "<p align=\"center\">\n <img alt=\"sideby: work, personal and API-key accounts, one terminal each, one shared config\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg\" width=\"880\">\n</p>\n\n<p align=\"center\">\n <b>Run work, personal and API-key accounts at the same time, without their configs drifting apart.</b><br>\n Each account is its own directory and its own terminal. Skills, hooks and settings stay linked to your main account. Claude Code, Codex, Grok Build and pi.\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/sideby\"><img alt=\"npm\" src=\"https://img.shields.io/npm/v/sideby\"></a>\n <a href=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml/badge.svg\"></a>\n <img alt=\"Node 22.18+\" src=\"https://img.shields.io/badge/node-%E2%89%A522.18-339933\">\n <a href=\"LICENSE\"><img alt=\"MIT\" src=\"https://img.shields.io/badge/license-MIT-blue\"></a>\n</p>\n\n<p align=\"center\">English | <a href=\"README.zh-CN.md\">中文</a></p>\n\n## Why not just an alias?\n\n`CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)\n\n| | At the same time | Skills, hooks, settings | Quota and usage | Setup |\n|---|---|---|---|---|\n| `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |\n| [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |\n| sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |\n\ncc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's \"open terminal\" can start one provider without changing the global config.\n\n## Quick start\n\nRequires Node 22.18+ and at least one Host CLI (Claude Code, Codex, Grok Build or pi) installed. AI agents: follow the setup steps in [`llms.txt`](llms.txt) (\"Set up sideby for a user\") instead.\n\n```sh\nnpm i -g sideby\nsideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks\nsideby login claude:work # sign in once\nsideby run work # Claude Code on the work account\nsideby ui # every account's quota at http://127.0.0.1:17420\n```\n\n`sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.\n\n## Set up with your AI agent\n\nPaste this into Claude Code, Codex or any coding agent:\n\n```text\nInstall the sideby CLI (npm package `sideby`) and its agent skill, then set up a second Claude Code account for me.\nFollow \"Set up sideby for a user\" in https://raw.githubusercontent.com/Adonis0123/sideby/main/llms.txt\n```\n\nThe agent checks Node and your Host CLIs, installs sideby and the skill, creates the account, tells you what it shares with your main one and makes its short command work. You sign in once in your browser; the agent gives you that one command. Once the skill is installed, short requests work too: \"add another Claude account, cc008\", \"what do my Codex accounts share?\", \"which account still has quota?\".\n\n## What it looks like\n\n<img alt=\"Two terminals: the personal account and the work account running at the same time, both linked to the main account's skills and settings, then the local quota panel, and at 95% Auto Handoff moving work's session to the next account\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif\" width=\"1280\">\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\">\n <img alt=\"sideby panel: every account's 5h and 7d quota, 7-day token usage and health in one page\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\" width=\"1280\">\n</picture>\n\n## What else it does\n\n| You want to | Run | Guide |\n|---|---|---|\n| Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |\n| Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |\n| See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |\n| Find the cheapest auto-compact size from your own sessions, and use it in one click | `sideby tune` (or the panel's Context tuning tab) | [Context tuning](docs/guide/usage.md#context-tuning) |\n| See each account's model and effort, and set them per account | `sideby model <account>` (or click the model in the panel) | [Model and effort](docs/guide/usage.md#model-and-effort) |\n| Stop Claude sessions from connecting to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` (or the panel's banner) | [Remote Control](docs/guide/usage.md#remote-control) |\n| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |\n| Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |\n| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |\n| Reopen a session from Orca and the like in the account that holds it | `\"resumeRouting\": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |\n| Open Claude Desktop or the Codex app as one account, several side by side (macOS) | `sideby desktop claude:work` (or the panel) | [Desktop apps](docs/guide/usage.md#desktop-apps) |\n| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |\n| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |\n| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |\n\n## Supported hosts\n\n| Host | Account directories | Sign-in | Quota | Usage (7 days) |\n|---|---|---|---|---|\n| Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |\n| Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |\n| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | no public source | no public source |\n| pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |\n\nsideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).\n\n## Platforms\n\nmacOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 22 and 24. **Windows is untested.** `sideby app` only installs a launcher on macOS and Linux. Linked items are symlinks, `sideby shell-init` prints bash and zsh functions, and the Claude quota wrapper runs `/bin/sh`.\n\n## Roadmap\n\n**Gemini CLI.** Gemini can point at another config directory with `GEMINI_CONFIG_DIR`. Older docs use `GEMINI_CLI_HOME`; current builds treat that name as deprecated, and exit on startup if it is set together with an exact directory override. A sideby family still needs a tested Gemini version, a login check that does not read tokens, and a list of which files to link or copy. That is more than a small patch, so it is not in this version.\n\n## Safe by design\n\n- It never edits a Host's global config to switch accounts. It moves a task to another account only when you turn on Auto Handoff, and never proxies requests or pools logins.\n- It never reads, prints or sends a credential value. Nothing leaves your machine: sideby calls no remote service.\n- `doctor --fix` never replaces a real file and never changes where a link points.\n\nDetails and the terms-of-service question are in the [FAQ](docs/guide/faq.md).\n\nUsage questions and ideas go in [Discussions](https://github.com/Adonis0123/sideby/discussions).\n\n## Docs\n\n- [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes\n- [Commands](docs/guide/commands.md): every command, exit codes and `--json`\n- [Configuration](docs/guide/configuration.md): `config.json`, aliases, shell functions\n- [Panel](docs/guide/panel.md): the panel, panel app and embedding\n- [Plugins](docs/guide/plugins.md): write your own Family or launch hook\n- [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall\n- [Changelog](https://github.com/Adonis0123/sideby/releases)\n\n## For AI agents\n\n- [`llms.txt`](llms.txt): setup steps, commands, JSON contract and plugin API in one index. `sideby families --json` states what each Host's accounts share.\n- [`skills/sideby/SKILL.md`](skills/sideby/SKILL.md): one Agent Skill for seeing, repairing and creating accounts and for moving on when one hits its limit. Install with `npx skills add Adonis0123/sideby -g`; `sideby doctor` warns when the installed copy is for another sideby version.\n- [`AGENTS.md`](AGENTS.md): rules for agents working on this repository.\n\n## License\n\n[MIT](LICENSE)\n"
|
|
80
|
+
"readme": "<p align=\"center\">\n <img alt=\"sideby: work, personal and API-key accounts, one terminal each, one shared config\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg\" width=\"880\">\n</p>\n\n<p align=\"center\">\n <b>Run work, personal and API-key accounts at the same time, without their configs drifting apart.</b><br>\n Each account is its own directory and its own terminal. Skills, hooks and settings stay linked to your main account. Claude Code, Codex, Grok Build and pi.\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/sideby\"><img alt=\"npm\" src=\"https://img.shields.io/npm/v/sideby\"></a>\n <a href=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml/badge.svg\"></a>\n <img alt=\"Node 22.18+\" src=\"https://img.shields.io/badge/node-%E2%89%A522.18-339933\">\n <a href=\"LICENSE\"><img alt=\"MIT\" src=\"https://img.shields.io/badge/license-MIT-blue\"></a>\n</p>\n\n<p align=\"center\">English | <a href=\"README.zh-CN.md\">中文</a></p>\n\n## Quick start\n\nRequires Node 22.18+ and at least one Host CLI (Claude Code, Codex, Grok Build or pi) installed. AI agents: follow the setup steps in [`llms.txt`](llms.txt) (\"Set up sideby for a user\") instead.\n\n```sh\nnpm i -g sideby\nsideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks\nsideby login claude:work # sign in once\nsideby run work # Claude Code on the work account\nsideby quota setup claude --yes # Claude quota in the panel; undo: sideby quota teardown claude\nsideby ui # every account's quota at http://127.0.0.1:17420\n```\n\n`sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.\n\n## Set up with your AI agent\n\nPaste this into Claude Code, Codex or any coding agent:\n\n```text\nInstall the sideby CLI (npm package `sideby`) and its agent skill, then set up a second Claude Code account for me.\nFollow \"Set up sideby for a user\" in https://raw.githubusercontent.com/Adonis0123/sideby/main/llms.txt\n```\n\nThe agent checks Node and your Host CLIs, installs sideby and the skill, creates the account, tells you what it shares with your main one and makes its short command work. You sign in once in your browser; the agent gives you that one command. Once the skill is installed, short requests work too: \"add another Claude account, cc008\", \"what do my Codex accounts share?\", \"which account still has quota?\".\n\n## What it looks like\n\nThe 25-second story: one account buried in tasks, then accounts side by side sharing one setup, and a task moving on when one runs out.\n\nhttps://github.com/user-attachments/assets/3a470c03-9009-4f1d-b7f6-8b4625d74958\n\n<img alt=\"Two terminals: the personal account and the work account running at the same time, both linked to the main account's skills and settings, then the local quota panel, and at 95% Auto Handoff moving work's session to the next account\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif\" width=\"1280\">\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\">\n <img alt=\"sideby panel: every account's 5h and 7d quota, 7-day token usage and health in one page\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\" width=\"1280\">\n</picture>\n\n## Why not just an alias?\n\n`CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)\n\n| | At the same time | Skills, hooks, settings | Quota and usage | Setup |\n|---|---|---|---|---|\n| `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |\n| [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |\n| sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |\n\ncc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's \"open terminal\" can start one provider without changing the global config.\n\n## What else it does\n\n| You want to | Run | Guide |\n|---|---|---|\n| Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |\n| Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |\n| See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |\n| Find the cheapest auto-compact size from your own sessions, and use it in one click | `sideby tune` (or the panel's Context tuning tab) | [Context tuning](docs/guide/usage.md#context-tuning) |\n| See each account's model and effort, and set them per account | `sideby model <account>` (or click the model in the panel) | [Model and effort](docs/guide/usage.md#model-and-effort) |\n| Stop Claude sessions from connecting to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` (or the panel's banner) | [Remote Control](docs/guide/usage.md#remote-control) |\n| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |\n| Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |\n| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |\n| Reopen a session from Orca and the like in the account that holds it | `\"resumeRouting\": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |\n| Open Claude Desktop or the Codex app as one account, several side by side (macOS) | `sideby desktop claude:work` (or the panel) | [Desktop apps](docs/guide/usage.md#desktop-apps) |\n| Keep several Grok Bot windows open, each signed in to its own account (macOS) | `sideby desktop grok-bot:002` (or the panel) | [Grok Bot windows](docs/guide/usage.md#grok-bot-windows) |\n| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |\n| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |\n| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |\n\n## Supported hosts\n\n| Host | Account directories | Sign-in | Quota | Usage (7 days) |\n|---|---|---|---|---|\n| Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |\n| Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |\n| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | no public source | no public source |\n| pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |\n\nsideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).\n\n## Platforms\n\nmacOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 22 and 24. **Windows is untested.** `sideby app` only installs a launcher on macOS and Linux. Linked items are symlinks, `sideby shell-init` prints bash and zsh functions, and the Claude quota wrapper runs `/bin/sh`.\n\n## Roadmap\n\n**Gemini CLI.** Gemini can point at another config directory with `GEMINI_CONFIG_DIR`. Older docs use `GEMINI_CLI_HOME`; current builds treat that name as deprecated, and exit on startup if it is set together with an exact directory override. A sideby family still needs a tested Gemini version, a login check that does not read tokens, and a list of which files to link or copy. That is more than a small patch, so it is not in this version.\n\n## Safe by design\n\n- It never edits a Host's global config to switch accounts. It moves a task to another account only when you turn on Auto Handoff, and never proxies requests or pools logins.\n- It never reads, prints or sends a credential value. Nothing leaves your machine: sideby calls no remote service.\n- `doctor --fix` never replaces a real file and never changes where a link points.\n\nDetails and the terms-of-service question are in the [FAQ](docs/guide/faq.md).\n\nUsage questions and ideas go in [Discussions](https://github.com/Adonis0123/sideby/discussions).\n\n## Docs\n\n- [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes\n- [Commands](docs/guide/commands.md): every command, exit codes and `--json`\n- [Configuration](docs/guide/configuration.md): `config.json`, aliases, shell functions\n- [Panel](docs/guide/panel.md): the panel, panel app and embedding\n- [Plugins](docs/guide/plugins.md): write your own Family or launch hook\n- [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall\n- [Changelog](https://github.com/Adonis0123/sideby/releases)\n\n## For AI agents\n\n- [`llms.txt`](llms.txt): setup steps, commands, JSON contract and plugin API in one index. `sideby families --json` states what each Host's accounts share.\n- [`skills/sideby/SKILL.md`](skills/sideby/SKILL.md): one Agent Skill for seeing, repairing and creating accounts and for moving on when one hits its limit. Install with `npx skills add Adonis0123/sideby -g`; `sideby doctor` warns when the installed copy is for another sideby version.\n- [`AGENTS.md`](AGENTS.md): rules for agents working on this repository.\n\n## License\n\n[MIT](LICENSE)\n"
|
|
81
81
|
}
|