sideby 0.6.0 → 0.8.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 +3 -2
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +2 -0
- package/dist/src/cli/commands.js +44 -5
- package/dist/src/cli/json-schemas.js +37 -0
- package/dist/src/cli/main.js +26 -2
- package/dist/src/core/desktop-app.d.ts +96 -0
- package/dist/src/core/desktop-app.js +382 -0
- package/dist/src/core/doctor.d.ts +6 -1
- package/dist/src/core/doctor.js +19 -0
- package/dist/src/core/handoff-run.d.ts +2 -0
- package/dist/src/core/handoff-run.js +10 -4
- package/dist/src/core/handoff-select.d.ts +9 -0
- package/dist/src/core/handoff-select.js +10 -2
- package/dist/src/core/handoff-status.js +12 -0
- package/dist/src/core/handoff.d.ts +12 -0
- package/dist/src/core/handoff.js +13 -4
- package/dist/src/core/same-login.d.ts +24 -0
- package/dist/src/core/same-login.js +52 -0
- package/dist/src/core/tuning-apply.js +1 -1
- package/dist/src/families/claude/desktop.d.ts +6 -0
- package/dist/src/families/claude/desktop.js +28 -0
- package/dist/src/families/codex/desktop.d.ts +7 -0
- package/dist/src/families/codex/desktop.js +25 -0
- package/dist/src/families/desktop.d.ts +27 -0
- package/dist/src/families/desktop.js +9 -0
- package/dist/src/panel/handler.js +28 -6
- package/dist/src/panel/i18n.d.ts +11 -0
- package/dist/src/panel/i18n.js +22 -0
- package/dist/src/panel/page-logic.d.ts +6 -1
- package/dist/src/panel/page-logic.js +5 -4
- package/dist/src/panel/page-script.js +37 -3
- package/dist/src/runtime.d.ts +26 -0
- package/dist/src/runtime.js +89 -23
- package/dist/src/types.d.ts +5 -0
- package/package.json +2 -2
- package/schemas/desktop.json +80 -0
- package/schemas/list.json +27 -0
- package/schemas/next.json +12 -0
- package/schemas/quota.json +4 -0
- package/skills/sideby/SKILL.md +4 -2
- package/skills/sideby/references/handoff.md +1 -1
- package/skills/sideby/references/quota.md +2 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Families whose Identity tells one Quota apart. Codex is left out: its Identity is an email only, and one email can
|
|
3
|
+
* belong to several ChatGPT workspaces, each with its own Quota (ADR-0016).
|
|
4
|
+
*/
|
|
5
|
+
export const SAME_LOGIN_FAMILIES = new Set(['claude']);
|
|
6
|
+
const norm = (s) => s?.trim().toLowerCase();
|
|
7
|
+
/**
|
|
8
|
+
* Whether two Accounts are the same login: same Family (one that groups), both subscriptions, the same email, and the
|
|
9
|
+
* same organization (both have one and it is equal, or neither has one). Without an email nothing groups.
|
|
10
|
+
*/
|
|
11
|
+
export function isSameLogin(a, b) {
|
|
12
|
+
if (a.ref === b.ref || a.family !== b.family || !SAME_LOGIN_FAMILIES.has(a.family))
|
|
13
|
+
return false;
|
|
14
|
+
if (a.kind !== 'subscription' || b.kind !== 'subscription')
|
|
15
|
+
return false;
|
|
16
|
+
const ea = norm(a.identity?.email);
|
|
17
|
+
if (!ea || ea !== norm(b.identity?.email))
|
|
18
|
+
return false;
|
|
19
|
+
return norm(a.identity?.org) === norm(b.identity?.org);
|
|
20
|
+
}
|
|
21
|
+
/** Ref → the other refs of its Same Login, in the order given; Accounts without one are left out. */
|
|
22
|
+
export function sameLoginGroups(members) {
|
|
23
|
+
const out = new Map();
|
|
24
|
+
for (const a of members) {
|
|
25
|
+
const peers = members.filter((b) => isSameLogin(a, b)).map((b) => b.ref);
|
|
26
|
+
if (peers.length)
|
|
27
|
+
out.set(a.ref, peers);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The Quota an Account of a Same Login shows: the freshest `ok` reading of the group (its own wins a tie), marked
|
|
33
|
+
* with `from` when it is another member's. Without any `ok` reading the Account keeps its own result.
|
|
34
|
+
*/
|
|
35
|
+
export function sharedQuota(ref, readings, peers) {
|
|
36
|
+
const own = readings.get(ref) ?? { status: 'unavailable', reason: 'unrecognized' };
|
|
37
|
+
let best;
|
|
38
|
+
for (const r of [ref, ...peers]) {
|
|
39
|
+
const q = readings.get(r);
|
|
40
|
+
if (q?.status !== 'ok')
|
|
41
|
+
continue;
|
|
42
|
+
const t = Date.parse(q.observedAt);
|
|
43
|
+
if (!Number.isFinite(t))
|
|
44
|
+
continue;
|
|
45
|
+
if (!best || t > best.t)
|
|
46
|
+
best = { ref: r, q, t };
|
|
47
|
+
}
|
|
48
|
+
if (!best || best.ref === ref)
|
|
49
|
+
return own;
|
|
50
|
+
const { from: _from, ...reading } = best.q;
|
|
51
|
+
return { ...reading, from: best.ref };
|
|
52
|
+
}
|
|
@@ -32,7 +32,7 @@ function render(obj, original) {
|
|
|
32
32
|
}
|
|
33
33
|
async function readObject(file) {
|
|
34
34
|
const st = await stat(file).catch(() => null);
|
|
35
|
-
if (!st
|
|
35
|
+
if (!st?.isFile())
|
|
36
36
|
return { error: 'it is not a regular file' };
|
|
37
37
|
if (st.size > MAX_BYTES)
|
|
38
38
|
return { error: 'it is too large to be a settings file' };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DesktopAppDef } from '../desktop.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Claude Desktop (spec §3.20). Its Code tab honours CLAUDE_CONFIG_DIR; the data folder moves only with
|
|
4
|
+
* `--user-data-dir` (signed builds ignore CLAUDE_USER_DATA_DIR).
|
|
5
|
+
*/
|
|
6
|
+
export declare const claudeDesktop: DesktopAppDef;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Desktop (spec §3.20). Its Code tab honours CLAUDE_CONFIG_DIR; the data folder moves only with
|
|
3
|
+
* `--user-data-dir` (signed builds ignore CLAUDE_USER_DATA_DIR).
|
|
4
|
+
*/
|
|
5
|
+
export const claudeDesktop = {
|
|
6
|
+
family: 'claude',
|
|
7
|
+
title: 'Claude Desktop',
|
|
8
|
+
bundleId: 'com.anthropic.claudefordesktop',
|
|
9
|
+
bundleNames: ['Claude.app'],
|
|
10
|
+
installUrl: 'https://claude.ai/download',
|
|
11
|
+
openArgs: (app, dir, appData) => [
|
|
12
|
+
'-n',
|
|
13
|
+
'-a',
|
|
14
|
+
app,
|
|
15
|
+
'--env',
|
|
16
|
+
`CLAUDE_CONFIG_DIR=${dir}`,
|
|
17
|
+
'--args',
|
|
18
|
+
`--user-data-dir=${appData}`,
|
|
19
|
+
],
|
|
20
|
+
clearVars: ['CLAUDE_USER_DATA_DIR'],
|
|
21
|
+
signInInApp: true,
|
|
22
|
+
sharedFiles: [
|
|
23
|
+
{
|
|
24
|
+
path: 'claude_desktop_config.json',
|
|
25
|
+
mainPath: 'Library/Application Support/Claude/claude_desktop_config.json',
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DesktopAppDef } from '../desktop.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The Codex app (`ChatGPT.app`, spec §3.20). It shares the Account's terminal sign-in through CODEX_HOME. Its own
|
|
4
|
+
* code replaces the path `--user-data-dir` sets, so CODEX_ELECTRON_USER_DATA_PATH goes along (the app's "Codex Demo"
|
|
5
|
+
* recipe); both are internal to the app and may change.
|
|
6
|
+
*/
|
|
7
|
+
export declare const codexDesktop: DesktopAppDef;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Codex app (`ChatGPT.app`, spec §3.20). It shares the Account's terminal sign-in through CODEX_HOME. Its own
|
|
3
|
+
* code replaces the path `--user-data-dir` sets, so CODEX_ELECTRON_USER_DATA_PATH goes along (the app's "Codex Demo"
|
|
4
|
+
* recipe); both are internal to the app and may change.
|
|
5
|
+
*/
|
|
6
|
+
export const codexDesktop = {
|
|
7
|
+
family: 'codex',
|
|
8
|
+
title: 'Codex app',
|
|
9
|
+
bundleId: 'com.openai.codex',
|
|
10
|
+
bundleNames: ['ChatGPT.app', 'Codex.app'],
|
|
11
|
+
installUrl: 'https://openai.com/codex/',
|
|
12
|
+
openArgs: (app, dir, appData) => [
|
|
13
|
+
'-n',
|
|
14
|
+
'--env',
|
|
15
|
+
`CODEX_HOME=${dir}`,
|
|
16
|
+
'--env',
|
|
17
|
+
`CODEX_ELECTRON_USER_DATA_PATH=${appData}`,
|
|
18
|
+
app,
|
|
19
|
+
'--args',
|
|
20
|
+
`--user-data-dir=${appData}`,
|
|
21
|
+
],
|
|
22
|
+
clearVars: ['CODEX_ELECTRON_USER_DATA_PATH'],
|
|
23
|
+
signInInApp: false,
|
|
24
|
+
sharedFiles: [],
|
|
25
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface DesktopAppDef {
|
|
2
|
+
/** The Family id. */
|
|
3
|
+
family: string;
|
|
4
|
+
/** How messages name the app, for example `Claude Desktop`. */
|
|
5
|
+
title: string;
|
|
6
|
+
/** `CFBundleIdentifier` in the app's Info.plist. */
|
|
7
|
+
bundleId: string;
|
|
8
|
+
/** Bundle names to look for first in /Applications and ~/Applications. */
|
|
9
|
+
bundleNames: readonly string[];
|
|
10
|
+
installUrl: string;
|
|
11
|
+
/**
|
|
12
|
+
* `open` arguments for a non-main Account: a new instance (`-n`) with the Account's selecting variable and
|
|
13
|
+
* App Data. `app` is the bundle path, `dir` the Account directory.
|
|
14
|
+
*/
|
|
15
|
+
openArgs(app: string, dir: string, appData: string): string[];
|
|
16
|
+
/** Inherited variables cleared before `open`, besides the Family's Hijack Variables and selecting variable. */
|
|
17
|
+
clearVars: readonly string[];
|
|
18
|
+
/** The first sign-in happens inside the app, through a URL callback another running instance may catch. */
|
|
19
|
+
signInInApp: boolean;
|
|
20
|
+
/** Files of the App Data shared from the Main Account's default App Data as links (`mainPath` relative to HOME). */
|
|
21
|
+
sharedFiles: readonly {
|
|
22
|
+
path: string;
|
|
23
|
+
mainPath: string;
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
/** The Desktop App of a built-in Family id, if it has one. */
|
|
27
|
+
export declare function desktopDef(familyId: string): DesktopAppDef | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Desktop Apps of the built-in Families (spec §3.20, ADR-0015). Internal on purpose: not part of the public
|
|
2
|
+
// FamilyDef, so plugin Families cannot declare one in v1.
|
|
3
|
+
import { claudeDesktop } from './claude/desktop.js';
|
|
4
|
+
import { codexDesktop } from './codex/desktop.js';
|
|
5
|
+
const DESKTOP_APPS = new Map([claudeDesktop, codexDesktop].map((d) => [d.family, d]));
|
|
6
|
+
/** The Desktop App of a built-in Family id, if it has one. */
|
|
7
|
+
export function desktopDef(familyId) {
|
|
8
|
+
return DESKTOP_APPS.get(familyId);
|
|
9
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { aliasesByAccount } from '../core/accounts.js';
|
|
4
4
|
import { aliasArgs, aliasRefs } from '../core/config.js';
|
|
5
|
+
import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
|
|
5
6
|
import { UserError } from '../core/errors.js';
|
|
6
7
|
import { planHandoff } from '../core/handoff.js';
|
|
7
8
|
import { applyHandoffEnable, planHandoffEnable } from '../core/handoff-enable.js';
|
|
@@ -43,7 +44,10 @@ export function handoffByFamily(rt, accounts, now) {
|
|
|
43
44
|
const out = {};
|
|
44
45
|
for (const [id, f] of rt.families) {
|
|
45
46
|
const list = accounts.filter((a) => a.family === id);
|
|
46
|
-
|
|
47
|
+
// Count logins, not directories: a Same Login group (spec §3.21) is one Quota, so it alone is no alternative.
|
|
48
|
+
const subs = list.filter((a) => a.kind === 'subscription');
|
|
49
|
+
const logins = subs.filter((a, i) => !subs.slice(0, i).some((b) => a.sameLogin?.includes(b.ref))).length;
|
|
50
|
+
if (!f.readQuota || logins < 2)
|
|
47
51
|
continue;
|
|
48
52
|
const plan = planHandoff(id, list.map((a) => ({
|
|
49
53
|
ref: a.ref,
|
|
@@ -203,10 +207,11 @@ async function buildState(rt, opts) {
|
|
|
203
207
|
]);
|
|
204
208
|
const health = healthByAccount(history);
|
|
205
209
|
const aliases = aliasesByAccount(aliasRefs(rt.config.aliases), accounts);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
+
// Desktop Apps (spec §3.20): one process listing for every Account, run alongside the per-Account reads.
|
|
211
|
+
const states = await withDesktopApps(rt, accounts, Promise.all(accounts.map((a) => accountState(rt, a, families))));
|
|
212
|
+
const list = states.map((st) => {
|
|
213
|
+
const h = health.get(st.ref);
|
|
214
|
+
const names = aliases.get(st.ref);
|
|
210
215
|
const withArgs = (names ?? [])
|
|
211
216
|
.map((n) => [n, aliasArgs(rt.config.aliases?.[n])])
|
|
212
217
|
.filter(([, args]) => args.length);
|
|
@@ -216,7 +221,7 @@ async function buildState(rt, opts) {
|
|
|
216
221
|
...(names ? { aliases: names } : {}),
|
|
217
222
|
...(withArgs.length ? { aliasArgs: Object.fromEntries(withArgs) } : {}),
|
|
218
223
|
};
|
|
219
|
-
})
|
|
224
|
+
});
|
|
220
225
|
const handoff = handoffByFamily(rt, list, ctx.now);
|
|
221
226
|
return {
|
|
222
227
|
schemaVersion: 1,
|
|
@@ -434,6 +439,23 @@ export function createPanelHandler(opts) {
|
|
|
434
439
|
return { ref: found.ref, ...r, file: tildify(r.file, rt.paths.home) };
|
|
435
440
|
},
|
|
436
441
|
},
|
|
442
|
+
// Opens the Account's Desktop App, or brings its window to the front (spec §3.20). It creates the Account's
|
|
443
|
+
// app data folder and starts a program, so a read-only Panel refuses it (writes defaults to true).
|
|
444
|
+
'/api/account/desktop': {
|
|
445
|
+
async run(body) {
|
|
446
|
+
const account = optString(body, 'account');
|
|
447
|
+
if (!account)
|
|
448
|
+
throw new HttpError(400, '"account" is required');
|
|
449
|
+
const rt = await getRuntime();
|
|
450
|
+
const r = await openDesktopApp(rt, account);
|
|
451
|
+
const home = rt.paths.home;
|
|
452
|
+
return {
|
|
453
|
+
...r,
|
|
454
|
+
appPath: tildify(r.appPath, home),
|
|
455
|
+
...(r.appData ? { appData: tildify(r.appData, home) } : {}),
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
},
|
|
437
459
|
// The Auto Handoff page (spec §3.17): where work would go next and the last hand-overs. Reads only.
|
|
438
460
|
'/api/handoff/overview': {
|
|
439
461
|
writes: () => false,
|
package/dist/src/panel/i18n.d.ts
CHANGED
|
@@ -134,6 +134,8 @@ declare const en: {
|
|
|
134
134
|
'acct.next': string;
|
|
135
135
|
'acct.nextHint': string;
|
|
136
136
|
'acct.nextLabel': string;
|
|
137
|
+
'acct.sameLogin': string;
|
|
138
|
+
'acct.sameLoginHint': string;
|
|
137
139
|
'acct.notInstalled': string;
|
|
138
140
|
'acct.readError': string;
|
|
139
141
|
'acct.installHost': string;
|
|
@@ -148,6 +150,7 @@ declare const en: {
|
|
|
148
150
|
'quota.resetNoData': string;
|
|
149
151
|
'quota.dataFrom': string;
|
|
150
152
|
'quota.asOf': string;
|
|
153
|
+
'quota.sharedFrom': string;
|
|
151
154
|
'quota.noWindows': string;
|
|
152
155
|
'quota.off': string;
|
|
153
156
|
'quota.turnOnFamily': string;
|
|
@@ -234,6 +237,14 @@ declare const en: {
|
|
|
234
237
|
'menu.like': string;
|
|
235
238
|
'menu.tune': string;
|
|
236
239
|
'menu.model': string;
|
|
240
|
+
'desk.open': string;
|
|
241
|
+
'desk.show': string;
|
|
242
|
+
'desk.opening': string;
|
|
243
|
+
'desk.openHint': string;
|
|
244
|
+
'desk.showHint': string;
|
|
245
|
+
'desk.started': string;
|
|
246
|
+
'desk.focused': string;
|
|
247
|
+
'desk.failed': string;
|
|
237
248
|
'md.title': string;
|
|
238
249
|
'md.intro': string;
|
|
239
250
|
'md.model': string;
|
package/dist/src/panel/i18n.js
CHANGED
|
@@ -136,6 +136,8 @@ const en = {
|
|
|
136
136
|
'acct.next': 'Next',
|
|
137
137
|
'acct.nextHint': 'The account to move on to when this family runs low: most quota left. Click to copy `sideby next {family}`.',
|
|
138
138
|
'acct.nextLabel': '{ref} is next: copy `sideby next {family}`',
|
|
139
|
+
'acct.sameLogin': 'Same login as {names}',
|
|
140
|
+
'acct.sameLoginHint': 'Signed in as the same login as {names}. The vendor counts one quota for all of them, so they show the same quota and a handoff never moves between them.',
|
|
139
141
|
'acct.notInstalled': 'Tool missing',
|
|
140
142
|
'acct.readError': 'Could not read this account.',
|
|
141
143
|
'acct.installHost': '{host} is not installed on this machine. {link}',
|
|
@@ -150,6 +152,7 @@ const en = {
|
|
|
150
152
|
'quota.resetNoData': 'Window reset · no new data since {date}',
|
|
151
153
|
'quota.dataFrom': 'Data from {ago}',
|
|
152
154
|
'quota.asOf': 'As of {ago}',
|
|
155
|
+
'quota.sharedFrom': 'Same login as {name}; quota read from {name}, {ago}',
|
|
153
156
|
'quota.noWindows': 'No quota windows reported.',
|
|
154
157
|
'quota.off': 'Quota not on',
|
|
155
158
|
'quota.turnOnFamily': 'Turn on quota',
|
|
@@ -236,6 +239,14 @@ const en = {
|
|
|
236
239
|
'menu.like': 'New account like this',
|
|
237
240
|
'menu.tune': 'Context tuning',
|
|
238
241
|
'menu.model': 'Model and effort…',
|
|
242
|
+
'desk.open': 'Open desktop app',
|
|
243
|
+
'desk.show': 'Desktop app open',
|
|
244
|
+
'desk.opening': 'Opening {app}…',
|
|
245
|
+
'desk.openHint': 'Open {app} as {ref}, in its own window next to your other accounts',
|
|
246
|
+
'desk.showHint': '{app} is open as {ref}; click to bring it to the front',
|
|
247
|
+
'desk.started': 'Opened {app} as {ref}.',
|
|
248
|
+
'desk.focused': '{app} for {ref} is in front.',
|
|
249
|
+
'desk.failed': 'Could not open the desktop app for {ref}: {msg}',
|
|
239
250
|
'md.title': 'Model and effort',
|
|
240
251
|
'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
252
|
'md.model': 'Model',
|
|
@@ -585,6 +596,8 @@ const zh = {
|
|
|
585
596
|
'acct.next': '下一个',
|
|
586
597
|
'acct.nextHint': '这个家族额度用完时换到这里:剩余额度最多。点击复制 `sideby next {family}`。',
|
|
587
598
|
'acct.nextLabel': '{ref} 是下一个:复制 `sideby next {family}`',
|
|
599
|
+
'acct.sameLogin': '与 {names} 同一登录',
|
|
600
|
+
'acct.sameLoginHint': '与 {names} 用同一个身份登录。供应商只算一份额度,所以它们显示同一份额度,交接也不会在它们之间进行。',
|
|
588
601
|
'acct.notInstalled': '未安装工具',
|
|
589
602
|
'acct.readError': '无法读取这个账号。',
|
|
590
603
|
'acct.installHost': '本机没有安装 {host}。{link}',
|
|
@@ -599,6 +612,7 @@ const zh = {
|
|
|
599
612
|
'quota.resetNoData': '已重置,{date}后无新数据',
|
|
600
613
|
'quota.dataFrom': '数据来自 {ago}',
|
|
601
614
|
'quota.asOf': '截至 {ago}',
|
|
615
|
+
'quota.sharedFrom': '与 {name} 同一登录;额度读自 {name},{ago}',
|
|
602
616
|
'quota.noWindows': '没有额度窗口数据。',
|
|
603
617
|
'quota.off': '额度未开启',
|
|
604
618
|
'quota.turnOnFamily': '开启额度显示',
|
|
@@ -685,6 +699,14 @@ const zh = {
|
|
|
685
699
|
'menu.like': '照此新建账号',
|
|
686
700
|
'menu.tune': '上下文调优',
|
|
687
701
|
'menu.model': '模型和 effort…',
|
|
702
|
+
'desk.open': '打开桌面应用',
|
|
703
|
+
'desk.show': '桌面应用已打开',
|
|
704
|
+
'desk.opening': '正在打开 {app}…',
|
|
705
|
+
'desk.openHint': '以 {ref} 打开 {app},和其他账号的窗口同时开着',
|
|
706
|
+
'desk.showHint': '{app} 已经以 {ref} 打开,点一下切到前台',
|
|
707
|
+
'desk.started': '已以 {ref} 打开 {app}。',
|
|
708
|
+
'desk.focused': '{ref} 的 {app} 已切到前台。',
|
|
709
|
+
'desk.failed': '没能打开 {ref} 的桌面应用:{msg}',
|
|
688
710
|
'md.title': '模型和 effort',
|
|
689
711
|
'md.intro': '只影响之后用 sideby 启动的 {ref} 新会话(短命令或 sideby run)。保存在 sideby 的配置里,不改 {host} 自己的设置。',
|
|
690
712
|
'md.model': '模型',
|
|
@@ -26,7 +26,11 @@ export interface LogicAccount {
|
|
|
26
26
|
}[];
|
|
27
27
|
observedAt?: string;
|
|
28
28
|
plan?: string;
|
|
29
|
+
/** The Account the reading came from, when it is another Account of the same login (spec §3.21). */
|
|
30
|
+
from?: string;
|
|
29
31
|
};
|
|
32
|
+
/** The other Accounts of its Same Login (spec §3.21). */
|
|
33
|
+
sameLogin?: string[];
|
|
30
34
|
usage?: {
|
|
31
35
|
status: string;
|
|
32
36
|
inputTokens?: number;
|
|
@@ -65,7 +69,8 @@ export declare function passedWindow(w: {
|
|
|
65
69
|
export declare function quotaPressure(a: LogicAccount, now: number): number;
|
|
66
70
|
/**
|
|
67
71
|
* Why an Account needs a look, most serious first: `error`, `health-fail`, `signed-out`, `not-installed`,
|
|
68
|
-
* `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well.
|
|
72
|
+
* `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well. A Quota read from
|
|
73
|
+
* another Account of the same login (`quota.from`, spec §3.21) is that Account's warning, so it counts once per group.
|
|
69
74
|
*/
|
|
70
75
|
export declare function attentionReasons(a: LogicAccount, health: {
|
|
71
76
|
fail: number;
|
|
@@ -55,7 +55,8 @@ export function quotaPressure(a, now) {
|
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
57
|
* Why an Account needs a look, most serious first: `error`, `health-fail`, `signed-out`, `not-installed`,
|
|
58
|
-
* `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well.
|
|
58
|
+
* `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well. A Quota read from
|
|
59
|
+
* another Account of the same login (`quota.from`, spec §3.21) is that Account's warning, so it counts once per group.
|
|
59
60
|
*/
|
|
60
61
|
export function attentionReasons(a, health, now, warn) {
|
|
61
62
|
const out = [];
|
|
@@ -68,7 +69,7 @@ export function attentionReasons(a, health, now, warn) {
|
|
|
68
69
|
if (a.hostInstalled === false)
|
|
69
70
|
out.push('not-installed');
|
|
70
71
|
const q = a.quota;
|
|
71
|
-
if (q && q.status === 'ok' && q.windows)
|
|
72
|
+
if (q && q.status === 'ok' && q.windows && !q.from)
|
|
72
73
|
for (const w of q.windows) {
|
|
73
74
|
const reset = Date.parse(w.resetsAt);
|
|
74
75
|
if (Number.isFinite(reset) && reset <= now)
|
|
@@ -257,8 +258,8 @@ export function suggestName(names) {
|
|
|
257
258
|
if (!taken.work)
|
|
258
259
|
return 'work';
|
|
259
260
|
for (let i = 2;; i++)
|
|
260
|
-
if (!taken[
|
|
261
|
-
return
|
|
261
|
+
if (!taken[`work${i}`])
|
|
262
|
+
return `work${i}`;
|
|
262
263
|
}
|
|
263
264
|
/**
|
|
264
265
|
* A Finding's hint as the Panel shows it: for a fixable Finding, "run `sideby doctor --fix`" becomes the
|
|
@@ -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, highlight: null, fade: true }
|
|
56
|
+
const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, highlight: null, fade: true }
|
|
57
57
|
const $ = (id) => document.getElementById(id)
|
|
58
58
|
|
|
59
59
|
// ---------- preferences ----------
|
|
@@ -573,6 +573,7 @@ const MAIN = String.raw `
|
|
|
573
573
|
const items = [
|
|
574
574
|
{ icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
|
|
575
575
|
...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
|
|
576
|
+
a.desktopApp && a.desktopApp.installed && a.kind !== 'api' && !BOOT.readOnly ? { icon: 'terminal', label: t(a.desktopApp.running ? 'desk.show' : 'desk.open', { app: a.desktopApp.app }), disabled: Boolean(ui.desktopRef), run: () => openDesktop(a) } : null,
|
|
576
577
|
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
577
578
|
a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
|
|
578
579
|
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
@@ -836,7 +837,9 @@ const MAIN = String.raw `
|
|
|
836
837
|
if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
|
|
837
838
|
const stale = isStale(q.observedAt, now)
|
|
838
839
|
const wins = q.windows || []
|
|
839
|
-
|
|
840
|
+
// A reading borrowed from another Account of the same login says where it came from (spec §3.21).
|
|
841
|
+
const from = q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
|
|
842
|
+
return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: from || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })) },
|
|
840
843
|
wins.length ? cols.map((c) => {
|
|
841
844
|
const w = wins.find((x) => x.label === c)
|
|
842
845
|
return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
|
|
@@ -883,6 +886,9 @@ const MAIN = String.raw `
|
|
|
883
886
|
return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
|
|
884
887
|
}),
|
|
885
888
|
a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
|
|
889
|
+
a.sameLogin && a.sameLogin.length
|
|
890
|
+
? h('span', { class: 'tag', title: t('acct.sameLoginHint', { names: a.sameLogin.map(refName).join(', ') }) }, t('acct.sameLogin', { names: a.sameLogin.map(refName).join(', ') }))
|
|
891
|
+
: null,
|
|
886
892
|
isNext(a)
|
|
887
893
|
? h('button', { class: 'tag tag-ok tag-btn', type: 'button', title: t('acct.nextHint', { family: a.family }), 'aria-label': t('acct.nextLabel', { ref: a.ref, family: a.family }), onclick: () => copyText('sideby next ' + a.family) }, t('acct.next'))
|
|
888
894
|
: null,
|
|
@@ -891,6 +897,8 @@ const MAIN = String.raw `
|
|
|
891
897
|
: null,
|
|
892
898
|
a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t('acct.notInstalled')) : null)
|
|
893
899
|
}
|
|
900
|
+
// The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
|
|
901
|
+
function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
|
|
894
902
|
// Who the account is signed in as, under its name; masked while "Hide emails" is on.
|
|
895
903
|
function emailLine(a) {
|
|
896
904
|
const id = a.identity
|
|
@@ -906,8 +914,34 @@ const MAIN = String.raw `
|
|
|
906
914
|
a.kind === 'api' && a.model ? h('span', { class: 'mono', title: t('acct.model') }, a.model) : null,
|
|
907
915
|
a.kind !== 'api' ? modelChip(a) : null,
|
|
908
916
|
a.kind !== 'api' && plan ? h('span', { class: 'plan' }, plan) : null,
|
|
917
|
+
desktopButton(a),
|
|
909
918
|
]))
|
|
910
919
|
}
|
|
920
|
+
// The family's desktop app as this account (spec §3.20): only where it is installed, never for API accounts or a
|
|
921
|
+
// read-only panel. A running window shows as "open"; the click brings it to the front instead of a second one.
|
|
922
|
+
function desktopButton(a) {
|
|
923
|
+
const d = a.desktopApp
|
|
924
|
+
if (!d || !d.installed || a.kind === 'api' || BOOT.readOnly) return null
|
|
925
|
+
const busy = ui.desktopRef === a.ref
|
|
926
|
+
const label = busy ? t('desk.opening', { app: d.app }) : t(d.running ? 'desk.show' : 'desk.open', { app: d.app })
|
|
927
|
+
return h('button', { class: 'tag tag-btn' + (d.running ? ' tag-ok' : ''), type: 'button', disabled: busy, title: t(d.running ? 'desk.showHint' : 'desk.openHint', { app: d.app, ref: a.ref }), 'aria-label': label + ' (' + a.ref + ')', onclick: (e) => { e.stopPropagation(); openDesktop(a) } },
|
|
928
|
+
d.running ? h('span', { class: 'dot dot-ok', 'aria-hidden': 'true' }) : null, label)
|
|
929
|
+
}
|
|
930
|
+
async function openDesktop(a) {
|
|
931
|
+
if (ui.desktopRef) return
|
|
932
|
+
ui.desktopRef = a.ref
|
|
933
|
+
renderAccounts()
|
|
934
|
+
try {
|
|
935
|
+
const r = await api('/api/account/desktop', { account: a.ref })
|
|
936
|
+
const text = r.action === 'started' ? t('desk.started', { app: r.app, ref: r.ref }) : r.action === 'focused' ? t('desk.focused', { app: r.app, ref: r.ref }) : ''
|
|
937
|
+
if (text) toast(text)
|
|
938
|
+
// Notices say what to do next (sign in, close other windows while signing in); they stay a little longer.
|
|
939
|
+
for (const n of r.notices || []) toast(n, { kind: 'info', duration: 12000 })
|
|
940
|
+
} catch (e) { toast(t('desk.failed', { ref: a.ref, msg: e.message }), { kind: 'fail' }) }
|
|
941
|
+
ui.desktopRef = null
|
|
942
|
+
renderAccounts()
|
|
943
|
+
load({ silent: true })
|
|
944
|
+
}
|
|
911
945
|
function notes(a) {
|
|
912
946
|
const fam = family(a.family)
|
|
913
947
|
const out = []
|
|
@@ -920,7 +954,7 @@ const MAIN = String.raw `
|
|
|
920
954
|
}
|
|
921
955
|
// What a row or card shows depends on more than the account (health, view preferences, language); the
|
|
922
956
|
// fingerprint covers what its click handlers use.
|
|
923
|
-
function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a)]) }
|
|
957
|
+
function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref]) }
|
|
924
958
|
// The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
|
|
925
959
|
function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
|
|
926
960
|
function row(a, now, cols) {
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type AccountRefError } from './core/accounts.ts';
|
|
2
2
|
import { type Config, type HandoffSettings } from './core/config.ts';
|
|
3
3
|
import { type CreateResult } from './core/create.ts';
|
|
4
|
+
import { type DesktopDeps } from './core/desktop-app.ts';
|
|
4
5
|
import { type DoctorReport } from './core/doctor.ts';
|
|
5
6
|
import { UserError } from './core/errors.ts';
|
|
6
7
|
import { type HandoffPlan } from './core/handoff.ts';
|
|
@@ -102,8 +103,21 @@ export interface AccountStatus extends Account {
|
|
|
102
103
|
model?: string;
|
|
103
104
|
/** Who the Account is signed in as (email, organization), when its Family can tell. */
|
|
104
105
|
identity?: AccountIdentity;
|
|
106
|
+
/**
|
|
107
|
+
* The other Accounts signed in as the same login, which share its Quota (spec §3.21); absent when there are none.
|
|
108
|
+
*/
|
|
109
|
+
sameLogin?: string[];
|
|
105
110
|
/** Plugin errors met while reading this Account (for example an unreadable settings file). */
|
|
106
111
|
problems?: string[];
|
|
112
|
+
/**
|
|
113
|
+
* The Family's Desktop App for this Account (spec §3.20): macOS only, Subscription Accounts of claude and codex.
|
|
114
|
+
* Filled by `sideby list` and the Panel, which list processes once for all Accounts; `status()` leaves it out.
|
|
115
|
+
*/
|
|
116
|
+
desktopApp?: {
|
|
117
|
+
app: string;
|
|
118
|
+
installed: boolean;
|
|
119
|
+
running: boolean;
|
|
120
|
+
};
|
|
107
121
|
}
|
|
108
122
|
export interface DoctorOptions {
|
|
109
123
|
/** Family id or Account ref; all Accounts when omitted. */
|
|
@@ -187,7 +201,13 @@ export interface Runtime {
|
|
|
187
201
|
* (spec §3.15). Throws UnknownFamilyError, or a UserError when the Family cannot route sessions.
|
|
188
202
|
*/
|
|
189
203
|
resumeLaunch(familyId: string, userArgs: readonly string[]): Promise<ResumeLaunch>;
|
|
204
|
+
/**
|
|
205
|
+
* The Account's Quota. For an Account of a Same Login (spec §3.21) it is the group's freshest reading, with `from`
|
|
206
|
+
* naming the Account it was read from when that is another one.
|
|
207
|
+
*/
|
|
190
208
|
quota(account: Account): Promise<QuotaResult>;
|
|
209
|
+
/** The other Accounts signed in as the same login as this one (spec §3.21), in discovery order; empty when none. */
|
|
210
|
+
sameLogin(account: Account): Promise<string[]>;
|
|
191
211
|
usage(account: Account): Promise<UsageResult>;
|
|
192
212
|
/** Estimated Compaction Threshold per model for one Account (spec §3.18); read-only. */
|
|
193
213
|
tuning(account: Account): Promise<TuningResult>;
|
|
@@ -232,4 +252,10 @@ export declare function createRuntime(opts?: {
|
|
|
232
252
|
env?: Env;
|
|
233
253
|
builtins?: BuiltinPlugin[];
|
|
234
254
|
now?: () => Date;
|
|
255
|
+
/**
|
|
256
|
+
* Internal, for tests: replaces the machine the Desktop App calls use (platform, app folders and the programs
|
|
257
|
+
* `open`, `ps`, `osascript`, `plutil`, `mdfind`; spec §3.20). Omitted, the real machine is used. Not part of
|
|
258
|
+
* the plugin or Portal contract; it may change without notice.
|
|
259
|
+
*/
|
|
260
|
+
desktop?: Partial<DesktopDeps>;
|
|
235
261
|
}): Promise<Runtime>;
|